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