Discovery OAuth: Validate discovery config and add tests

Refactor the DiscoveryOAuthService to improve robustness and
maintainability.

* Validation:
  - Add validateRootUrl to ensure the configured root-url is valid and
    uses http/https.
  - Add validateDiscoveryDocument to verify that the provider's
    response contains all required OIDC endpoints (issuer, auth,
    token, and userinfo) and that they are absolute URLs.
  - Automatically trim trailing slashes from the root-url to prevent
    double-slashes in the final discovery path.
  - Add null-safety checks in DiscoveryApi.

* Performance:
  - Use Guava's CharStreams for more efficient stream-to-string
    conversion, replacing manual BufferedReader loops.

* Testing:
  - Add DiscoveryApiTest for unit testing the ScribeJava API bridge.
  - Add DiscoveryOAuthServiceTest with high coverage, including
    mocking the discovery process and testing various failure modes
    (malformed URLs, missing JSON fields, and HTTP errors).

Feature: https://github.com/davido/gerrit-oauth-provider/issues/134
Change-Id: I961c0a0e13511c4134e24a98c36e83aa12ea5c3c
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
index 4583e89..0f7df65 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 The Android Open Source Project
+// 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.
@@ -17,14 +17,16 @@
 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 = authorizationUrl;
-    this.accessTokenEndpoint = accessTokenEndpoint;
+    this.authorizationUrl = Objects.requireNonNull(authorizationUrl, "auth url is required");
+    this.accessTokenEndpoint =
+        Objects.requireNonNull(accessTokenEndpoint, "token endpoint is required");
   }
 
   @Override
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 24f732f..8923dcf 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
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 The Android Open Source Project
+// 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.
@@ -15,7 +15,7 @@
 package com.googlesource.gerrit.plugins.oauth.discovery;
 
 import static com.google.gerrit.json.OutputFormat.JSON;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.asString;
 import static org.slf4j.LoggerFactory.getLogger;
 
 import com.github.scribejava.core.model.OAuth2AccessToken;
@@ -23,6 +23,7 @@
 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;
@@ -38,22 +39,26 @@
 import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
 import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
 import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
-import java.io.BufferedReader;
 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;
@@ -64,61 +69,130 @@
     PluginConfig cfg = cfgFactory.create(PROVIDER_NAME);
 
     String rootUrl = cfg.getString(InitOAuth.ROOT_URL);
-    if (!URI.create(rootUrl).isAbsolute()) {
-      throw new ProvisionException("Root URL must be absolute URL");
-    }
+    URI rootUri = validateRootUrl(rootUrl);
 
-    // Fetch the entrypoint from discovery
-    DiscoveryOpenIdConnect discovery = fetchDiscoveryDocument(rootUrl + WELL_KNOWN_PATH);
-
-    // Log the discovery endpoints for debugging
-    log.info(
-        "OpenID Connect discovery:\n"
-            + "issuer: {}\n"
-            + "endpoint:\n"
-            + "\tauth: {}\n"
-            + "\ttoken: {}\n"
-            + "\tuser_info: {}",
-        discovery.getIssuer(),
-        discovery.getAuthorizationEndpoint(),
-        discovery.getTokenEndpoint(),
-        discovery.getUserinfoEndpoint());
-
-    this.userinfoEndpoint = discovery.getUserinfoEndpoint();
+    DiscoveryOpenIdConnect discovery = fetchDiscoveryDocument(rootUri.toString() + WELL_KNOWN_PATH);
+    validateDiscoveryDocument(discovery);
 
     service =
         oauth20ServiceFactory.create(
             PROVIDER_NAME,
             new DiscoveryApi(discovery.getAuthorizationEndpoint(), discovery.getTokenEndpoint()),
-            "openid profile email");
+            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 DiscoveryOpenIdConnect fetchDiscoveryDocument(String discoveryUrl) {
+  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 {
-      URL url = new URL(discoveryUrl);
-      HttpURLConnection connection = (HttpURLConnection) url.openConnection();
-      connection.setRequestMethod("GET");
+      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();
-      if (responseCode != SC_OK) {
-        throw new IOException("Failed to fetch discovery document: " + responseCode);
+      String responseBody;
+
+      try (InputStream in =
+          (responseCode >= 200 && responseCode < 300)
+              ? connection.getInputStream()
+              : connection.getErrorStream()) {
+        responseBody =
+            (in == null)
+                ? ""
+                : CharStreams.toString(new InputStreamReader(in, StandardCharsets.UTF_8));
       }
 
-      StringBuilder response = new StringBuilder();
-      try (BufferedReader reader =
-          new BufferedReader(
-              new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
-        String line;
-        while ((line = reader.readLine()) != null) {
-          response.append(line);
-        }
+      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(response.toString(), DiscoveryOpenIdConnect.class);
+
+      return JSON.newGson().fromJson(responseBody, DiscoveryOpenIdConnect.class);
     } catch (IOException e) {
-      throw new ProvisionException("Cannot fetch OpenID Connect discovery document", e);
+      throw new ProvisionException(
+          "Cannot fetch OpenID Connect discovery document: " + discoveryUrl, e);
+    } finally {
+      if (connection != null) {
+        connection.disconnect();
+      }
     }
   }
 
@@ -128,41 +202,46 @@
     OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw());
     service.signRequest(t, request);
 
+    JsonElement userJson = null;
     try (Response response = service.execute(request)) {
-      if (response.getCode() != SC_OK) {
+      if (response.getCode() != HttpServletResponse.SC_OK) {
         throw new IOException(
             String.format(
                 "Status %s (%s) for request %s",
                 response.getCode(), response.getBody(), request.getUrl()));
       }
-      JsonElement userJson = JSON.newGson().fromJson(response.getBody(), JsonElement.class);
+
+      userJson = JSON.newGson().fromJson(response.getBody(), JsonElement.class);
       if (log.isDebugEnabled()) {
         log.debug("User info response: {}", response.getBody());
       }
-      JsonObject jsonObject = userJson.getAsJsonObject();
-      if (jsonObject == null || jsonObject.isJsonNull()) {
-        throw new IOException("Response doesn't contain valid user info: " + jsonObject);
+
+      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);
       }
-
-      // Try to get user info from standard fields
-      JsonElement sub = jsonObject.get("sub");
-      JsonElement username = getPreferredValue(jsonObject, "preferred_username", "username");
-      JsonElement email = jsonObject.get("email");
-      JsonElement name = getPreferredValue(jsonObject, "name", "display_name");
-
-      return new OAuthUserInfo(
-          extIdScheme + ":" + (sub != null ? sub.getAsString() : ""),
-          username != null && !username.isJsonNull() ? username.getAsString() : null,
-          email != null && !email.isJsonNull() ? email.getAsString() : null,
-          name != null && !name.isJsonNull() ? name.getAsString() : null,
-          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));
   }
 
-  // Get the prefered one from multiple possible vaules
-  private JsonElement getPreferredValue(JsonObject obj, String... keys) {
+  private static JsonElement getPreferredValue(JsonObject obj, String... keys) {
     for (String key : keys) {
       JsonElement value = obj.get(key);
       if (value != null && !value.isJsonNull()) {
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
index 2301663..6608ab6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 The Android Open Source Project
+// 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.
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");
+  }
+}