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);
+  }
+}