From 436ca027ee83697a82ebd3d1091c539e02352a2c Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Tue, 19 Dec 2023 17:43:32 +0000 Subject: [PATCH] Support for OAuth2 Strava --- .../main/asciidoc/images/oidc-strava-1.png | Bin 0 -> 60795 bytes ...ecurity-oidc-code-flow-authentication.adoc | 12 + .../security-openid-connect-providers.adoc | 52 +- .../oidc/common/runtime/OidcCommonConfig.java | 8 +- .../io/quarkus/oidc/OidcTenantConfig.java | 1 + .../runtime/CodeAuthenticationMechanism.java | 7 +- .../io/quarkus/oidc/runtime/OidcProvider.java | 3 +- .../oidc/runtime/OidcProviderClient.java | 68 +- .../io/quarkus/oidc/runtime/OidcUtils.java | 3 + .../runtime/providers/KnownOidcProviders.java | 23 + .../oidc/runtime/KnownOidcProvidersTest.java | 586 ++++++++++++++++++ .../quarkus/oidc/runtime/OidcUtilsTest.java | 521 ---------------- .../src/main/resources/application.properties | 4 +- .../keycloak/CodeFlowAuthorizationTest.java | 107 +++- 14 files changed, 815 insertions(+), 580 deletions(-) create mode 100644 docs/src/main/asciidoc/images/oidc-strava-1.png create mode 100644 extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java diff --git a/docs/src/main/asciidoc/images/oidc-strava-1.png b/docs/src/main/asciidoc/images/oidc-strava-1.png new file mode 100644 index 0000000000000000000000000000000000000000..3a73837972af44b4b7940044f9abe61403e57507 GIT binary patch literal 60795 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 Mry^T$@9*dT1K#SA^8f$< literal 0 HcmV?d00001 diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 6dd7a688dac15d..851fc1e1750e6d 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -266,6 +266,18 @@ quarkus.oidc.tls.trust-store-password=${trust-store-password} #quarkus.oidc.tls.trust-store-alias=certAlias ---- +===== POST query + +Some providers such as the xref:security-openid-connect-providers#strava[Strava OAuth2 provider] require client credentials be posted as HTTP POST query parameters: + +[source,properties] +---- +quarkus.oidc.provider=strava +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.credentials.client-secret.value=mysecret +quarkus.oidc.credentials.client-secret.method=query +---- + ==== Introspection endpoint authentication Some OIDC providers require authenticating to its introspection endpoint by using Basic authentication and with credentials that are different from the `client_id` and `client_secret`. diff --git a/docs/src/main/asciidoc/security-openid-connect-providers.adoc b/docs/src/main/asciidoc/security-openid-connect-providers.adoc index f4a98eeaa2c4e9..0d997d668b2687 100644 --- a/docs/src/main/asciidoc/security-openid-connect-providers.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-providers.adoc @@ -8,9 +8,9 @@ https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc include::_attributes.adoc[] :diataxis-type: concept :categories: security,web -:keywords: oidc github twitter google facebook mastodon microsoft apple spotify twitch linkedin +:keywords: oidc github twitter google facebook mastodon microsoft apple spotify twitch linkedin strava :toclevels: 3 -:topics: security,oidc,github,twitter,google,facebook,mastodon,microsoft,apple,spotify,twitch +:topics: security,oidc,github,twitter,google,facebook,mastodon,microsoft,apple,spotify,twitch,linkedin,strava :extensions: io.quarkus:quarkus-oidc This document explains how to configure well-known social OIDC and OAuth2 providers. @@ -525,7 +525,27 @@ quarkus.oidc.client-id= quarkus.oidc.credentials.client-secret= ---- +[[strava]] +=== Strava +Create a https://www.strava.com/settings/api[Strava application]: + +image::oidc-strava-1.png[role="thumb"] + +For example, set `Category` to `SocialMotivation`, and set `ApplicationCallbackDomain` to either `localhost` or the domain name provided by Ngrok, see the <> for more information. + +You can now configure your `application.properties`: + +[source,properties] +---- +quarkus.oidc.provider=strava +quarkus.oidc.client-id= +quarkus.oidc.credentials.client-secret= +# default value is '/strava' +quarkus.oidc.authentication.redirect-path=/fitness/welcome <1> +---- +<1> Strava does not enforce that the redirect (callback) URI which is provided as an authorization code flow parameter is equal to the URI registered in the Strava application because it only requires configuring `ApplicationCallbackDomain`. For example, if `ApplicationCallbackDomain` is set to `www.my-strava-example.com`, Strava will accept redirect URIs such as `www.my-strava-example.com/a`, `www.my-strava-example.com/path/a`, which is not recommended by OAuth2 best security practices, see link:https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-insufficient-redirect-uri-v[Insufficent redirect_uri validation] for more information. +Therefore you must configure a redirect path when working with the Strava provider and Quarkus will enforce that the current request path matches the configured `quarkus.oidc.authentication.redirect-path` value before completing the authotization code flow. See the <> for more information. [[provider-scope]] == Provider scopes @@ -685,9 +705,33 @@ Follow the same approach if the endpoint must access other Google services. The pattern of authenticating with a given provider, where the endpoint uses either an ID token or UserInfo (especially if an OAuth2-only provider such as `GitHub` is used) to get some information about the currently authenticated user and using an access token to access some downstream services (provider or application specific ones) on behalf of this user can be universally applied, irrespectively of which provider is used to secure the application. -== HTTPS Redirect URL +[[exact_redirect_uri_match]] +== Exact redirect URI match + +Most OIDC and OAuth2 providers with the exception of <> will enforce that the authorization code flow can be completed only if the redirect URI matches precisely the redirect URI configured in a given provider's dashboard. + +From the practical point of view, your Quarkus endpoint will most likely need to have the `quarkus.oidc.authentication.redirect-path` relative path property set to an initial entry path for all the authenticated users, for example, `quarkus.oidc.authentication.redirect-path=/authenticated`, which means that newly authenticated users will land on the `/authenticated` page, irrespectively of how many secured entry points your application has and which secured resource they initially accessed. + +It is a typical flow for many OIDC `web-app` applications. Once the user lands on the initial secured page, your application can return an HTML page which uses links to guide users to other parts of the application or users can be immediately redirected to other application resources with the help of JAX-RS API. + +If necessary, you can configure Quarkus to restore the original request URI after the authentication has been completed. For example: + +[source,properties] +---- +quarkus.oidc.provider=strava <1> +quarkus.oidc.client-id= +quarkus.oidc.credentials.secret= +quarkus.oidc.authentication.restore-path-after-redirect=true <2> +---- +<1> `strava` provider configuration is the only supported configuration which enforces the `quarkus.oidc.authentication.redirect-path` property with the `/strava` path which you can override with another path such as `/fitness`. +<2> If the users access the `/run` endpoint before the authentication, then, once they have authenticated and been redirected to the configured redirect path such as `/strava`, they will land on the original request `/run` path. + +You do not have to set `quarkus.oidc.authentication.redirect-path` immediately because Quarkus assumes the current request URL is an authorization code flow redirect URL if no `quarkus.oidc.authentication.redirect-path` is configured. For example, to test that a <> authentication is working, you can have a Quarkus endpoint listening on `/google` and update the Google dashboard that `http://localhost:8080/google` redirect URI is supported. Setting `quarkus.oidc.authentication.redirect-path` property will be required once your secured application URL space grows. + +[[redirect_url]] +== HTTPS Redirect URI -Some providers will only accept HTTPS-based redirect URLs. Tools such as https://ngrok.com/[ngrok] https://linuxhint.com/set-up-use-ngrok/[can be set up] to help testing such providers with Quarkus endpoints running on localhost in dev mode. +Some providers will only accept HTTPS-based redirect URIs. Tools such as https://ngrok.com/[ngrok] https://linuxhint.com/set-up-use-ngrok/[can be set up] to help testing such providers with Quarkus endpoints running on localhost in dev mode. == Rate Limiting diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index f3810d610a001b..34cdfd4c8857e8 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -176,7 +176,13 @@ public static enum Method { * form * parameters. */ - POST_JWT + POST_JWT, + + /** + * client id and secret are submitted as HTTP query parameters. This option is only supported for the OIDC + * extension. + */ + QUERY } /** diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index 8c178262454bce..805099be88105d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -1812,6 +1812,7 @@ public static enum Provider { MASTODON, MICROSOFT, SPOTIFY, + STRAVA, TWITCH, TWITTER, // New name for Twitter diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 08a1829ba87a19..3dafcb1ccb0330 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -1258,8 +1258,13 @@ public AuthorizationCodeTokens apply(AuthorizationCodeTokens tokens) { private Uni getCodeFlowTokensUni(RoutingContext context, TenantConfigContext configContext, String code, String codeVerifier) { - // 'redirect_uri': typically it must match the 'redirect_uri' query parameter which was used during the code request. + // 'redirect_uri': it must match the 'redirect_uri' query parameter which was used during the code request. String redirectPath = getRedirectPath(configContext.oidcConfig, context); + if (configContext.oidcConfig.authentication.redirectPath.isPresent() + && !configContext.oidcConfig.authentication.redirectPath.get().equals(context.request().path())) { + LOG.warnf("Token redirect path %s does not match the current request path", redirectPath); + return Uni.createFrom().failure(new AuthenticationFailedException()); + } String redirectUriParam = buildUri(context, isForceHttps(configContext.oidcConfig), redirectPath); LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index 0dce102574eb85..77f389b5c4baed 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -39,6 +39,7 @@ import io.quarkus.oidc.TokenCustomizer; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; @@ -551,7 +552,7 @@ private class SymmetricKeyResolver implements VerificationKeyResolver { @Override public Key resolveKey(JsonWebSignature jws, List nestingContext) throws UnresolvableKeyException { - return KeyUtils.createSecretKeyFromSecret(oidcConfig.credentials.secret.get()); + return KeyUtils.createSecretKeyFromSecret(OidcCommonUtils.clientSecret(oidcConfig.credentials)); } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java index 4aad5025906224..68aaec904843d9 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java @@ -19,6 +19,7 @@ import io.quarkus.oidc.common.OidcEndpoint; import io.quarkus.oidc.common.OidcRequestContextProperties; import io.quarkus.oidc.common.OidcRequestFilter; +import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Secret.Method; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.common.runtime.OidcEndpointAccessException; @@ -51,6 +52,7 @@ public class OidcProviderClient implements Closeable { private final String introspectionBasicAuthScheme; private final Key clientJwtKey; private final Map> filters; + private final boolean clientSecretQueryAuthentication; public OidcProviderClient(WebClient client, Vertx vertx, @@ -65,6 +67,7 @@ public OidcProviderClient(WebClient client, this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcConfig); this.introspectionBasicAuthScheme = initIntrospectionBasicAuthScheme(oidcConfig); this.filters = filters; + this.clientSecretQueryAuthentication = oidcConfig.credentials.clientSecret.method.orElse(null) == Method.QUERY; } private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConfig) { @@ -139,38 +142,54 @@ public Uni refreshAuthorizationCodeTokens(String refres private UniOnItem> getHttpResponse(String uri, MultiMap formBody, boolean introspect) { HttpRequest request = client.postAbs(uri); - request.putHeader(CONTENT_TYPE_HEADER, APPLICATION_X_WWW_FORM_URLENCODED); - request.putHeader(ACCEPT_HEADER, APPLICATION_JSON); - if (oidcConfig.codeGrant.headers != null) { - for (Map.Entry headerEntry : oidcConfig.codeGrant.headers.entrySet()) { - request.putHeader(headerEntry.getKey(), headerEntry.getValue()); - } - } - if (introspect && introspectionBasicAuthScheme != null) { - request.putHeader(AUTHORIZATION_HEADER, introspectionBasicAuthScheme); - if (oidcConfig.clientId.isPresent() && oidcConfig.introspectionCredentials.includeClientId) { - formBody.set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); - } - } else if (clientSecretBasicAuthScheme != null) { - request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); - } else if (clientJwtKey != null) { - String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, metadata.getTokenUri(), clientJwtKey); - if (OidcCommonUtils.isClientSecretPostJwtAuthRequired(oidcConfig.credentials)) { + + Buffer buffer = null; + + if (!clientSecretQueryAuthentication) { + request.putHeader(CONTENT_TYPE_HEADER, APPLICATION_X_WWW_FORM_URLENCODED); + request.putHeader(ACCEPT_HEADER, APPLICATION_JSON); + + if (introspect && introspectionBasicAuthScheme != null) { + request.putHeader(AUTHORIZATION_HEADER, introspectionBasicAuthScheme); + if (oidcConfig.clientId.isPresent() && oidcConfig.introspectionCredentials.includeClientId) { + formBody.set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); + } + } else if (clientSecretBasicAuthScheme != null) { + request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); + } else if (clientJwtKey != null) { + String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, metadata.getTokenUri(), clientJwtKey); + if (OidcCommonUtils.isClientSecretPostJwtAuthRequired(oidcConfig.credentials)) { + formBody.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); + formBody.add(OidcConstants.CLIENT_SECRET, jwt); + } else { + formBody.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE); + formBody.add(OidcConstants.CLIENT_ASSERTION, jwt); + } + } else if (OidcCommonUtils.isClientSecretPostAuthRequired(oidcConfig.credentials)) { formBody.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); - formBody.add(OidcConstants.CLIENT_SECRET, jwt); + formBody.add(OidcConstants.CLIENT_SECRET, OidcCommonUtils.clientSecret(oidcConfig.credentials)); } else { - formBody.add(OidcConstants.CLIENT_ASSERTION_TYPE, OidcConstants.JWT_BEARER_CLIENT_ASSERTION_TYPE); - formBody.add(OidcConstants.CLIENT_ASSERTION, jwt); + formBody.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); } - } else if (OidcCommonUtils.isClientSecretPostAuthRequired(oidcConfig.credentials)) { - formBody.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); - formBody.add(OidcConstants.CLIENT_SECRET, OidcCommonUtils.clientSecret(oidcConfig.credentials)); + buffer = OidcCommonUtils.encodeForm(formBody); } else { formBody.add(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); + formBody.add(OidcConstants.CLIENT_SECRET, OidcCommonUtils.clientSecret(oidcConfig.credentials)); + for (Map.Entry entry : formBody) { + request.addQueryParam(entry.getKey(), OidcCommonUtils.urlEncode(entry.getValue())); + } + request.putHeader(ACCEPT_HEADER, APPLICATION_JSON); + buffer = Buffer.buffer(); } + + if (oidcConfig.codeGrant.headers != null) { + for (Map.Entry headerEntry : oidcConfig.codeGrant.headers.entrySet()) { + request.putHeader(headerEntry.getKey(), headerEntry.getValue()); + } + } + LOG.debugf("Get token on: %s params: %s headers: %s", metadata.getTokenUri(), formBody, request.headers()); // Retry up to three times with a one-second delay between the retries if the connection is closed. - Buffer buffer = OidcCommonUtils.encodeForm(formBody); OidcEndpoint.Type endpoint = introspect ? OidcEndpoint.Type.INTROSPECTION : OidcEndpoint.Type.TOKEN; Uni> response = filter(endpoint, request, buffer, null).sendBuffer(buffer) @@ -178,6 +197,7 @@ private UniOnItem> getHttpResponse(String uri, MultiMap for .retry() .atMost(oidcConfig.connectionRetryCount).onFailure().transform(t -> t.getCause()); return response.onItem(); + } private AuthorizationCodeTokens getAuthorizationCodeTokens(HttpResponse resp) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 94220d432211f7..a1d4c7f6aacf4c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -553,6 +553,9 @@ static OidcTenantConfig mergeTenantConfig(OidcTenantConfig tenant, OidcTenantCon if (tenant.authentication.responseMode.isEmpty()) { tenant.authentication.responseMode = provider.authentication.responseMode; } + if (tenant.authentication.redirectPath.isEmpty()) { + tenant.authentication.redirectPath = provider.authentication.redirectPath; + } // credentials if (tenant.credentials.clientSecret.method.isEmpty()) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java index 129262cd29b2b0..d59f5fec66fb49 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java @@ -20,6 +20,7 @@ public static OidcTenantConfig provider(OidcTenantConfig.Provider provider) { case MASTODON -> mastodon(); case MICROSOFT -> microsoft(); case SPOTIFY -> spotify(); + case STRAVA -> strava(); case TWITCH -> twitch(); case TWITTER, X -> twitter(); }; @@ -153,6 +154,28 @@ private static OidcTenantConfig spotify() { return ret; } + private static OidcTenantConfig strava() { + OidcTenantConfig ret = new OidcTenantConfig(); + ret.setDiscoveryEnabled(false); + ret.setAuthServerUrl("https://www.strava.com/oauth"); + ret.setApplicationType(OidcTenantConfig.ApplicationType.WEB_APP); + ret.setAuthorizationPath("authorize"); + + ret.setTokenPath("token"); + ret.setUserInfoPath("https://www.strava.com/api/v3/athlete"); + + OidcTenantConfig.Authentication authentication = ret.getAuthentication(); + authentication.setAddOpenidScope(false); + authentication.setScopes(List.of("activity:read")); + authentication.setIdTokenRequired(false); + authentication.setRedirectPath("/strava"); + + ret.getToken().setVerifyAccessTokenWithUserInfo(true); + ret.getCredentials().getClientSecret().setMethod(Method.QUERY); + + return ret; + } + private static OidcTenantConfig twitch() { // Ref https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#oidc-authorization-code-grant-flow diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java new file mode 100644 index 00000000000000..1bafcd14e7b914 --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/KnownOidcProvidersTest.java @@ -0,0 +1,586 @@ +package io.quarkus.oidc.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.ApplicationType; +import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode; +import io.quarkus.oidc.OidcTenantConfig.Provider; +import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Secret.Method; +import io.quarkus.oidc.runtime.providers.KnownOidcProviders; +import io.smallrye.jwt.algorithm.SignatureAlgorithm; + +public class KnownOidcProvidersTest { + + @Test + public void testAcceptGitHubProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GITHUB)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().get()); + assertEquals("https://github.com/login/oauth", config.getAuthServerUrl().get()); + assertEquals("authorize", config.getAuthorizationPath().get()); + assertEquals("access_token", config.getTokenPath().get()); + assertEquals("https://api.github.com/user", config.getUserInfoPath().get()); + + assertFalse(config.authentication.idTokenRequired.get()); + assertTrue(config.authentication.userInfoRequired.get()); + assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); + assertEquals(List.of("user:email"), config.authentication.scopes.get()); + assertEquals("name", config.getToken().getPrincipalClaim().get()); + } + + @Test + public void testOverrideGitHubProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setDiscoveryEnabled(true); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorization"); + tenant.setTokenPath("tokens"); + tenant.setUserInfoPath("userinfo"); + + tenant.authentication.setIdTokenRequired(true); + tenant.authentication.setUserInfoRequired(false); + tenant.token.setVerifyAccessTokenWithUserInfo(false); + tenant.authentication.setScopes(List.of("write")); + tenant.token.setPrincipalClaim("firstname"); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GITHUB)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorization", config.getAuthorizationPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + assertEquals("userinfo", config.getUserInfoPath().get()); + + assertTrue(config.authentication.idTokenRequired.get()); + assertFalse(config.authentication.userInfoRequired.get()); + assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertEquals("firstname", config.getToken().getPrincipalClaim().get()); + } + + @Test + public void testAcceptTwitterProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITTER)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().get()); + assertEquals("https://api.twitter.com/2/oauth2", config.getAuthServerUrl().get()); + assertEquals("https://twitter.com/i/oauth2/authorize", config.getAuthorizationPath().get()); + assertEquals("token", config.getTokenPath().get()); + assertEquals("https://api.twitter.com/2/users/me", config.getUserInfoPath().get()); + + assertFalse(config.authentication.idTokenRequired.get()); + assertTrue(config.authentication.userInfoRequired.get()); + assertFalse(config.authentication.addOpenidScope.get()); + assertEquals(List.of("offline.access", "tweet.read", "users.read"), config.authentication.scopes.get()); + assertTrue(config.authentication.pkceRequired.get()); + } + + @Test + public void testOverrideTwitterProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setDiscoveryEnabled(true); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorization"); + tenant.setTokenPath("tokens"); + tenant.setUserInfoPath("userinfo"); + + tenant.authentication.setIdTokenRequired(true); + tenant.authentication.setUserInfoRequired(false); + tenant.authentication.setAddOpenidScope(true); + tenant.authentication.setPkceRequired(false); + tenant.authentication.setScopes(List.of("write")); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITTER)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorization", config.getAuthorizationPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + assertEquals("userinfo", config.getUserInfoPath().get()); + + assertTrue(config.authentication.idTokenRequired.get()); + assertFalse(config.authentication.userInfoRequired.get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertTrue(config.authentication.addOpenidScope.get()); + assertFalse(config.authentication.pkceRequired.get()); + } + + @Test + public void testAcceptMastodonProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MASTODON)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().get()); + assertEquals("https://mastodon.social", config.getAuthServerUrl().get()); + assertEquals("/oauth/authorize", config.getAuthorizationPath().get()); + assertEquals("/oauth/token", config.getTokenPath().get()); + assertEquals("/api/v1/accounts/verify_credentials", config.getUserInfoPath().get()); + + assertFalse(config.authentication.idTokenRequired.get()); + assertTrue(config.authentication.userInfoRequired.get()); + assertFalse(config.authentication.addOpenidScope.get()); + assertEquals(List.of("read"), config.authentication.scopes.get()); + } + + @Test + public void testOverrideMastodonProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setDiscoveryEnabled(true); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorization"); + tenant.setTokenPath("tokens"); + tenant.setUserInfoPath("userinfo"); + + tenant.authentication.setIdTokenRequired(true); + tenant.authentication.setUserInfoRequired(false); + tenant.authentication.setAddOpenidScope(true); + tenant.authentication.setScopes(List.of("write")); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MASTODON)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorization", config.getAuthorizationPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + assertEquals("userinfo", config.getUserInfoPath().get()); + + assertTrue(config.authentication.idTokenRequired.get()); + assertFalse(config.authentication.userInfoRequired.get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertTrue(config.authentication.addOpenidScope.get()); + } + + @Test + public void testAcceptXProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.X)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().get()); + assertEquals("https://api.twitter.com/2/oauth2", config.getAuthServerUrl().get()); + assertEquals("https://twitter.com/i/oauth2/authorize", config.getAuthorizationPath().get()); + assertEquals("token", config.getTokenPath().get()); + assertEquals("https://api.twitter.com/2/users/me", config.getUserInfoPath().get()); + + assertFalse(config.authentication.idTokenRequired.get()); + assertTrue(config.authentication.userInfoRequired.get()); + assertFalse(config.authentication.addOpenidScope.get()); + assertEquals(List.of("offline.access", "tweet.read", "users.read"), config.authentication.scopes.get()); + assertTrue(config.authentication.pkceRequired.get()); + } + + @Test + public void testOverrideXProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setDiscoveryEnabled(true); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorization"); + tenant.setTokenPath("tokens"); + tenant.setUserInfoPath("userinfo"); + + tenant.authentication.setIdTokenRequired(true); + tenant.authentication.setUserInfoRequired(false); + tenant.authentication.setAddOpenidScope(true); + tenant.authentication.setPkceRequired(false); + tenant.authentication.setScopes(List.of("write")); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.X)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorization", config.getAuthorizationPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + assertEquals("userinfo", config.getUserInfoPath().get()); + + assertTrue(config.authentication.idTokenRequired.get()); + assertFalse(config.authentication.userInfoRequired.get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertTrue(config.authentication.addOpenidScope.get()); + assertFalse(config.authentication.pkceRequired.get()); + } + + @Test + public void testAcceptFacebookProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertFalse(config.isDiscoveryEnabled().get()); + assertEquals("https://www.facebook.com", config.getAuthServerUrl().get()); + assertEquals("https://facebook.com/dialog/oauth/", config.getAuthorizationPath().get()); + assertEquals("https://www.facebook.com/.well-known/oauth/openid/jwks/", config.getJwksPath().get()); + assertEquals("https://graph.facebook.com/v12.0/oauth/access_token", config.getTokenPath().get()); + + assertEquals(List.of("email", "public_profile"), config.authentication.scopes.get()); + assertTrue(config.authentication.forceRedirectHttpsScheme.get()); + } + + @Test + public void testOverrideFacebookProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setDiscoveryEnabled(true); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorization"); + tenant.setJwksPath("jwks"); + tenant.setTokenPath("tokens"); + + tenant.authentication.setScopes(List.of("write")); + tenant.authentication.setForceRedirectHttpsScheme(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertTrue(config.isDiscoveryEnabled().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorization", config.getAuthorizationPath().get()); + assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); + assertEquals("jwks", config.getJwksPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + + assertEquals(List.of("write"), config.authentication.scopes.get()); + } + + @Test + public void testAcceptGoogleProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GOOGLE)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertEquals("https://accounts.google.com", config.getAuthServerUrl().get()); + assertEquals("name", config.getToken().getPrincipalClaim().get()); + assertEquals(List.of("openid", "email", "profile"), config.authentication.scopes.get()); + assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); + } + + @Test + public void testOverrideGoogleProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.authentication.setScopes(List.of("write")); + tenant.token.setPrincipalClaim("firstname"); + tenant.token.setVerifyAccessTokenWithUserInfo(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GOOGLE)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("firstname", config.getToken().getPrincipalClaim().get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); + } + + @Test + public void testAcceptMicrosoftProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MICROSOFT)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertEquals("https://login.microsoftonline.com/common/v2.0", config.getAuthServerUrl().get()); + assertEquals(List.of("openid", "email", "profile"), config.authentication.scopes.get()); + assertEquals("any", config.getToken().getIssuer().get()); + } + + @Test + public void testOverrideMicrosoftProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.getToken().setIssuer("http://localhost/wiremock"); + tenant.authentication.setScopes(List.of("write")); + tenant.authentication.setForceRedirectHttpsScheme(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MICROSOFT)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertEquals("http://localhost/wiremock", config.getToken().getIssuer().get()); + assertFalse(config.authentication.forceRedirectHttpsScheme.get()); + } + + @Test + public void testAcceptAppleProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.APPLE)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertEquals("https://appleid.apple.com/", config.getAuthServerUrl().get()); + assertEquals(List.of("openid", "email", "name"), config.authentication.scopes.get()); + assertEquals(ResponseMode.FORM_POST, config.authentication.responseMode.get()); + assertEquals(Method.POST_JWT, config.credentials.clientSecret.method.get()); + assertEquals("https://appleid.apple.com/", config.credentials.jwt.audience.get()); + assertEquals(SignatureAlgorithm.ES256.getAlgorithm(), config.credentials.jwt.signatureAlgorithm.get()); + assertTrue(config.authentication.forceRedirectHttpsScheme.get()); + } + + @Test + public void testOverrideAppleProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.authentication.setScopes(List.of("write")); + tenant.authentication.setResponseMode(ResponseMode.QUERY); + tenant.credentials.clientSecret.setMethod(Method.POST); + tenant.credentials.jwt.setAudience("http://localhost/audience"); + tenant.credentials.jwt.setSignatureAlgorithm(SignatureAlgorithm.ES256.getAlgorithm()); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.APPLE)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertEquals(ResponseMode.QUERY, config.authentication.responseMode.get()); + assertEquals(Method.POST, config.credentials.clientSecret.method.get()); + assertEquals("http://localhost/audience", config.credentials.jwt.audience.get()); + assertEquals(SignatureAlgorithm.ES256.getAlgorithm(), config.credentials.jwt.signatureAlgorithm.get()); + } + + @Test + public void testAcceptSpotifyProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SPOTIFY)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertEquals("https://accounts.spotify.com", config.getAuthServerUrl().get()); + assertEquals(List.of("user-read-private", "user-read-email"), config.authentication.scopes.get()); + assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); + assertEquals("display_name", config.getToken().getPrincipalClaim().get()); + } + + @Test + public void testOverrideSpotifyProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.getToken().setIssuer("http://localhost/wiremock"); + tenant.authentication.setScopes(List.of("write")); + tenant.authentication.setForceRedirectHttpsScheme(false); + tenant.token.setPrincipalClaim("firstname"); + tenant.token.setVerifyAccessTokenWithUserInfo(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SPOTIFY)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertEquals("http://localhost/wiremock", config.getToken().getIssuer().get()); + assertFalse(config.authentication.forceRedirectHttpsScheme.get()); + assertEquals("firstname", config.getToken().getPrincipalClaim().get()); + assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); + } + + @Test + public void testAcceptStravaProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.STRAVA)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + + assertFalse(config.discoveryEnabled.get()); + assertEquals("https://www.strava.com/oauth", config.getAuthServerUrl().get()); + assertEquals("authorize", config.getAuthorizationPath().get()); + assertEquals("token", config.getTokenPath().get()); + assertEquals("https://www.strava.com/api/v3/athlete", config.getUserInfoPath().get()); + assertEquals(List.of("activity:read"), config.authentication.scopes.get()); + assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); + assertFalse(config.getAuthentication().idTokenRequired.get()); + assertEquals(Method.QUERY, config.credentials.clientSecret.method.get()); + assertEquals("/strava", config.authentication.redirectPath.get()); + } + + @Test + public void testOverrideStravaProperties() { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.setAuthorizationPath("authorizations"); + tenant.setTokenPath("tokens"); + tenant.setUserInfoPath("users"); + + tenant.authentication.setScopes(List.of("write")); + tenant.token.setVerifyAccessTokenWithUserInfo(false); + tenant.credentials.clientSecret.setMethod(Method.BASIC); + tenant.authentication.setRedirectPath("/fitness-app"); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.STRAVA)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertEquals("authorizations", config.getAuthorizationPath().get()); + assertEquals("tokens", config.getTokenPath().get()); + assertEquals("users", config.getUserInfoPath().get()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); + assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); + assertEquals("/fitness-app", config.authentication.redirectPath.get()); + } + + @Test + public void testAcceptTwitchProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITCH)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); + assertEquals("https://id.twitch.tv/oauth2", config.getAuthServerUrl().get()); + assertEquals(Method.POST, config.credentials.clientSecret.method.get()); + assertTrue(config.authentication.forceRedirectHttpsScheme.get()); + } + + @Test + public void testOverrideTwitchProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.credentials.clientSecret.setMethod(Method.BASIC); + tenant.authentication.setForceRedirectHttpsScheme(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); + assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); + } + + @Test + public void testAcceptDiscordProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.DISCORD)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertFalse(config.discoveryEnabled.get()); + assertEquals("https://discord.com/api/oauth2", config.getAuthServerUrl().get()); + assertEquals("authorize", config.getAuthorizationPath().get()); + assertEquals("token", config.getTokenPath().get()); + assertEquals("https://discord.com/api/users/@me", config.getUserInfoPath().get()); + assertEquals(List.of("identify", "email"), config.authentication.scopes.get()); + assertFalse(config.getAuthentication().idTokenRequired.get()); + } + + @Test + public void testOverrideDiscordProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.credentials.clientSecret.setMethod(Method.BASIC); + tenant.authentication.setForceRedirectHttpsScheme(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.DISCORD)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); + assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); + } + + @Test + public void testAcceptLinkedInProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.LINKEDIN)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals("https://www.linkedin.com/oauth", config.getAuthServerUrl().get()); + assertEquals(List.of("email", "profile"), config.authentication.scopes.get()); + } + + @Test + public void testOverrideLinkedInProperties() throws Exception { + OidcTenantConfig tenant = new OidcTenantConfig(); + tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); + + tenant.setApplicationType(ApplicationType.HYBRID); + tenant.setAuthServerUrl("http://localhost/wiremock"); + tenant.credentials.clientSecret.setMethod(Method.BASIC); + tenant.authentication.setForceRedirectHttpsScheme(false); + + OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.LINKEDIN)); + + assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); + assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); + assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); + assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); + assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); + } +} diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index 3770db039424a0..8afa5cbf49ef81 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -26,12 +26,6 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; -import io.quarkus.oidc.OidcTenantConfig.ApplicationType; -import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode; -import io.quarkus.oidc.OidcTenantConfig.Provider; -import io.quarkus.oidc.common.runtime.OidcCommonConfig.Credentials.Secret.Method; -import io.quarkus.oidc.runtime.providers.KnownOidcProviders; -import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.build.Jwt; import io.vertx.core.http.Cookie; import io.vertx.core.http.impl.CookieImpl; @@ -89,521 +83,6 @@ public void testGetMultipleSessionCookies() throws Exception { } } - @Test - public void testAcceptGitHubProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GITHUB)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertFalse(config.isDiscoveryEnabled().get()); - assertEquals("https://github.com/login/oauth", config.getAuthServerUrl().get()); - assertEquals("authorize", config.getAuthorizationPath().get()); - assertEquals("access_token", config.getTokenPath().get()); - assertEquals("https://api.github.com/user", config.getUserInfoPath().get()); - - assertFalse(config.authentication.idTokenRequired.get()); - assertTrue(config.authentication.userInfoRequired.get()); - assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); - assertEquals(List.of("user:email"), config.authentication.scopes.get()); - assertEquals("name", config.getToken().getPrincipalClaim().get()); - } - - @Test - public void testOverrideGitHubProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setDiscoveryEnabled(true); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.setAuthorizationPath("authorization"); - tenant.setTokenPath("tokens"); - tenant.setUserInfoPath("userinfo"); - - tenant.authentication.setIdTokenRequired(true); - tenant.authentication.setUserInfoRequired(false); - tenant.token.setVerifyAccessTokenWithUserInfo(false); - tenant.authentication.setScopes(List.of("write")); - tenant.token.setPrincipalClaim("firstname"); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GITHUB)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertTrue(config.isDiscoveryEnabled().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("authorization", config.getAuthorizationPath().get()); - assertEquals("tokens", config.getTokenPath().get()); - assertEquals("userinfo", config.getUserInfoPath().get()); - - assertTrue(config.authentication.idTokenRequired.get()); - assertFalse(config.authentication.userInfoRequired.get()); - assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertEquals("firstname", config.getToken().getPrincipalClaim().get()); - } - - @Test - public void testAcceptTwitterProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITTER)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertFalse(config.isDiscoveryEnabled().get()); - assertEquals("https://api.twitter.com/2/oauth2", config.getAuthServerUrl().get()); - assertEquals("https://twitter.com/i/oauth2/authorize", config.getAuthorizationPath().get()); - assertEquals("token", config.getTokenPath().get()); - assertEquals("https://api.twitter.com/2/users/me", config.getUserInfoPath().get()); - - assertFalse(config.authentication.idTokenRequired.get()); - assertTrue(config.authentication.userInfoRequired.get()); - assertFalse(config.authentication.addOpenidScope.get()); - assertEquals(List.of("offline.access", "tweet.read", "users.read"), config.authentication.scopes.get()); - assertTrue(config.authentication.pkceRequired.get()); - } - - @Test - public void testOverrideTwitterProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setDiscoveryEnabled(true); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.setAuthorizationPath("authorization"); - tenant.setTokenPath("tokens"); - tenant.setUserInfoPath("userinfo"); - - tenant.authentication.setIdTokenRequired(true); - tenant.authentication.setUserInfoRequired(false); - tenant.authentication.setAddOpenidScope(true); - tenant.authentication.setPkceRequired(false); - tenant.authentication.setScopes(List.of("write")); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITTER)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertTrue(config.isDiscoveryEnabled().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("authorization", config.getAuthorizationPath().get()); - assertEquals("tokens", config.getTokenPath().get()); - assertEquals("userinfo", config.getUserInfoPath().get()); - - assertTrue(config.authentication.idTokenRequired.get()); - assertFalse(config.authentication.userInfoRequired.get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertTrue(config.authentication.addOpenidScope.get()); - assertFalse(config.authentication.pkceRequired.get()); - } - - @Test - public void testAcceptMastodonProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MASTODON)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertFalse(config.isDiscoveryEnabled().get()); - assertEquals("https://mastodon.social", config.getAuthServerUrl().get()); - assertEquals("/oauth/authorize", config.getAuthorizationPath().get()); - assertEquals("/oauth/token", config.getTokenPath().get()); - assertEquals("/api/v1/accounts/verify_credentials", config.getUserInfoPath().get()); - - assertFalse(config.authentication.idTokenRequired.get()); - assertTrue(config.authentication.userInfoRequired.get()); - assertFalse(config.authentication.addOpenidScope.get()); - assertEquals(List.of("read"), config.authentication.scopes.get()); - } - - @Test - public void testOverrideMastodonProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setDiscoveryEnabled(true); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.setAuthorizationPath("authorization"); - tenant.setTokenPath("tokens"); - tenant.setUserInfoPath("userinfo"); - - tenant.authentication.setIdTokenRequired(true); - tenant.authentication.setUserInfoRequired(false); - tenant.authentication.setAddOpenidScope(true); - tenant.authentication.setScopes(List.of("write")); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MASTODON)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertTrue(config.isDiscoveryEnabled().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("authorization", config.getAuthorizationPath().get()); - assertEquals("tokens", config.getTokenPath().get()); - assertEquals("userinfo", config.getUserInfoPath().get()); - - assertTrue(config.authentication.idTokenRequired.get()); - assertFalse(config.authentication.userInfoRequired.get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertTrue(config.authentication.addOpenidScope.get()); - } - - @Test - public void testAcceptXProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.X)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertFalse(config.isDiscoveryEnabled().get()); - assertEquals("https://api.twitter.com/2/oauth2", config.getAuthServerUrl().get()); - assertEquals("https://twitter.com/i/oauth2/authorize", config.getAuthorizationPath().get()); - assertEquals("token", config.getTokenPath().get()); - assertEquals("https://api.twitter.com/2/users/me", config.getUserInfoPath().get()); - - assertFalse(config.authentication.idTokenRequired.get()); - assertTrue(config.authentication.userInfoRequired.get()); - assertFalse(config.authentication.addOpenidScope.get()); - assertEquals(List.of("offline.access", "tweet.read", "users.read"), config.authentication.scopes.get()); - assertTrue(config.authentication.pkceRequired.get()); - } - - @Test - public void testOverrideXProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setDiscoveryEnabled(true); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.setAuthorizationPath("authorization"); - tenant.setTokenPath("tokens"); - tenant.setUserInfoPath("userinfo"); - - tenant.authentication.setIdTokenRequired(true); - tenant.authentication.setUserInfoRequired(false); - tenant.authentication.setAddOpenidScope(true); - tenant.authentication.setPkceRequired(false); - tenant.authentication.setScopes(List.of("write")); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.X)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertTrue(config.isDiscoveryEnabled().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("authorization", config.getAuthorizationPath().get()); - assertEquals("tokens", config.getTokenPath().get()); - assertEquals("userinfo", config.getUserInfoPath().get()); - - assertTrue(config.authentication.idTokenRequired.get()); - assertFalse(config.authentication.userInfoRequired.get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertTrue(config.authentication.addOpenidScope.get()); - assertFalse(config.authentication.pkceRequired.get()); - } - - @Test - public void testAcceptFacebookProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertFalse(config.isDiscoveryEnabled().get()); - assertEquals("https://www.facebook.com", config.getAuthServerUrl().get()); - assertEquals("https://facebook.com/dialog/oauth/", config.getAuthorizationPath().get()); - assertEquals("https://www.facebook.com/.well-known/oauth/openid/jwks/", config.getJwksPath().get()); - assertEquals("https://graph.facebook.com/v12.0/oauth/access_token", config.getTokenPath().get()); - - assertEquals(List.of("email", "public_profile"), config.authentication.scopes.get()); - assertTrue(config.authentication.forceRedirectHttpsScheme.get()); - } - - @Test - public void testOverrideFacebookProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setDiscoveryEnabled(true); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.setAuthorizationPath("authorization"); - tenant.setJwksPath("jwks"); - tenant.setTokenPath("tokens"); - - tenant.authentication.setScopes(List.of("write")); - tenant.authentication.setForceRedirectHttpsScheme(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertTrue(config.isDiscoveryEnabled().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("authorization", config.getAuthorizationPath().get()); - assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); - assertEquals("jwks", config.getJwksPath().get()); - assertEquals("tokens", config.getTokenPath().get()); - - assertEquals(List.of("write"), config.authentication.scopes.get()); - } - - @Test - public void testAcceptGoogleProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GOOGLE)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertEquals("https://accounts.google.com", config.getAuthServerUrl().get()); - assertEquals("name", config.getToken().getPrincipalClaim().get()); - assertEquals(List.of("openid", "email", "profile"), config.authentication.scopes.get()); - assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); - } - - @Test - public void testOverrideGoogleProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.authentication.setScopes(List.of("write")); - tenant.token.setPrincipalClaim("firstname"); - tenant.token.setVerifyAccessTokenWithUserInfo(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.GOOGLE)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals("firstname", config.getToken().getPrincipalClaim().get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); - } - - @Test - public void testAcceptMicrosoftProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MICROSOFT)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertEquals("https://login.microsoftonline.com/common/v2.0", config.getAuthServerUrl().get()); - assertEquals(List.of("openid", "email", "profile"), config.authentication.scopes.get()); - assertEquals("any", config.getToken().getIssuer().get()); - } - - @Test - public void testOverrideMicrosoftProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.getToken().setIssuer("http://localhost/wiremock"); - tenant.authentication.setScopes(List.of("write")); - tenant.authentication.setForceRedirectHttpsScheme(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.MICROSOFT)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertEquals("http://localhost/wiremock", config.getToken().getIssuer().get()); - assertFalse(config.authentication.forceRedirectHttpsScheme.get()); - } - - @Test - public void testAcceptAppleProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.APPLE)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertEquals("https://appleid.apple.com/", config.getAuthServerUrl().get()); - assertEquals(List.of("openid", "email", "name"), config.authentication.scopes.get()); - assertEquals(ResponseMode.FORM_POST, config.authentication.responseMode.get()); - assertEquals(Method.POST_JWT, config.credentials.clientSecret.method.get()); - assertEquals("https://appleid.apple.com/", config.credentials.jwt.audience.get()); - assertEquals(SignatureAlgorithm.ES256.getAlgorithm(), config.credentials.jwt.signatureAlgorithm.get()); - assertTrue(config.authentication.forceRedirectHttpsScheme.get()); - } - - @Test - public void testOverrideAppleProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.authentication.setScopes(List.of("write")); - tenant.authentication.setResponseMode(ResponseMode.QUERY); - tenant.credentials.clientSecret.setMethod(Method.POST); - tenant.credentials.jwt.setAudience("http://localhost/audience"); - tenant.credentials.jwt.setSignatureAlgorithm(SignatureAlgorithm.ES256.getAlgorithm()); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.APPLE)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertEquals(ResponseMode.QUERY, config.authentication.responseMode.get()); - assertEquals(Method.POST, config.credentials.clientSecret.method.get()); - assertEquals("http://localhost/audience", config.credentials.jwt.audience.get()); - assertEquals(SignatureAlgorithm.ES256.getAlgorithm(), config.credentials.jwt.signatureAlgorithm.get()); - } - - @Test - public void testAcceptSpotifyProperties() { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SPOTIFY)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertEquals("https://accounts.spotify.com", config.getAuthServerUrl().get()); - assertEquals(List.of("user-read-private", "user-read-email"), config.authentication.scopes.get()); - assertTrue(config.token.verifyAccessTokenWithUserInfo.get()); - assertEquals("display_name", config.getToken().getPrincipalClaim().get()); - } - - @Test - public void testOverrideSpotifyProperties() { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.getToken().setIssuer("http://localhost/wiremock"); - tenant.authentication.setScopes(List.of("write")); - tenant.authentication.setForceRedirectHttpsScheme(false); - tenant.token.setPrincipalClaim("firstname"); - tenant.token.setVerifyAccessTokenWithUserInfo(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.SPOTIFY)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertEquals(List.of("write"), config.authentication.scopes.get()); - assertEquals("http://localhost/wiremock", config.getToken().getIssuer().get()); - assertFalse(config.authentication.forceRedirectHttpsScheme.get()); - assertEquals("firstname", config.getToken().getPrincipalClaim().get()); - assertFalse(config.token.verifyAccessTokenWithUserInfo.get()); - } - - @Test - public void testAcceptTwitchProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.TWITCH)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.WEB_APP, config.getApplicationType().get()); - assertEquals("https://id.twitch.tv/oauth2", config.getAuthServerUrl().get()); - assertEquals(Method.POST, config.credentials.clientSecret.method.get()); - assertTrue(config.authentication.forceRedirectHttpsScheme.get()); - } - - @Test - public void testOverrideTwitchProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.credentials.clientSecret.setMethod(Method.BASIC); - tenant.authentication.setForceRedirectHttpsScheme(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.FACEBOOK)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); - assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); - } - - @Test - public void testAcceptDiscordProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.DISCORD)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertFalse(config.discoveryEnabled.get()); - assertEquals("https://discord.com/api/oauth2", config.getAuthServerUrl().get()); - assertEquals("authorize", config.getAuthorizationPath().get()); - assertEquals("token", config.getTokenPath().get()); - assertEquals("https://discord.com/api/users/@me", config.getUserInfoPath().get()); - assertEquals(List.of("identify", "email"), config.authentication.scopes.get()); - assertFalse(config.getAuthentication().idTokenRequired.get()); - } - - @Test - public void testOverrideDiscordProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.credentials.clientSecret.setMethod(Method.BASIC); - tenant.authentication.setForceRedirectHttpsScheme(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.DISCORD)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); - assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); - } - - @Test - public void testAcceptLinkedInProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.LINKEDIN)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals("https://www.linkedin.com/oauth", config.getAuthServerUrl().get()); - assertEquals(List.of("email", "profile"), config.authentication.scopes.get()); - } - - @Test - public void testOverrideLinkedInProperties() throws Exception { - OidcTenantConfig tenant = new OidcTenantConfig(); - tenant.setTenantId(OidcUtils.DEFAULT_TENANT_ID); - - tenant.setApplicationType(ApplicationType.HYBRID); - tenant.setAuthServerUrl("http://localhost/wiremock"); - tenant.credentials.clientSecret.setMethod(Method.BASIC); - tenant.authentication.setForceRedirectHttpsScheme(false); - - OidcTenantConfig config = OidcUtils.mergeTenantConfig(tenant, KnownOidcProviders.provider(Provider.LINKEDIN)); - - assertEquals(OidcUtils.DEFAULT_TENANT_ID, config.getTenantId().get()); - assertEquals(ApplicationType.HYBRID, config.getApplicationType().get()); - assertEquals("http://localhost/wiremock", config.getAuthServerUrl().get()); - assertFalse(config.getAuthentication().isForceRedirectHttpsScheme().get()); - assertEquals(Method.BASIC, config.credentials.clientSecret.method.get()); - } - @Test public void testCorrectTokenType() throws Exception { OidcTenantConfig.Token tokenClaims = new OidcTenantConfig.Token(); diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index f3f8a78afc7ea5..8603c5093f442f 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -90,6 +90,7 @@ quarkus.oidc.bearer-user-info-github-service.credentials.secret=AyM1SysPpbyDfgZl quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.provider=github quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.authorization-path=/ +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.token-path=access_token_refreshed quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.user-info-path=protocol/openid-connect/userinfo quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.jwks-path=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.code-grant.extra-params.extra-param=extra-param-value @@ -98,7 +99,8 @@ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.cache-user-info-in-idt quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.token.refresh-token-time-skew=298 quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.authentication.verify-access-token=true quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.client-id=quarkus-web-app -quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.client-secret.value=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.client-secret.method=query quarkus.oidc.code-flow-token-introspection.provider=github diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 63a292066b08bd..a21b2f14fc1170 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -2,9 +2,11 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.notContaining; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -85,6 +87,7 @@ public void testCodeFlow() throws IOException { // Clear the post logout cookie webClient.getCookieManager().clearCookies(); } + clearCache(); } @Test @@ -120,6 +123,7 @@ private void doTestCodeFlowEncryptedIdToken(String tenant) throws IOException { webClient.getCookieManager().clearCookies(); } + clearCache(); } @Test @@ -178,6 +182,7 @@ public void testCodeFlowFormPostAndBackChannelLogout() throws IOException { webClient.getCookieManager().clearCookies(); } + clearCache(); } @Test @@ -226,6 +231,7 @@ public void testCodeFlowFormPostAndFrontChannelLogout() throws Exception { webClient.getCookieManager().clearCookies(); } + clearCache(); } @Test @@ -238,7 +244,34 @@ public void testCodeFlowUserInfo() throws Exception { clearCache(); doTestCodeFlowUserInfo("code-flow-user-info-dynamic-github", 301); clearCache(); - doTestCodeFlowUserInfoCashedInIdToken(); + } + + @Test + public void testCodeFlowUserInfoCachedInIdToken() throws Exception { + defineCodeFlowUserInfoCachedInIdTokenStub(); + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + TextPage textPage = form.getInputByValue("login").click(); + + assertEquals("alice:alice:alice, cache size: 0", textPage.getContent()); + + JsonObject idTokenClaims = decryptIdToken(webClient, "code-flow-user-info-github-cached-in-idtoken"); + assertNotNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE)); + + // refresh + Thread.sleep(3000); + textPage = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); + assertEquals("alice:alice:bob, cache size: 0", textPage.getContent()); + + webClient.getCookieManager().clearCookies(); + } + clearCache(); } @Test @@ -263,6 +296,7 @@ public void testCodeFlowTokenIntrospection() throws Exception { webClient.getCookieManager().clearCookies(); } + clearCache(); } private void doTestCodeFlowUserInfo(String tenantId, long internalIdTokenLifetime) throws Exception { @@ -316,31 +350,6 @@ private JsonObject decryptIdToken(WebClient webClient, String tenantId) throws E return OidcUtils.decodeJwtContent(encodedIdToken); } - private void doTestCodeFlowUserInfoCashedInIdToken() throws Exception { - try (final WebClient webClient = createWebClient()) { - webClient.getOptions().setRedirectEnabled(true); - HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); - - HtmlForm form = page.getFormByName("form"); - form.getInputByName("username").type("alice"); - form.getInputByName("password").type("alice"); - - TextPage textPage = form.getInputByValue("login").click(); - - assertEquals("alice:alice:alice, cache size: 0", textPage.getContent()); - - JsonObject idTokenClaims = decryptIdToken(webClient, "code-flow-user-info-github-cached-in-idtoken"); - assertNotNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE)); - - // refresh - Thread.sleep(3000); - textPage = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); - assertEquals("alice:alice:bob, cache size: 0", textPage.getContent()); - - webClient.getCookieManager().clearCookies(); - } - } - private WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); @@ -350,7 +359,9 @@ private WebClient createWebClient() { private void defineCodeFlowAuthorizationOauth2TokenStub() { wireMockServer .stubFor(WireMock.post("/auth/realms/quarkus/access_token") - .withHeader("X-Custom", matching("XCustomHeaderValue")) + .withHeader("X-Custom", equalTo("XCustomHeaderValue")) + .withBasicAuth("quarkus-web-app", + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow") .withRequestBody(containing("extra-param=extra-param-value")) .withRequestBody(containing("authorization_code")) .willReturn(WireMock.aResponse() @@ -362,6 +373,8 @@ private void defineCodeFlowAuthorizationOauth2TokenStub() { + "}"))); wireMockServer .stubFor(WireMock.post("/auth/realms/quarkus/access_token") + .withBasicAuth("quarkus-web-app", + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow") .withRequestBody(containing("refresh_token=refresh1234")) .willReturn(WireMock.aResponse() .withHeader("Content-Type", "application/json") @@ -372,6 +385,46 @@ private void defineCodeFlowAuthorizationOauth2TokenStub() { } + private void defineCodeFlowUserInfoCachedInIdTokenStub() { + wireMockServer + .stubFor(WireMock.post(urlPathMatching("/auth/realms/quarkus/access_token_refreshed")) + .withHeader("X-Custom", matching("XCustomHeaderValue")) + .withQueryParam("extra-param", equalTo("extra-param-value")) + .withQueryParam("grant_type", equalTo("authorization_code")) + .withQueryParam("client_id", equalTo("quarkus-web-app")) + .withQueryParam("client_secret", equalTo( + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow")) + .withRequestBody(notContaining("extra-param=extra-param-value")) + .withRequestBody(notContaining("authorization_code")) + .withRequestBody(notContaining("client_id=quarkus-web-app")) + .withRequestBody(notContaining( + "client_secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"" + + OidcWiremockTestResource.getAccessToken("alice", Set.of()) + "\"," + + " \"refresh_token\": \"refresh1234\"" + + "}"))); + wireMockServer + .stubFor(WireMock.post(urlPathMatching("/auth/realms/quarkus/access_token_refreshed")) + .withQueryParam("refresh_token", equalTo("refresh1234")) + .withQueryParam("client_id", equalTo("quarkus-web-app")) + .withQueryParam("client_secret", equalTo( + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow")) + .withRequestBody(notContaining("refresh_token=refresh1234")) + .withRequestBody(notContaining("client_id=quarkus-web-app")) + .withRequestBody(notContaining( + "client_secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"" + + OidcWiremockTestResource.getAccessToken("bob", Set.of()) + "\"" + + "}"))); + + } + private void defineCodeFlowTokenIntrospectionStub() { wireMockServer .stubFor(WireMock.post("/auth/realms/quarkus/access_token")