Merge "[SAP IAS] Fix thread-safety race condition in PKCE handling"
diff --git a/MODULE.bazel b/MODULE.bazel
index 6f47498..83c2e6a 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -48,8 +48,8 @@
 maven.install(
     name = "oauth_plugin_deps",
     artifacts = [
-        "com.fasterxml.jackson.core:jackson-databind:2.10.2",
-        "com.github.scribejava:scribejava-apis:6.9.0",
+        "com.fasterxml.jackson.core:jackson-databind:2.21.1",
+        "com.github.scribejava:scribejava-apis:8.3.3",
         "com.sap.cloud.security:java-security:3.6.0",
     ],
     duplicate_version_warning = "error",
diff --git a/oauth_plugin_deps.lock.json b/oauth_plugin_deps.lock.json
index 034495f..696f5ea 100644
--- a/oauth_plugin_deps.lock.json
+++ b/oauth_plugin_deps.lock.json
@@ -1,25 +1,27 @@
 {
   "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
   "__INPUT_ARTIFACTS_HASH": {
-    "com.fasterxml.jackson.core:jackson-databind": 1009785917,
+    "com.fasterxml.jackson.core:jackson-databind": 1149371740,
     "com.github.ben-manes.caffeine:caffeine": -1640976164,
-    "com.github.scribejava:scribejava-apis": 1477368439,
+    "com.github.scribejava:scribejava-apis": 1954939894,
     "com.sap.cloud.security:java-security": 794433498,
     "commons-io:commons-io": 150368282,
     "org.slf4j:slf4j-api": 2145585153,
     "repositories": -1949687017
   },
   "__RESOLVED_ARTIFACTS_HASH": {
-    "com.fasterxml.jackson.core:jackson-annotations": 1228906799,
-    "com.fasterxml.jackson.core:jackson-annotations:jar:sources": -277300575,
-    "com.fasterxml.jackson.core:jackson-core": 1550936961,
-    "com.fasterxml.jackson.core:jackson-core:jar:sources": -1497775698,
-    "com.fasterxml.jackson.core:jackson-databind": 580126352,
-    "com.fasterxml.jackson.core:jackson-databind:jar:sources": -645758670,
-    "com.github.scribejava:scribejava-apis": -653050744,
-    "com.github.scribejava:scribejava-apis:jar:sources": -1859576969,
-    "com.github.scribejava:scribejava-core": 1406089847,
-    "com.github.scribejava:scribejava-core:jar:sources": -14907717,
+    "com.fasterxml.jackson.core:jackson-annotations": 1145483352,
+    "com.fasterxml.jackson.core:jackson-annotations:jar:sources": -1434301790,
+    "com.fasterxml.jackson.core:jackson-core": -2091189806,
+    "com.fasterxml.jackson.core:jackson-core:jar:sources": 1745403859,
+    "com.fasterxml.jackson.core:jackson-databind": 1402680186,
+    "com.fasterxml.jackson.core:jackson-databind:jar:sources": -63965010,
+    "com.github.scribejava:scribejava-apis": 122120106,
+    "com.github.scribejava:scribejava-apis:jar:sources": 1829844008,
+    "com.github.scribejava:scribejava-core": -1328542772,
+    "com.github.scribejava:scribejava-core:jar:sources": 242143042,
+    "com.github.scribejava:scribejava-java8": 427226495,
+    "com.github.scribejava:scribejava-java8:jar:sources": 1604009525,
     "com.sap.cloud.environment.servicebinding.api:java-access-api": 2007607395,
     "com.sap.cloud.environment.servicebinding.api:java-access-api:jar:sources": -1233227292,
     "com.sap.cloud.environment.servicebinding.api:java-consumption-api": -1817974863,
@@ -44,38 +46,45 @@
   "artifacts": {
     "com.fasterxml.jackson.core:jackson-annotations": {
       "shasums": {
-        "jar": "8c3cba89bf3e03b35a0e6f892c2eb17ed8fdde2e214c3a4c18703d63796bae46",
-        "sources": "9ab9d0a64a8d911bc2dd4edcbf76dcf43af0d9c1835f30a3117f972de55bbe90"
+        "jar": "53ca085f4a150f703f49e1aabd935bd03b43e1ea3d55d135438292af22cef56b",
+        "sources": "71fe6323d91b16d5d1007fd1e1533fa06bb369abde74f68a42a68c0f8f061a8b"
       },
-      "version": "2.10.2"
+      "version": "2.21"
     },
     "com.fasterxml.jackson.core:jackson-core": {
       "shasums": {
-        "jar": "4c41f22a48f6ebb28752baeb6d25bf09ba4ff0ad8bfb82650dde448928b9da4f",
-        "sources": "3f356724ca7a5ba77a18e5448e31d544a4e04b9beca585f680fed81ff80767fb"
+        "jar": "1edd5f2e49dca5f8e4519957c24b7b3050bd1c7ee883920da33cff031ff1f7c0",
+        "sources": "50d16c835cd81fd829036d551c73c40196a988dd6c55d00590e3e0bb41deda32"
       },
-      "version": "2.10.2"
+      "version": "2.21.1"
     },
     "com.fasterxml.jackson.core:jackson-databind": {
       "shasums": {
-        "jar": "42c25644e35fadfbded1b7f35a8d1e70a86737f190e43aa2c56cea4b96cbda88",
-        "sources": "6436b499a6d31caf37b2a03af3d1729c9d53b9971fbf40b9239da8551b3e8db6"
+        "jar": "b011eb5202d9ec889e27f1dcbdf6c63f06a76e7a16c0a1b30c6048d556c9a28e",
+        "sources": "f9f1f15b07eed9bc0fa2a3ab0ee0f7e9ebe3c17dd4ea519cc7509625a57777ca"
       },
-      "version": "2.10.2"
+      "version": "2.21.1"
     },
     "com.github.scribejava:scribejava-apis": {
       "shasums": {
-        "jar": "c563bcd2bae1f3e671d034ebb18006ddb214174fc0ec8a68ac9fa4cc3fc81000",
-        "sources": "31939f8da8bec28fea7c82dcba56ea565a8bfb9b92bd351eccf10e6c79e8911b"
+        "jar": "fd39f6278f42b55d127c9cae1a5f141883f35a89fd17ca38e629afedca3d6f99",
+        "sources": "6d408e619342bc097dbc2ff794d3044a5c9a2b142351cc40f6241fec0b29c0aa"
       },
-      "version": "6.9.0"
+      "version": "8.3.3"
     },
     "com.github.scribejava:scribejava-core": {
       "shasums": {
-        "jar": "7901774ed1b3d1634a2e50320a0687d499389a6fecf83f788f5297900f6338c0",
-        "sources": "798206cbb8457fa55c2a15a82a39277fafbc54f6ca1a65ce709214c5262eaf64"
+        "jar": "b21eacc12983b0de585b05ef307b35f012e48f2898a4acdba529bbb0d379c443",
+        "sources": "20edab88eea3bb900edf24195e321318dd598c60441eb708f61d2984ad552f3e"
       },
-      "version": "6.9.0"
+      "version": "8.3.3"
+    },
+    "com.github.scribejava:scribejava-java8": {
+      "shasums": {
+        "jar": "d5dd567b7095f21740da30f691d762521d105b07e25bc4d668ffda1c0b5370cc",
+        "sources": "acb7c5008b85efae739eb1502e8ee8b0e8ba06afc1053e820e0a27fff7618746"
+      },
+      "version": "8.3.3"
     },
     "com.sap.cloud.environment.servicebinding.api:java-access-api": {
       "shasums": {
@@ -158,6 +167,10 @@
       "com.github.scribejava:scribejava-core"
     ],
     "com.github.scribejava:scribejava-core": [
+      "com.fasterxml.jackson.core:jackson-databind",
+      "com.github.scribejava:scribejava-java8"
+    ],
+    "com.github.scribejava:scribejava-java8": [
       "com.fasterxml.jackson.core:jackson-databind"
     ],
     "com.sap.cloud.environment.servicebinding.api:java-access-api": [
@@ -205,7 +218,11 @@
       "com.fasterxml.jackson.core.exc",
       "com.fasterxml.jackson.core.filter",
       "com.fasterxml.jackson.core.format",
+      "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_1",
+      "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_1.bte",
+      "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_1.chr",
       "com.fasterxml.jackson.core.io",
+      "com.fasterxml.jackson.core.io.schubfach",
       "com.fasterxml.jackson.core.json",
       "com.fasterxml.jackson.core.json.async",
       "com.fasterxml.jackson.core.sym",
@@ -222,6 +239,7 @@
       "com.fasterxml.jackson.databind.exc",
       "com.fasterxml.jackson.databind.ext",
       "com.fasterxml.jackson.databind.introspect",
+      "com.fasterxml.jackson.databind.jdk14",
       "com.fasterxml.jackson.databind.json",
       "com.fasterxml.jackson.databind.jsonFormatVisitors",
       "com.fasterxml.jackson.databind.jsonschema",
@@ -233,23 +251,29 @@
       "com.fasterxml.jackson.databind.ser.impl",
       "com.fasterxml.jackson.databind.ser.std",
       "com.fasterxml.jackson.databind.type",
-      "com.fasterxml.jackson.databind.util"
+      "com.fasterxml.jackson.databind.util",
+      "com.fasterxml.jackson.databind.util.internal"
     ],
     "com.github.scribejava:scribejava-apis": [
       "com.github.scribejava.apis",
       "com.github.scribejava.apis.facebook",
       "com.github.scribejava.apis.fitbit",
+      "com.github.scribejava.apis.google",
       "com.github.scribejava.apis.imgur",
+      "com.github.scribejava.apis.instagram",
       "com.github.scribejava.apis.mailru",
       "com.github.scribejava.apis.microsoftazureactivedirectory",
       "com.github.scribejava.apis.odnoklassniki",
       "com.github.scribejava.apis.openid",
+      "com.github.scribejava.apis.polar",
       "com.github.scribejava.apis.salesforce",
+      "com.github.scribejava.apis.slack",
       "com.github.scribejava.apis.tutby",
       "com.github.scribejava.apis.vk",
       "com.github.scribejava.apis.wunderlist"
     ],
     "com.github.scribejava:scribejava-core": [
+      "com.github.scribejava.core.base64",
       "com.github.scribejava.core.builder",
       "com.github.scribejava.core.builder.api",
       "com.github.scribejava.core.exceptions",
@@ -257,7 +281,6 @@
       "com.github.scribejava.core.httpclient",
       "com.github.scribejava.core.httpclient.jdk",
       "com.github.scribejava.core.httpclient.multipart",
-      "com.github.scribejava.core.java8",
       "com.github.scribejava.core.model",
       "com.github.scribejava.core.oauth",
       "com.github.scribejava.core.oauth2",
@@ -268,6 +291,9 @@
       "com.github.scribejava.core.services",
       "com.github.scribejava.core.utils"
     ],
+    "com.github.scribejava:scribejava-java8": [
+      "com.github.scribejava.java8.base64"
+    ],
     "com.sap.cloud.environment.servicebinding.api:java-access-api": [
       "com.sap.cloud.environment.servicebinding.api",
       "com.sap.cloud.environment.servicebinding.api.exception"
@@ -333,6 +359,8 @@
       "com.github.scribejava:scribejava-apis:jar:sources",
       "com.github.scribejava:scribejava-core",
       "com.github.scribejava:scribejava-core:jar:sources",
+      "com.github.scribejava:scribejava-java8",
+      "com.github.scribejava:scribejava-java8:jar:sources",
       "com.sap.cloud.environment.servicebinding.api:java-access-api",
       "com.sap.cloud.environment.servicebinding.api:java-access-api:jar:sources",
       "com.sap.cloud.environment.servicebinding.api:java-consumption-api",
diff --git a/oauth_third_party_runtime_jars.allowlist.txt b/oauth_third_party_runtime_jars.allowlist.txt
index 15953aa..899941d 100644
--- a/oauth_third_party_runtime_jars.allowlist.txt
+++ b/oauth_third_party_runtime_jars.allowlist.txt
@@ -12,4 +12,5 @@
 json
 scribejava-apis
 scribejava-core
+scribejava-java8
 token-client
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
index 6ba00aa..9a60b59 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
@@ -24,6 +24,7 @@
 import com.googlesource.gerrit.plugins.oauth.cas.CasOAuthService;
 import com.googlesource.gerrit.plugins.oauth.cognito.CognitoOAuthService;
 import com.googlesource.gerrit.plugins.oauth.dex.DexOAuthService;
+import com.googlesource.gerrit.plugins.oauth.discovery.DiscoveryOAuthService;
 import com.googlesource.gerrit.plugins.oauth.facebook.FacebookOAuthService;
 import com.googlesource.gerrit.plugins.oauth.github.GitHubOAuthService;
 import com.googlesource.gerrit.plugins.oauth.gitlab.GitLabOAuthService;
@@ -52,6 +53,7 @@
     install(new OAuthServiceModule(cfgFactory, CasOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, CognitoOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, DexOAuthService.class));
+    install(new OAuthServiceModule(cfgFactory, DiscoveryOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, FacebookOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, GitHubOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, GitLabOAuthService.class));
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 dbd8cd5..b28b754 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -31,6 +31,7 @@
 import com.googlesource.gerrit.plugins.oauth.cas.CasOAuthService;
 import com.googlesource.gerrit.plugins.oauth.cognito.CognitoOAuthService;
 import com.googlesource.gerrit.plugins.oauth.dex.DexOAuthService;
+import com.googlesource.gerrit.plugins.oauth.discovery.DiscoveryOAuthService;
 import com.googlesource.gerrit.plugins.oauth.facebook.FacebookOAuthService;
 import com.googlesource.gerrit.plugins.oauth.github.GitHubOAuthService;
 import com.googlesource.gerrit.plugins.oauth.gitlab.GitLabOAuthService;
@@ -81,6 +82,7 @@
   private final Section auth0OAuthProviderSection;
   private final Section authentikOAuthProviderSection;
   private final Section cognitoOAuthProviderSection;
+  private final Section discoveryOAuthProviderSection;
 
   @Inject
   InitOAuth(ConsoleUI ui, Section.Factory sections, @PluginName String pluginName) {
@@ -105,6 +107,7 @@
     this.authentikOAuthProviderSection = getConfigSection(AuthentikOAuthService.class);
     this.cognitoOAuthProviderSection = getConfigSection(CognitoOAuthService.class);
     this.iasOAuthProviderSection = getConfigSection(SAPIasOAuthService.class);
+    this.discoveryOAuthProviderSection = getConfigSection(DiscoveryOAuthService.class);
   }
 
   @Override
@@ -252,6 +255,16 @@
       cognitoOAuthProviderSection.string(
           "Link to existing Gerrit LDAP accounts?", LINK_TO_EXISTING_GERRIT_ACCOUNT, "false");
     }
+
+    boolean configureDiscoveryOAuthProvider =
+        ui.yesno(
+            isConfigured(discoveryOAuthProviderSection),
+            "Use Well Known Discovery OAuth provider for Gerrit login?");
+    if (configureDiscoveryOAuthProvider && configureOAuth(discoveryOAuthProviderSection)) {
+      checkRootUrl(
+          discoveryOAuthProviderSection.string(
+              "Discovery Root URL(before `/.well-known')", ROOT_URL, null));
+    }
   }
 
   /**
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
new file mode 100644
index 0000000..0f7df65
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
@@ -0,0 +1,46 @@
+// 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.discovery;
+
+import com.github.scribejava.core.builder.api.DefaultApi20;
+import com.github.scribejava.core.oauth2.clientauthentication.ClientAuthentication;
+import com.github.scribejava.core.oauth2.clientauthentication.HttpBasicAuthenticationScheme;
+import java.util.Objects;
+
+public class DiscoveryApi extends DefaultApi20 {
+  private final String authorizationUrl;
+  private final String accessTokenEndpoint;
+
+  public DiscoveryApi(String authorizationUrl, String accessTokenEndpoint) {
+    this.authorizationUrl = Objects.requireNonNull(authorizationUrl, "auth url is required");
+    this.accessTokenEndpoint =
+        Objects.requireNonNull(accessTokenEndpoint, "token endpoint is required");
+  }
+
+  @Override
+  public String getAuthorizationBaseUrl() {
+    return authorizationUrl;
+  }
+
+  @Override
+  public String getAccessTokenEndpoint() {
+    return accessTokenEndpoint;
+  }
+
+  @Override
+  public ClientAuthentication getClientAuthentication() {
+    return HttpBasicAuthenticationScheme.instance();
+  }
+}
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
new file mode 100644
index 0000000..8923dcf
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java
@@ -0,0 +1,281 @@
+// 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.discovery;
+
+import static com.google.gerrit.json.OutputFormat.JSON;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.asString;
+import static org.slf4j.LoggerFactory.getLogger;
+
+import com.github.scribejava.core.model.OAuth2AccessToken;
+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.OAuth20Service;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+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.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.oauth.InitOAuth;
+import com.googlesource.gerrit.plugins.oauth.OAuth20ServiceFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
+import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+
+@Singleton
+@OAuthServiceProviderConfig(name = DiscoveryOAuthService.PROVIDER_NAME)
+public class DiscoveryOAuthService implements OAuthServiceProvider {
+  private static final Logger log = getLogger(DiscoveryOAuthService.class);
+
+  public static final String PROVIDER_NAME = "discovery";
+  private static final String WELL_KNOWN_PATH = "/.well-known/openid-configuration";
+  private static final String SCOPE = "openid profile email";
+
+  private final OAuth20Service service;
+  private final String extIdScheme;
+  private final String userinfoEndpoint;
+
+  @Inject
+  DiscoveryOAuthService(
+      OAuthPluginConfigFactory cfgFactory, OAuth20ServiceFactory oauth20ServiceFactory) {
+    PluginConfig cfg = cfgFactory.create(PROVIDER_NAME);
+
+    String rootUrl = cfg.getString(InitOAuth.ROOT_URL);
+    URI rootUri = validateRootUrl(rootUrl);
+
+    DiscoveryOpenIdConnect discovery = fetchDiscoveryDocument(rootUri.toString() + WELL_KNOWN_PATH);
+    validateDiscoveryDocument(discovery);
+
+    service =
+        oauth20ServiceFactory.create(
+            PROVIDER_NAME,
+            new DiscoveryApi(discovery.getAuthorizationEndpoint(), discovery.getTokenEndpoint()),
+            SCOPE);
+
+    userinfoEndpoint = discovery.getUserinfoEndpoint();
+    extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME);
+
+    if (log.isDebugEnabled()) {
+      log.debug("OAuth2: discovery issuer={}", discovery.getIssuer());
+      log.debug("OAuth2: authorization endpoint={}", discovery.getAuthorizationEndpoint());
+      log.debug("OAuth2: token endpoint={}", discovery.getTokenEndpoint());
+      log.debug("OAuth2: userinfo endpoint={}", discovery.getUserinfoEndpoint());
+    }
+  }
+
+  private URI validateRootUrl(String rootUrl) {
+    return validateUrl(
+        rootUrl,
+        true,
+        "Root URL must be configured",
+        "Root URL is not a valid URL",
+        "Root URL must be absolute URL",
+        "Root URL must use http or https");
+  }
+
+  private void validateDiscoveryDocument(DiscoveryOpenIdConnect discovery) {
+    if (discovery == null) {
+      throw new ProvisionException("Discovery document is empty");
+    }
+
+    validateUrlField("issuer", discovery.getIssuer());
+    validateUrlField("authorization_endpoint", discovery.getAuthorizationEndpoint());
+    validateUrlField("token_endpoint", discovery.getTokenEndpoint());
+    validateUrlField("userinfo_endpoint", discovery.getUserinfoEndpoint());
+  }
+
+  private void validateUrlField(String fieldName, String value) {
+    validateUrl(
+        value,
+        false,
+        "Discovery document missing required field: " + fieldName,
+        "Discovery document field is not a valid URL: " + fieldName,
+        "Discovery document field must be absolute URL: " + fieldName,
+        "Discovery document field must use http or https: " + fieldName);
+  }
+
+  private static URI validateUrl(
+      String value,
+      boolean trimTrailingSlashes,
+      String missingMessage,
+      String invalidMessage,
+      String absoluteMessage,
+      String schemeMessage) {
+    if (value == null || value.isBlank()) {
+      throw new ProvisionException(missingMessage);
+    }
+
+    String normalizedValue = trimTrailingSlashes ? value.replaceAll("/+$", "") : value;
+
+    URI uri;
+    try {
+      uri = URI.create(normalizedValue);
+    } catch (IllegalArgumentException e) {
+      throw new ProvisionException(invalidMessage, e);
+    }
+
+    if (!uri.isAbsolute()) {
+      throw new ProvisionException(absoluteMessage);
+    }
+
+    if (uri.getScheme() == null
+        || (!"http".equalsIgnoreCase(uri.getScheme())
+            && !"https".equalsIgnoreCase(uri.getScheme()))) {
+      throw new ProvisionException(schemeMessage);
+    }
+
+    return uri;
+  }
+
+  DiscoveryOpenIdConnect fetchDiscoveryDocument(String discoveryUrl) {
+    HttpURLConnection connection = null;
+    try {
+      URL url = URI.create(discoveryUrl).toURL();
+      connection = (HttpURLConnection) url.openConnection();
+      connection.setConnectTimeout(5000);
+      connection.setReadTimeout(5000);
+
+      int responseCode = connection.getResponseCode();
+      String responseBody;
+
+      try (InputStream in =
+          (responseCode >= 200 && responseCode < 300)
+              ? connection.getInputStream()
+              : connection.getErrorStream()) {
+        responseBody =
+            (in == null)
+                ? ""
+                : CharStreams.toString(new InputStreamReader(in, StandardCharsets.UTF_8));
+      }
+
+      if (responseCode != HttpServletResponse.SC_OK) {
+        log.error(
+            "Failed to fetch OIDC discovery from {}. Status: {}. Response: {}",
+            discoveryUrl,
+            responseCode,
+            responseBody);
+        throw new IOException("HTTP " + responseCode);
+      }
+
+      return JSON.newGson().fromJson(responseBody, DiscoveryOpenIdConnect.class);
+    } catch (IOException e) {
+      throw new ProvisionException(
+          "Cannot fetch OpenID Connect discovery document: " + discoveryUrl, e);
+    } finally {
+      if (connection != null) {
+        connection.disconnect();
+      }
+    }
+  }
+
+  @Override
+  public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException {
+    OAuthRequest request = new OAuthRequest(Verb.GET, userinfoEndpoint);
+    OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw());
+    service.signRequest(t, request);
+
+    JsonElement userJson = null;
+    try (Response response = service.execute(request)) {
+      if (response.getCode() != HttpServletResponse.SC_OK) {
+        throw new IOException(
+            String.format(
+                "Status %s (%s) for request %s",
+                response.getCode(), response.getBody(), request.getUrl()));
+      }
+
+      userJson = JSON.newGson().fromJson(response.getBody(), JsonElement.class);
+      if (log.isDebugEnabled()) {
+        log.debug("User info response: {}", response.getBody());
+      }
+
+      if (userJson != null && userJson.isJsonObject()) {
+        JsonObject jsonObject = userJson.getAsJsonObject();
+        JsonElement sub = jsonObject.get("sub");
+        if (sub == null || sub.isJsonNull()) {
+          throw new IOException("Response doesn't contain sub field");
+        }
+
+        JsonElement username = getPreferredValue(jsonObject, "preferred_username", "username");
+        JsonElement email = jsonObject.get("email");
+        JsonElement name = getPreferredValue(jsonObject, "name", "display_name");
+
+        return new OAuthUserInfo(
+            extIdScheme + ":" + sub.getAsString(),
+            asString(username),
+            asString(email),
+            asString(name),
+            null);
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      throw new RuntimeException("Cannot retrieve user info resource", e);
+    }
+
+    throw new IOException(String.format("Invalid JSON '%s': not a JSON Object", userJson));
+  }
+
+  private static JsonElement getPreferredValue(JsonObject obj, String... keys) {
+    for (String key : keys) {
+      JsonElement value = obj.get(key);
+      if (value != null && !value.isJsonNull()) {
+        return value;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public OAuthToken getAccessToken(OAuthVerifier rv) {
+    try {
+      OAuth2AccessToken accessToken = service.getAccessToken(rv.getValue());
+      return new OAuthToken(
+          accessToken.getAccessToken(), accessToken.getTokenType(), accessToken.getRawResponse());
+    } catch (InterruptedException | ExecutionException | IOException e) {
+      String msg = "Cannot retrieve access token";
+      log.error(msg, e);
+      throw new RuntimeException(msg, e);
+    }
+  }
+
+  @Override
+  public String getAuthorizationUrl() {
+    return service.getAuthorizationUrl();
+  }
+
+  @Override
+  public String getVersion() {
+    return service.getVersion();
+  }
+
+  @Override
+  public String getName() {
+    return "Discovery OAuth2";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
new file mode 100644
index 0000000..6608ab6
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
@@ -0,0 +1,55 @@
+// 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.discovery;
+
+import com.google.gson.annotations.SerializedName;
+
+public class DiscoveryOpenIdConnect {
+  @SerializedName("issuer")
+  private String issuer;
+
+  @SerializedName("authorization_endpoint")
+  private String authorizationEndpoint;
+
+  @SerializedName("token_endpoint")
+  private String tokenEndpoint;
+
+  @SerializedName("userinfo_endpoint")
+  private String userinfoEndpoint;
+
+  @SerializedName("jwks_uri")
+  private String jwksUri;
+
+  // Getters
+  public String getIssuer() {
+    return issuer;
+  }
+
+  public String getAuthorizationEndpoint() {
+    return authorizationEndpoint;
+  }
+
+  public String getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  public String getUserinfoEndpoint() {
+    return userinfoEndpoint;
+  }
+
+  public String getJwksUri() {
+    return jwksUri;
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index a1c214a..f13f111 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -94,6 +94,11 @@
     link-to-existing-gerrit-accounts = false
     enable-pkce = false
     enable-resource-owner-password-flow = false
+
+  [plugin "@PLUGIN@-discovery-oauth"]
+    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>"
 ```
 
 When one from the sections above is omitted, OAuth SSO is used.
@@ -368,3 +373,17 @@
 Owner Password Flow can optionally be enabled. This OAuth flow lets the user send the password
 and Gerrit will use that password to authenticate the user against the OAuth server. Note, that
 this flow is not considered to be secure. To enable this flow set `enable-resource-owner-password-flow = true`.
+
+### OpenID Connect Discovery 1.0 URL
+
+This supports the [OpenID Connect Discovery 1.0 spec](https://openid.net/specs/openid-connect-discovery-1_0.html), which is supported by many providers.
+
+The discovery URL looks like below:
+
+  https://idm.example.com/oauth2/openid/:client_id:/.well-known/openid-configuration
+
+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.
+
+Tested providers:
+- Authelia
+- Kanidm
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApiTest.java
new file mode 100644
index 0000000..d250264
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApiTest.java
@@ -0,0 +1,49 @@
+// 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.discovery;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.github.scribejava.core.oauth2.clientauthentication.HttpBasicAuthenticationScheme;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DiscoveryApiTest {
+  private static final String AUTHORIZATION_URL = "https://id.example.com/oauth2/authorize";
+  private static final String TOKEN_URL = "https://id.example.com/oauth2/token";
+
+  private DiscoveryApi api;
+
+  @Before
+  public void setUp() {
+    api = new DiscoveryApi(AUTHORIZATION_URL, TOKEN_URL);
+  }
+
+  @Test
+  public void testGetAuthorizationBaseUrl() {
+    assertThat(api.getAuthorizationBaseUrl()).isEqualTo(AUTHORIZATION_URL);
+  }
+
+  @Test
+  public void testGetAccessTokenEndpoint() {
+    assertThat(api.getAccessTokenEndpoint()).isEqualTo(TOKEN_URL);
+  }
+
+  @Test
+  public void testGetClientAuthentication() {
+    assertThat(api.getClientAuthentication())
+        .isSameInstanceAs(HttpBasicAuthenticationScheme.instance());
+  }
+}
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
new file mode 100644
index 0000000..214664d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthServiceTest.java
@@ -0,0 +1,283 @@
+// 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.discovery;
+
+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.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.OAuth20Service;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.inject.ProvisionException;
+import com.googlesource.gerrit.plugins.oauth.InitOAuth;
+import com.googlesource.gerrit.plugins.oauth.OAuth20ServiceFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
+import java.io.IOException;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DiscoveryOAuthServiceTest {
+  @Mock private OAuthPluginConfigFactory mockConfigFactory;
+  @Mock private PluginConfig mockPluginConfig;
+  @Mock private OAuth20Service mockScribeOAuthService;
+  @Mock private OAuth20ServiceFactory mockServiceFactory;
+
+  private static final String TEST_DISCOVERY_ROOT_URL = "https://id.example.com/realms/gerrit";
+  private static final String TEST_ISSUER = "https://id.example.com/realms/gerrit";
+  private static final String TEST_AUTHORIZATION_ENDPOINT =
+      "https://id.example.com/realms/gerrit/protocol/openid-connect/auth";
+  private static final String TEST_TOKEN_ENDPOINT =
+      "https://id.example.com/realms/gerrit/protocol/openid-connect/token";
+  private static final String TEST_USERINFO_ENDPOINT =
+      "https://id.example.com/realms/gerrit/protocol/openid-connect/userinfo";
+
+  private static final String DISCOVERY_PROVIDER_PREFIX_FOR_TEST = "discovery-oauth:";
+
+  @Before
+  public void setUp() {
+    when(mockConfigFactory.create(DiscoveryOAuthService.PROVIDER_NAME))
+        .thenReturn(mockPluginConfig);
+    when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn(TEST_DISCOVERY_ROOT_URL);
+
+    when(mockServiceFactory.create(anyString(), any(DefaultApi20.class), anyString()))
+        .thenReturn(mockScribeOAuthService);
+  }
+
+  private DiscoveryOpenIdConnect mockDiscoveryDocument(
+      String issuer, String authorizationEndpoint, String tokenEndpoint, String userinfoEndpoint) {
+    DiscoveryOpenIdConnect discovery = mock(DiscoveryOpenIdConnect.class);
+    when(discovery.getIssuer()).thenReturn(issuer);
+    when(discovery.getAuthorizationEndpoint()).thenReturn(authorizationEndpoint);
+    when(discovery.getTokenEndpoint()).thenReturn(tokenEndpoint);
+    when(discovery.getUserinfoEndpoint()).thenReturn(userinfoEndpoint);
+    return discovery;
+  }
+
+  private DiscoveryOpenIdConnect validDiscoveryDocument() {
+    return mockDiscoveryDocument(
+        TEST_ISSUER, TEST_AUTHORIZATION_ENDPOINT, TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT);
+  }
+
+  private DiscoveryOAuthService createServiceWithDiscoveryDoc(DiscoveryOpenIdConnect discovery) {
+    return new DiscoveryOAuthService(mockConfigFactory, mockServiceFactory) {
+      @Override
+      DiscoveryOpenIdConnect fetchDiscoveryDocument(String discoveryUrl) {
+        return discovery;
+      }
+    };
+  }
+
+  private ProvisionException assertProvisionException(DiscoveryOpenIdConnect discovery) {
+    try {
+      createServiceWithDiscoveryDoc(discovery);
+    } catch (ProvisionException e) {
+      return e;
+    }
+    throw new AssertionError("expected ProvisionException");
+  }
+
+  private ProvisionException assertConstructorProvisionException() {
+    try {
+      new DiscoveryOAuthService(mockConfigFactory, mockServiceFactory);
+    } catch (ProvisionException e) {
+      return e;
+    }
+    throw new AssertionError("expected ProvisionException");
+  }
+
+  private void mockUserInfoResponse(String body) throws Exception {
+    Response mockHttpResponse = mock(Response.class);
+    when(mockHttpResponse.getCode()).thenReturn(HttpServletResponse.SC_OK);
+    when(mockHttpResponse.getBody()).thenReturn(body);
+    when(mockScribeOAuthService.execute(any(OAuthRequest.class))).thenReturn(mockHttpResponse);
+  }
+
+  @Test
+  public void getUserInfo_validStandardFields_shouldMapUserInfo() throws Exception {
+    DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument());
+
+    mockUserInfoResponse(
+        "{\"sub\":\"12345\",\"preferred_username\":\"jane.doe\","
+            + "\"email\":\"jane.doe@example.com\",\"name\":\"Jane Doe\"}");
+
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    OAuthUserInfo userInfo = service.getUserInfo(inputToken);
+
+    assertThat(userInfo).isNotNull();
+    assertThat(userInfo.getExternalId()).isEqualTo(DISCOVERY_PROVIDER_PREFIX_FOR_TEST + "12345");
+    assertThat(userInfo.getUserName()).isEqualTo("jane.doe");
+    assertThat(userInfo.getEmailAddress()).isEqualTo("jane.doe@example.com");
+    assertThat(userInfo.getDisplayName()).isEqualTo("Jane Doe");
+    assertThat(userInfo.getClaimedIdentity()).isNull();
+  }
+
+  @Test
+  public void getUserInfo_fallbackFields_shouldMapUserInfo() throws Exception {
+    DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument());
+
+    mockUserInfoResponse(
+        "{\"sub\":\"67890\",\"username\":\"john\","
+            + "\"email\":\"john@example.com\",\"display_name\":\"John Doe\"}");
+
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    OAuthUserInfo userInfo = service.getUserInfo(inputToken);
+
+    assertThat(userInfo).isNotNull();
+    assertThat(userInfo.getExternalId()).isEqualTo(DISCOVERY_PROVIDER_PREFIX_FOR_TEST + "67890");
+    assertThat(userInfo.getUserName()).isEqualTo("john");
+    assertThat(userInfo.getEmailAddress()).isEqualTo("john@example.com");
+    assertThat(userInfo.getDisplayName()).isEqualTo("John Doe");
+  }
+
+  @Test
+  public void getUserInfo_missingSub_shouldThrowIOException() throws Exception {
+    DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument());
+
+    mockUserInfoResponse(
+        "{\"preferred_username\":\"jane.doe\",\"email\":\"jane.doe@example.com\"}");
+
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    try {
+      service.getUserInfo(inputToken);
+    } catch (IOException e) {
+      assertThat(e).hasMessageThat().contains("sub");
+      return;
+    }
+
+    throw new AssertionError("expected IOException");
+  }
+
+  @Test
+  public void getUserInfo_nonObjectJson_shouldThrowIOException() throws Exception {
+    DiscoveryOAuthService service = createServiceWithDiscoveryDoc(validDiscoveryDocument());
+
+    mockUserInfoResponse("[]");
+
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    try {
+      service.getUserInfo(inputToken);
+    } catch (IOException e) {
+      assertThat(e).hasMessageThat().contains("not a JSON Object");
+      return;
+    }
+
+    throw new AssertionError("expected IOException");
+  }
+
+  @Test
+  public void constructor_missingRootUrl_shouldThrowProvisionException() {
+    when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn(null);
+
+    ProvisionException e = assertConstructorProvisionException();
+
+    assertThat(e).hasMessageThat().contains("Root URL must be configured");
+  }
+
+  @Test
+  public void constructor_relativeRootUrl_shouldThrowProvisionException() {
+    when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn("/relative/path");
+
+    ProvisionException e = assertConstructorProvisionException();
+
+    assertThat(e).hasMessageThat().contains("Root URL must be absolute URL");
+  }
+
+  @Test
+  public void constructor_unsupportedRootUrlScheme_shouldThrowProvisionException() {
+    when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn("ftp://id.example.com/realm");
+
+    ProvisionException e = assertConstructorProvisionException();
+
+    assertThat(e).hasMessageThat().contains("Root URL must use http or https");
+  }
+
+  @Test
+  public void constructor_nullDiscoveryDocument_shouldThrowProvisionException() {
+    ProvisionException e = assertProvisionException(null);
+
+    assertThat(e).hasMessageThat().contains("Discovery document is empty");
+  }
+
+  @Test
+  public void constructor_missingIssuer_shouldThrowProvisionException() {
+    DiscoveryOpenIdConnect discovery =
+        mockDiscoveryDocument(
+            null, TEST_AUTHORIZATION_ENDPOINT, TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT);
+
+    ProvisionException e = assertProvisionException(discovery);
+
+    assertThat(e).hasMessageThat().contains("missing required field: issuer");
+  }
+
+  @Test
+  public void constructor_malformedTokenEndpoint_shouldThrowProvisionException() {
+    DiscoveryOpenIdConnect discovery =
+        mockDiscoveryDocument(
+            TEST_ISSUER, TEST_AUTHORIZATION_ENDPOINT, "http://[invalid", TEST_USERINFO_ENDPOINT);
+
+    ProvisionException e = assertProvisionException(discovery);
+
+    assertThat(e).hasMessageThat().contains("not a valid URL: token_endpoint");
+  }
+
+  @Test
+  public void constructor_relativeUserinfoEndpoint_shouldThrowProvisionException() {
+    DiscoveryOpenIdConnect discovery =
+        mockDiscoveryDocument(
+            TEST_ISSUER,
+            TEST_AUTHORIZATION_ENDPOINT,
+            TEST_TOKEN_ENDPOINT,
+            "/protocol/openid-connect/userinfo");
+
+    ProvisionException e = assertProvisionException(discovery);
+
+    assertThat(e).hasMessageThat().contains("userinfo_endpoint");
+    assertThat(e).hasMessageThat().contains("absolute URL");
+  }
+
+  @Test
+  public void constructor_unsupportedUserinfoEndpointScheme_shouldThrowProvisionException() {
+    DiscoveryOpenIdConnect discovery =
+        mockDiscoveryDocument(
+            TEST_ISSUER,
+            TEST_AUTHORIZATION_ENDPOINT,
+            TEST_TOKEN_ENDPOINT,
+            "ftp://id.example.com/realms/gerrit/protocol/openid-connect/userinfo");
+
+    ProvisionException e = assertProvisionException(discovery);
+
+    assertThat(e).hasMessageThat().contains("must use http or https: userinfo_endpoint");
+  }
+}