Merge branch 'stable-3.14' * stable-3.14: Discovery OAuth: Validate discovery config and add tests Discovery OAuth: support generic OAuth via well-known discovery URL Bump scribejava to 8.3.3 and jackson to 2.21.1 Change-Id: I9f8bed23c9ad1e6507a167c74d7d982575c328bc
diff --git a/BUILD b/BUILD index 9c1e3ca..950cad0 100644 --- a/BUILD +++ b/BUILD
@@ -1,20 +1,9 @@ load( "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl", "gerrit_plugin", + "gerrit_plugin_dependency_tests", "gerrit_plugin_tests", ) -load( - "@com_googlesource_gerrit_bazlets//tools:in_gerrit_tree.bzl", - "in_gerrit_tree_enabled", -) -load( - "@com_googlesource_gerrit_bazlets//tools:runtime_jars_allowlist.bzl", - "runtime_jars_allowlist_test", -) -load( - "@com_googlesource_gerrit_bazlets//tools:runtime_jars_overlap.bzl", - "runtime_jars_overlap_test", -) gerrit_plugin( name = "oauth", @@ -47,20 +36,9 @@ ":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", ], ) -runtime_jars_allowlist_test( - name = "check_oauth_third_party_runtime_jars", - allowlist = ":oauth_third_party_runtime_jars.allowlist.txt", - hint = ":check_oauth_third_party_runtime_jars_manifest", - target = ":oauth__plugin", -) - -runtime_jars_overlap_test( - name = "oauth_no_overlap_with_gerrit", - against = "//:headless.war.jars.txt", - hint = "Exclude overlaps via maven.install(excluded_artifacts=[...]) and re-run this test.", - target = ":oauth__plugin", - target_compatible_with = in_gerrit_tree_enabled(), -) +gerrit_plugin_dependency_tests(plugin = "oauth")
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java index b28b754..a1a1dd7 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -264,6 +264,8 @@ checkRootUrl( discoveryOAuthProviderSection.string( "Discovery Root URL(before `/.well-known')", ROOT_URL, null)); + discoveryOAuthProviderSection.string( + "Enable PKCE for Discovery OAuth provider?", ENABLE_PKCE, "false"); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java index 8923dcf..e646564 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java
@@ -22,8 +22,12 @@ import com.github.scribejava.core.model.OAuthRequest; import com.github.scribejava.core.model.Response; import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.AccessTokenRequestParams; +import com.github.scribejava.core.oauth.AuthorizationUrlBuilder; import com.github.scribejava.core.oauth.OAuth20Service; import com.google.common.io.CharStreams; +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; @@ -60,6 +64,7 @@ private static final String SCOPE = "openid profile email"; private final OAuth20Service service; + private final boolean enablePKCE; private final String extIdScheme; private final String userinfoEndpoint; @@ -74,6 +79,7 @@ DiscoveryOpenIdConnect discovery = fetchDiscoveryDocument(rootUri.toString() + WELL_KNOWN_PATH); validateDiscoveryDocument(discovery); + enablePKCE = cfg.getBoolean(InitOAuth.ENABLE_PKCE, false); service = oauth20ServiceFactory.create( PROVIDER_NAME, @@ -252,9 +258,13 @@ } @Override - public OAuthToken getAccessToken(OAuthVerifier rv) { + public OAuthToken getAccessToken(OAuthVerifier rv, @Nullable String codeVerifier) { try { - OAuth2AccessToken accessToken = service.getAccessToken(rv.getValue()); + AccessTokenRequestParams reqParams = AccessTokenRequestParams.create(rv.getValue()); + if (enablePKCE && codeVerifier != null) { + reqParams.pkceCodeVerifier(codeVerifier); + } + OAuth2AccessToken accessToken = service.getAccessToken(reqParams); return new OAuthToken( accessToken.getAccessToken(), accessToken.getTokenType(), accessToken.getRawResponse()); } catch (InterruptedException | ExecutionException | IOException e) { @@ -265,8 +275,16 @@ } @Override - public String getAuthorizationUrl() { - return service.getAuthorizationUrl(); + public OAuthAuthorizationInfo getAuthorizationInfo() { + AuthorizationUrlBuilder builder = service.createAuthorizationUrlBuilder(); + String verifier = null; + + if (enablePKCE) { + builder.initPKCE(); + verifier = builder.getPkce().getCodeVerifier(); + } + + return new OAuthAuthorizationInfo(builder.build(), verifier); } @Override
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/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index f13f111..447b017 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -99,6 +99,7 @@ root-url = "<root url>" # The part before /.well-known. for example, https://kanidm.example.com/oauth2/openid/gerrit client-id = "<client-id>" client-secret = "<client-secret>" + enable-pkce = false ``` When one from the sections above is omitted, OAuth SSO is used. @@ -384,6 +385,8 @@ Set the `root-url` to the part before `/.well-known` of the discovery URL of your OAuth server, and client-id and client-secret in gerrit.config. +You can optionally set `enable-pkce = true` if you want to use PKCE as part of the authorization workflow during login. + Tested providers: - Authelia - Kanidm
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthServiceTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthServiceTest.java index 214664d..8030cf1 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthServiceTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthServiceTest.java
@@ -18,14 +18,20 @@ 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.builder.api.DefaultApi20; import com.github.scribejava.core.model.OAuthRequest; import com.github.scribejava.core.model.Response; +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.OAuthToken; import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo; +import com.google.gerrit.extensions.auth.oauth.OAuthVerifier; import com.google.gerrit.server.config.PluginConfig; import com.google.inject.ProvisionException; import com.googlesource.gerrit.plugins.oauth.InitOAuth; @@ -36,6 +42,7 @@ 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; @@ -117,6 +124,53 @@ } @Test + public void getAuthorizationInfo_withPkceEnabled_shouldReturnVerifier() throws Exception { + when(mockPluginConfig.getBoolean("enable-pkce", false)).thenReturn(true); + + AuthorizationUrlBuilder mockUrlBuilder = mock(AuthorizationUrlBuilder.class); + when(mockScribeOAuthService.createAuthorizationUrlBuilder()).thenReturn(mockUrlBuilder); + + PKCE pkce = new PKCE(); + pkce.setCodeVerifier("secret-verifier-123"); + + when(mockUrlBuilder.getPkce()).thenReturn(pkce); + when(mockUrlBuilder.build()).thenReturn("https://id.example.com/auth?code_challenge=xyz"); + + DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument()); + OAuthAuthorizationInfo info = service.getAuthorizationInfo(); + + assertThat(info.getAuthorizationUrl()).contains("code_challenge=xyz"); + assertThat(info.getPkceVerifier()).isEqualTo("secret-verifier-123"); + } + + @Test + public void getAccessToken_withPkce_shouldPassVerifierToScribe() throws Exception { + when(mockPluginConfig.getBoolean("enable-pkce", false)).thenReturn(true); + DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument()); + + OAuthVerifier verifier = new OAuthVerifier("auth-code"); + String secureVerifierFromSession = "session-secret-verifier"; + + com.github.scribejava.core.model.OAuth2AccessToken mockToken = + mock(com.github.scribejava.core.model.OAuth2AccessToken.class); + + when(mockToken.getAccessToken()).thenReturn("dummy-access-token"); + when(mockToken.getTokenType()).thenReturn("Bearer"); + when(mockToken.getRawResponse()).thenReturn("raw-json-response"); + + when(mockScribeOAuthService.getAccessToken(any(AccessTokenRequestParams.class))) + .thenReturn(mockToken); + + service.getAccessToken(verifier, secureVerifierFromSession); + + ArgumentCaptor<AccessTokenRequestParams> captor = + ArgumentCaptor.forClass(AccessTokenRequestParams.class); + verify(mockScribeOAuthService).getAccessToken(captor.capture()); + + assertThat(captor.getValue().getPkceCodeVerifier()).isEqualTo(secureVerifierFromSession); + } + + @Test public void getUserInfo_validStandardFields_shouldMapUserInfo() throws Exception { DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument());
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); + } +}