Merge "[SAP IAS] Fix thread-safety race condition in PKCE handling"
diff --git a/BUILD b/BUILD
index 9c1e3ca..72d61d8 100644
--- a/BUILD
+++ b/BUILD
@@ -47,6 +47,8 @@
":oauth__plugin",
"@oauth_plugin_deps//:com_github_scribejava_scribejava_apis",
"@oauth_plugin_deps//:com_github_scribejava_scribejava_core",
+ "@oauth_plugin_deps//:com_sap_cloud_security_java_api",
+ "@oauth_plugin_deps//:com_sap_cloud_security_java_security",
],
)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
index 7925e6b..1f7f0e1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
@@ -21,6 +21,8 @@
import com.github.scribejava.core.oauth.AuthorizationUrlBuilder;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.auth.oauth.OAuthAuthorizationInfo;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -56,7 +58,6 @@
private final String rootUrl;
private final boolean linkExistingGerrit;
private final boolean enablePKCE;
- private final AuthorizationUrlBuilder authorizationUrlBuilder;
private final String extIdScheme;
private final CombiningValidator<Token> tokenValidator;
@@ -76,7 +77,6 @@
service =
oauth20ServiceFactory.create(PROVIDER_NAME, new SAPIasApi(rootUrl), "openid profile email");
- authorizationUrlBuilder = service.createAuthorizationUrlBuilder();
extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME);
this.tokenValidator = tokenValidator;
}
@@ -110,11 +110,11 @@
}
@Override
- public OAuthToken getAccessToken(OAuthVerifier rv) {
+ public OAuthToken getAccessToken(OAuthVerifier rv, @Nullable String codeVerifier) {
try {
AccessTokenRequestParams reqParams = AccessTokenRequestParams.create(rv.getValue());
- if (enablePKCE) {
- reqParams.pkceCodeVerifier(authorizationUrlBuilder.getPkce().getCodeVerifier());
+ if (enablePKCE && codeVerifier != null) {
+ reqParams.pkceCodeVerifier(codeVerifier);
}
OAuth2AccessToken accessToken = service.getAccessToken(reqParams);
return new OAuthToken(
@@ -127,11 +127,14 @@
}
@Override
- public String getAuthorizationUrl() {
+ public OAuthAuthorizationInfo getAuthorizationInfo() {
+ AuthorizationUrlBuilder builder = service.createAuthorizationUrlBuilder();
+ String pkceVerifier = null;
if (enablePKCE) {
- authorizationUrlBuilder.initPKCE();
+ builder.initPKCE();
+ pkceVerifier = builder.getPkce().getCodeVerifier();
}
- return authorizationUrlBuilder.build();
+ return new OAuthAuthorizationInfo(builder.build(), pkceVerifier);
}
public OAuth2AccessToken getAccessToken(String externalUsername, String password) {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthServiceTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthServiceTest.java
new file mode 100644
index 0000000..a929d54
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthServiceTest.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.oauth.sap;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.github.scribejava.core.oauth.AccessTokenRequestParams;
+import com.github.scribejava.core.oauth.AuthorizationUrlBuilder;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import com.github.scribejava.core.pkce.PKCE;
+import com.google.gerrit.extensions.auth.oauth.OAuthAuthorizationInfo;
+import com.google.gerrit.extensions.auth.oauth.OAuthVerifier;
+import com.google.gerrit.server.config.PluginConfig;
+import com.googlesource.gerrit.plugins.oauth.InitOAuth;
+import com.googlesource.gerrit.plugins.oauth.OAuth20ServiceFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
+import com.sap.cloud.security.token.Token;
+import com.sap.cloud.security.token.validation.CombiningValidator;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class SAPIasOAuthServiceTest {
+
+ @Mock private OAuthPluginConfigFactory mockConfigFactory;
+ @Mock private PluginConfig mockPluginConfig;
+ @Mock private OAuth20ServiceFactory mockServiceFactory;
+ @Mock private OAuth20Service mockScribeOAuthService;
+ @Mock private CombiningValidator<Token> mockTokenValidator;
+ @Mock private AuthorizationUrlBuilder mockUrlBuilder;
+
+ private static final String TEST_SAP_ROOT_URL = "https://accounts.sap.com";
+
+ @Before
+ public void setUp() {
+ when(mockConfigFactory.create(SAPIasOAuthService.PROVIDER_NAME)).thenReturn(mockPluginConfig);
+ when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn(TEST_SAP_ROOT_URL);
+
+ when(mockServiceFactory.create(
+ anyString(),
+ any(com.github.scribejava.core.builder.api.DefaultApi20.class),
+ anyString()))
+ .thenReturn(mockScribeOAuthService);
+ }
+
+ @Test
+ public void getAuthorizationInfo_withPkce_shouldReturnVerifierFromLocalBuilder() {
+ when(mockPluginConfig.getBoolean(InitOAuth.ENABLE_PKCE, false)).thenReturn(true);
+
+ AuthorizationUrlBuilder mockUrlBuilder = mock(AuthorizationUrlBuilder.class);
+ when(mockScribeOAuthService.createAuthorizationUrlBuilder()).thenReturn(mockUrlBuilder);
+
+ PKCE pkce = new PKCE();
+ pkce.setCodeVerifier("sap-secret-verifier");
+
+ when(mockUrlBuilder.getPkce()).thenReturn(pkce);
+ when(mockUrlBuilder.build()).thenReturn("https://sap.com/auth?code_challenge=xyz");
+
+ SAPIasOAuthService service =
+ new SAPIasOAuthService(mockConfigFactory, mockServiceFactory, mockTokenValidator);
+ OAuthAuthorizationInfo info = service.getAuthorizationInfo();
+
+ assertThat(info.getPkceVerifier()).isEqualTo("sap-secret-verifier");
+ }
+
+ @Test
+ public void getAccessToken_withPkce_shouldUsePassedVerifierIgnoringInternalState()
+ throws Exception {
+ when(mockPluginConfig.getBoolean(InitOAuth.ENABLE_PKCE, false)).thenReturn(true);
+ SAPIasOAuthService service =
+ new SAPIasOAuthService(mockConfigFactory, mockServiceFactory, mockTokenValidator);
+
+ OAuthVerifier verifier = new OAuthVerifier("auth-code");
+ String verifierFromSession = "session-stored-verifier";
+
+ com.github.scribejava.core.model.OAuth2AccessToken mockToken =
+ mock(com.github.scribejava.core.model.OAuth2AccessToken.class);
+
+ when(mockToken.getAccessToken()).thenReturn("token");
+ when(mockToken.getTokenType()).thenReturn("Bearer");
+ when(mockToken.getRawResponse()).thenReturn("dummy-raw");
+
+ when(mockScribeOAuthService.getAccessToken(any(AccessTokenRequestParams.class)))
+ .thenReturn(mockToken);
+
+ service.getAccessToken(verifier, verifierFromSession);
+
+ ArgumentCaptor<AccessTokenRequestParams> captor =
+ ArgumentCaptor.forClass(AccessTokenRequestParams.class);
+ verify(mockScribeOAuthService).getAccessToken(captor.capture());
+
+ assertThat(captor.getValue().getPkceCodeVerifier()).isEqualTo(verifierFromSession);
+ }
+}