From 9eb0a295eff363dbb3ac5768addb93b027c1af29 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Fri, 24 Dec 2021 13:19:37 +0000 Subject: [PATCH] Add support for well-known OIDC providers --- ...ity-openid-connect-web-authentication.adoc | 23 ++----- .../KeycloakPolicyEnforcerRecorder.java | 3 +- .../client/runtime/OidcClientRecorder.java | 2 +- .../oidc/common/runtime/OidcCommonConfig.java | 8 +-- .../oidc/test/CustomTenantConfigResolver.java | 2 +- .../io/quarkus/oidc/OidcTenantConfig.java | 48 +++++++++----- .../runtime/CodeAuthenticationMechanism.java | 4 +- .../runtime/DefaultTenantConfigResolver.java | 2 + ...efaultTokenIntrospectionUserInfoCache.java | 1 + .../runtime/OidcAuthenticationMechanism.java | 6 +- .../oidc/runtime/OidcIdentityProvider.java | 2 +- .../io/quarkus/oidc/runtime/OidcRecorder.java | 23 ++++--- .../io/quarkus/oidc/runtime/OidcUtils.java | 63 +++++++++++++++++++ .../runtime/providers/KnownOidcProviders.java | 29 +++++++++ .../quarkus/oidc/runtime/OidcUtilsTest.java | 53 ++++++++++++++++ .../it/keycloak/CodeFlowUserInfoResource.java | 15 ++++- .../CustomSecurityIdentityAugmentor.java | 5 +- .../keycloak/CustomTenantConfigResolver.java | 46 ++++++++++++++ .../it/keycloak/CustomTenantResolver.java | 5 +- .../src/main/resources/application.properties | 9 ++- .../keycloak/CodeFlowAuthorizationTest.java | 12 +++- 21 files changed, 301 insertions(+), 60 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java create mode 100644 integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java diff --git a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc index 9e302570a8be8c..228b53d2456a91 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -674,29 +674,16 @@ Here is how you can integrate `quarkus-oidc` with `GitHub` after you have link:h [source,properties] ---- -quarkus.oidc.auth-server-url=https://github.com/login/oauth -quarkus.oidc.discovery-enabled=false -quarkus.oidc.authorization-path=authorize -quarkus.oidc.token-path=access_token -quarkus.oidc.user-info-path=https://api.github.com/user - -# See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps -quarkus.oidc.authentication.scopes=user:email +quarkus.oidc.provider=github +quarkus.oidc.client-id=github_app_clientid +quarkus.oidc.credentials.secret=github_app_clientsecret -# Make sure a user info is required -quarkus.oidc.authentication.user-info-required=true +# user:email scope is requested by default, use 'quarkus.oidc.authentication.scopes' to request differrent scopes. +# See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps for more information. # Consider enabling UserInfo Cache # quarkus.oidc.token-cache.max-size=1000 # quarkus.oidc.token-cache.time-to-live=5M - -# Allow the code flow responses without ID tokens -quarkus.oidc.authentication.id-token-required=false - -quarkus.oidc.application-type=web-app - -quarkus.oidc.client-id=github_app_clientid -quarkus.oidc.credentials.secret=github_app_clientsecret ---- This is all what is needed for an endpoint like this one to return the currently authenticated user's profile with `GET http://localhost:8080/github/userinfo` and access it as the individual `UserInfo` properties: diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java index 1abb7df66c8258..12047157ab20b7 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java @@ -15,6 +15,7 @@ import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathCacheConfig; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.oidc.common.runtime.OidcCommonConfig.Tls.Verification; import io.quarkus.oidc.runtime.OidcConfig; @@ -55,7 +56,7 @@ private static PolicyEnforcer createPolicyEnforcer(OidcTenantConfig oidcConfig, KeycloakPolicyEnforcerTenantConfig keycloakPolicyEnforcerConfig, TlsConfig tlsConfig) { - if (oidcConfig.applicationType == OidcTenantConfig.ApplicationType.WEB_APP + if (oidcConfig.applicationType.orElse(ApplicationType.SERVICE) == OidcTenantConfig.ApplicationType.WEB_APP && oidcConfig.roles.source.orElse(null) != Source.accesstoken) { throw new OIDCException("Application 'web-app' type is only supported if access token is the source of roles"); } diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java index 2d4cf94ef62155..9f8e56561ee167 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java @@ -126,7 +126,7 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig tokenRequestUriUni = Uni.createFrom().item(oidcConfig.tokenPath.get()); } else { String authServerUriString = OidcCommonUtils.getAuthServerUrl(oidcConfig); - if (!oidcConfig.discoveryEnabled) { + if (!oidcConfig.discoveryEnabled.orElse(true)) { tokenRequestUriUni = Uni.createFrom() .item(OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.tokenPath)); } else { 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 e70d009f228524..796ebd0fb07553 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 @@ -23,8 +23,8 @@ public class OidcCommonConfig { * Enables OIDC discovery. * If the discovery is disabled then the OIDC endpoint URLs must be configured individually. */ - @ConfigItem(defaultValue = "true") - public boolean discoveryEnabled = true; + @ConfigItem + public Optional discoveryEnabled = Optional.empty(); /** * Relative path or absolute URL of the OIDC token endpoint which issues access and refresh tokens. @@ -549,12 +549,12 @@ public void setCredentials(Credentials credentials) { this.credentials = credentials; } - public boolean isDiscoveryEnabled() { + public Optional isDiscoveryEnabled() { return discoveryEnabled; } public void setDiscoveryEnabled(boolean enabled) { - this.discoveryEnabled = enabled; + this.discoveryEnabled = Optional.of(enabled); } public Proxy getProxy() { diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CustomTenantConfigResolver.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CustomTenantConfigResolver.java index 55a18ea5daf1c6..343cce68a8b845 100644 --- a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CustomTenantConfigResolver.java +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CustomTenantConfigResolver.java @@ -19,7 +19,7 @@ public Uni resolve(RoutingContext context, OidcRequestContext< config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus"); config.setClientId("quarkus-web-app"); config.getCredentials().setSecret("secret"); - config.applicationType = ApplicationType.WEB_APP; + config.setApplicationType(ApplicationType.WEB_APP); return Uni.createFrom().item(config); } return Uni.createFrom().nullItem(); 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 79295853dcb8a5..35ccd7ddea414d 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 @@ -32,8 +32,8 @@ public class OidcTenantConfig extends OidcCommonConfig { /** * The application type, which can be one of the following values from enum {@link ApplicationType}. */ - @ConfigItem(defaultValue = "service") - public ApplicationType applicationType = ApplicationType.SERVICE; + @ConfigItem + public Optional applicationType = Optional.empty(); /** * Relative path or absolute URL of the OIDC authorization endpoint which authenticates the users. @@ -537,8 +537,7 @@ public static class Authentication { /** * If this property is set to 'true' then an OIDC UserInfo endpoint will be called */ - @ConfigItem(defaultValue = "false") - public boolean userInfoRequired; + public Optional userInfoRequired = Optional.empty(); /** * Session age extension in minutes. @@ -567,8 +566,8 @@ public static class Authentication { * Requires that ID token is available when the authorization code flow completes. In most case this property * should be enabled. Disable this property only when you need to use the authorization code flow with OAuth2 providers. */ - @ConfigItem(defaultValue = "true") - public boolean idTokenRequired = true; + @ConfigItem + public Optional idTokenRequired = Optional.empty(); public boolean isJavaScriptAutoRedirect() { return javaScriptAutoRedirect; @@ -590,8 +589,8 @@ public Optional> getScopes() { return scopes; } - public void setScopes(Optional> scopes) { - this.scopes = scopes; + public void setScopes(List scopes) { + this.scopes = Optional.of(scopes); } public Map getExtraParams() { @@ -642,12 +641,12 @@ public void setCookieDomain(String cookieDomain) { this.cookieDomain = Optional.of(cookieDomain); } - public boolean isUserInfoRequired() { + public Optional isUserInfoRequired() { return userInfoRequired; } public void setUserInfoRequired(boolean userInfoRequired) { - this.userInfoRequired = userInfoRequired; + this.userInfoRequired = Optional.of(userInfoRequired); } public boolean isRemoveRedirectParameters() { @@ -682,12 +681,12 @@ public void setCookiePathHeader(String cookiePathHeader) { this.cookiePathHeader = Optional.of(cookiePathHeader); } - public boolean isIdTokenRequired() { + public Optional isIdTokenRequired() { return idTokenRequired; } public void setIdTokenRequired(boolean idTokenRequired) { - this.idTokenRequired = idTokenRequired; + this.idTokenRequired = Optional.of(idTokenRequired); } public Optional getCookieSuffix() { @@ -928,12 +927,12 @@ public static enum ApplicationType { HYBRID } - public ApplicationType getApplicationType() { + public Optional getApplicationType() { return applicationType; } public void setApplicationType(ApplicationType type) { - this.applicationType = type; + this.applicationType = Optional.of(type); } public boolean isAllowTokenIntrospectionCache() { @@ -951,4 +950,25 @@ public boolean isAllowUserInfoCache() { public void setAllowUserInfoCache(boolean allowUserInfoCache) { this.allowUserInfoCache = allowUserInfoCache; } + + public Optional getProvider() { + return provider; + } + + public void setProvider(Provider provider) { + this.provider = Optional.of(provider); + } + + /** + * Well known OpenId Connect provider identifier + */ + @ConfigItem + public Optional provider = Optional.empty(); + + public enum Provider { + /** + * GitHub + */ + GITHUB + } } 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 1f8559c0e8fc03..e4c2ab6baa70ec 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 @@ -105,7 +105,7 @@ public Uni apply(AuthorizationCodeTokens session) { context.put(AuthorizationCodeTokens.class.getName(), session); return authenticate(identityProviderManager, context, new IdTokenCredential(session.getIdToken(), - !configContext.oidcConfig.authentication.isIdTokenRequired())) + !configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true))) .call(new Function>() { @Override public Uni apply(SecurityIdentity identity) { @@ -289,7 +289,7 @@ public Uni apply(final AuthorizationCodeTokens tokens, final T boolean internalIdToken = false; if (tokens.getIdToken() == null) { - if (configContext.oidcConfig.authentication.isIdTokenRequired()) { + if (configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true)) { return Uni.createFrom() .failure(new AuthenticationCompletionException("ID Token is not available")); } else { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index ac50318c112618..df64bfa2c1c0bf 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -182,6 +182,8 @@ private Uni getDynamicTenantConfig(RoutingContext context) { if (oidcConfig == null) { //shouldn't happen, but guard against it anyway oidcConfig = Uni.createFrom().nullItem(); + } else { + oidcConfig = oidcConfig.onItem().transform(cfg -> OidcUtils.resolveProviderConfig(cfg)); } context.put(CURRENT_DYNAMIC_TENANT_CONFIG, oidcConfig); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java index 0516c9b98cf055..0bdc308822ed4d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java @@ -109,6 +109,7 @@ public int getCacheSize() { public void clearCache() { cacheMap.clear(); + size.set(0); } private void removeInvalidEntries() { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java index 7ba3da804c859e..15f6bf435fd7b2 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java @@ -10,6 +10,7 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; @@ -80,10 +81,11 @@ public OidcTenantConfig apply(OidcTenantConfig oidcTenantConfig) { } private boolean isWebApp(RoutingContext context, OidcTenantConfig oidcConfig) { - if (OidcTenantConfig.ApplicationType.HYBRID == oidcConfig.applicationType) { + ApplicationType applicationType = oidcConfig.applicationType.orElse(ApplicationType.SERVICE); + if (OidcTenantConfig.ApplicationType.HYBRID == applicationType) { return context.request().getHeader("Authorization") == null; } - return OidcTenantConfig.ApplicationType.WEB_APP == oidcConfig.applicationType; + return OidcTenantConfig.ApplicationType.WEB_APP == applicationType; } @Override diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index b34d04fbc6236e..0252ac0d1ba127 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -112,7 +112,7 @@ private Uni validateTokenWithOidcServer(RoutingContext vertxCo vertxContext.put(CODE_ACCESS_TOKEN_RESULT, codeAccessTokenResult); } - Uni userInfo = resolvedContext.oidcConfig.authentication.isUserInfoRequired() + Uni userInfo = resolvedContext.oidcConfig.authentication.isUserInfoRequired().orElse(false) ? getUserInfoUni(vertxContext, request, resolvedContext) : NULL_USER_INFO_UNI; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index d355640e960837..d985f591985fca 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -127,11 +127,14 @@ private static Throwable logTenantConfigContextFailure(Throwable t, String tenan } @SuppressWarnings("resource") - private Uni createTenantContext(Vertx vertx, OidcTenantConfig oidcConfig, TlsConfig tlsConfig, + private Uni createTenantContext(Vertx vertx, OidcTenantConfig oidcTenantConfig, TlsConfig tlsConfig, String tenantId) { - if (!oidcConfig.tenantId.isPresent()) { - oidcConfig.tenantId = Optional.of(tenantId); + if (!oidcTenantConfig.tenantId.isPresent()) { + oidcTenantConfig.tenantId = Optional.of(tenantId); } + + final OidcTenantConfig oidcConfig = OidcUtils.resolveProviderConfig(oidcTenantConfig); + if (!oidcConfig.tenantEnabled) { LOG.debugf("'%s' tenant configuration is disabled", tenantId); return Uni.createFrom().item(new TenantConfigContext(new OidcProvider(null, null, null), oidcConfig)); @@ -148,7 +151,7 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf return Uni.createFrom().failure(t); } - if (!oidcConfig.discoveryEnabled) { + if (!oidcConfig.discoveryEnabled.orElse(true)) { if (!isServiceApp(oidcConfig)) { if (!oidcConfig.authorizationPath.isPresent() || !oidcConfig.tokenPath.isPresent()) { throw new ConfigurationException( @@ -159,7 +162,8 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf } // JWK and introspection endpoints have to be set for both 'web-app' and 'service' applications if (!oidcConfig.jwksPath.isPresent() && !oidcConfig.introspectionPath.isPresent()) { - if (!oidcConfig.authentication.isIdTokenRequired() && oidcConfig.authentication.isUserInfoRequired()) { + if (!oidcConfig.authentication.isIdTokenRequired().orElse(true) + && oidcConfig.authentication.isUserInfoRequired().orElse(false)) { LOG.debugf("tenant %s supports only UserInfo", oidcConfig.tenantId.get()); } else { throw new ConfigurationException( @@ -189,7 +193,8 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf if (oidcConfig.tokenStateManager.strategy != Strategy.KEEP_ALL_TOKENS) { - if (oidcConfig.authentication.isUserInfoRequired() || oidcConfig.roles.source.orElse(null) == Source.userinfo) { + if (oidcConfig.authentication.isUserInfoRequired().orElse(false) + || oidcConfig.roles.source.orElse(null) == Source.userinfo) { throw new ConfigurationException( "UserInfo is required but DefaultTokenStateManager is configured to not keep the access token"); } @@ -250,7 +255,7 @@ public OidcProvider apply(JsonWebKeySet jwks) { } protected static Uni getJsonWebSetUni(OidcProviderClient client, OidcTenantConfig oidcConfig) { - if (!oidcConfig.isDiscoveryEnabled()) { + if (!oidcConfig.isDiscoveryEnabled().orElse(true)) { final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig); return client.getJsonWebKeySet().onFailure(OidcCommonUtils.oidcEndpointNotAvailable()) .retry() @@ -277,7 +282,7 @@ protected static Uni createOidcClientUni(OidcTenantConfig oi WebClient client = WebClient.create(new io.vertx.mutiny.core.Vertx(vertx), options); Uni metadataUni = null; - if (!oidcConfig.discoveryEnabled) { + if (!oidcConfig.discoveryEnabled.orElse(true)) { metadataUni = Uni.createFrom().item(createLocalMetadata(oidcConfig, authServerUriString)); } else { final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig); @@ -327,7 +332,7 @@ private static OidcConfigurationMetadata createLocalMetadata(OidcTenantConfig oi } private static boolean isServiceApp(OidcTenantConfig oidcConfig) { - return ApplicationType.SERVICE.equals(oidcConfig.applicationType); + return ApplicationType.SERVICE.equals(oidcConfig.applicationType.orElse(ApplicationType.SERVICE)); } private static void verifyAuthServerUrl(OidcCommonConfig oidcConfig) { 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 f39d8e8488ac68..c1c3e443280cc8 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 @@ -25,6 +25,7 @@ import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.TokenStateManager; import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.runtime.providers.KnownOidcProviders; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.AuthenticationRequestContext; @@ -282,4 +283,66 @@ static void setCookiePath(RoutingContext context, Authentication auth, ServerCoo cookie.setPath(auth.getCookiePath()); } } + + /** + * Merge the current tenant and well-known OpenId Connect provider configurations. + * Initialized properties take priority over uninitialized properties. + * + * Initialized properties in the current tenant configuration take priority + * over the same initialized properties in the well-known OpenId Connect provider configuration. + * + * Tenant id property of the current tenant must be set before the merge operation. + * + * @param tenant current tenant configuration + * @param provider well-known OpenId Connect provider configuration + * @return merged configuration + */ + static OidcTenantConfig mergeTenantConfig(OidcTenantConfig tenant, OidcTenantConfig provider) { + if (tenant.tenantId.isEmpty()) { + // OidcRecorder sets it before the merge operation + throw new IllegalStateException(); + } + // root properties + if (tenant.authServerUrl.isEmpty()) { + tenant.authServerUrl = provider.authServerUrl; + } + if (tenant.applicationType.isEmpty()) { + tenant.applicationType = provider.applicationType; + } + if (tenant.discoveryEnabled.isEmpty()) { + tenant.discoveryEnabled = provider.discoveryEnabled; + } + if (tenant.authorizationPath.isEmpty()) { + tenant.authorizationPath = provider.authorizationPath; + } + if (tenant.tokenPath.isEmpty()) { + tenant.tokenPath = provider.tokenPath; + } + if (tenant.userInfoPath.isEmpty()) { + tenant.userInfoPath = provider.userInfoPath; + } + + // authentication + if (tenant.authentication.idTokenRequired.isEmpty()) { + tenant.authentication.idTokenRequired = provider.authentication.idTokenRequired; + } + if (tenant.authentication.userInfoRequired.isEmpty()) { + tenant.authentication.userInfoRequired = provider.authentication.userInfoRequired; + } + if (tenant.authentication.scopes.isEmpty()) { + tenant.authentication.scopes = provider.authentication.scopes; + } + + return tenant; + } + + static OidcTenantConfig resolveProviderConfig(OidcTenantConfig oidcTenantConfig) { + if (oidcTenantConfig != null && oidcTenantConfig.provider.isPresent()) { + return OidcUtils.mergeTenantConfig(oidcTenantConfig, + KnownOidcProviders.provider(oidcTenantConfig.provider.get())); + } else { + return oidcTenantConfig; + } + + } } 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 new file mode 100644 index 00000000000000..7941f6915ca3a2 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/KnownOidcProviders.java @@ -0,0 +1,29 @@ +package io.quarkus.oidc.runtime.providers; + +import java.util.List; + +import io.quarkus.oidc.OidcTenantConfig; + +public class KnownOidcProviders { + + public static OidcTenantConfig provider(OidcTenantConfig.Provider provider) { + if (OidcTenantConfig.Provider.GITHUB == provider) { + return github(); + } + return null; + } + + private static OidcTenantConfig github() { + OidcTenantConfig ret = new OidcTenantConfig(); + ret.setAuthServerUrl("https://github.com/login/oauth"); + ret.setApplicationType(OidcTenantConfig.ApplicationType.WEB_APP); + ret.setDiscoveryEnabled(false); + ret.setAuthorizationPath("authorize"); + ret.setTokenPath("access_token"); + ret.setUserInfoPath("https://api.github.com/user"); + ret.authentication.setScopes(List.of("user:email")); + ret.authentication.setUserInfoRequired(true); + ret.authentication.setIdTokenRequired(false); + return ret; + } +} 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 dffd713a7a0319..a51a70858faf92 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 @@ -21,11 +21,64 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.ApplicationType; +import io.quarkus.oidc.OidcTenantConfig.Provider; +import io.quarkus.oidc.runtime.providers.KnownOidcProviders; import io.smallrye.jwt.build.Jwt; import io.vertx.core.json.JsonObject; public class OidcUtilsTest { + @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()); + assertEquals(List.of("user:email"), config.authentication.scopes.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.authentication.setScopes(List.of("write")); + + 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()); + assertEquals(List.of("write"), config.authentication.scopes.get()); + } + @Test public void testCorrectTokenType() throws Exception { OidcTenantConfig.Token tokenClaims = new OidcTenantConfig.Token(); diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java index 92374c75b0b9db..dc53433a87a0b9 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java @@ -9,7 +9,7 @@ import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; -@Path("/code-flow-user-info") +@Path("/") @Authenticated public class CodeFlowUserInfoResource { @@ -23,10 +23,23 @@ public class CodeFlowUserInfoResource { DefaultTokenIntrospectionUserInfoCache tokenCache; @GET + @Path("/code-flow-user-info-only") public String access() { int cacheSize = tokenCache.getCacheSize(); tokenCache.clearCache(); return identity.getPrincipal().getName() + ":" + userInfo.getString("preferred_username") + ", cache size: " + cacheSize; } + + @GET + @Path("/code-flow-user-info-github") + public String accessGitHub() { + return access(); + } + + @GET + @Path("/code-flow-user-info-dynamic-github") + public String accessDynamicGitHub() { + return access(); + } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java index 5a2f0cb268a445..c0682efe28f229 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java @@ -18,7 +18,10 @@ public class CustomSecurityIdentityAugmentor implements SecurityIdentityAugmento @Override public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { RoutingContext routingContext = identity.getAttribute(RoutingContext.class.getName()); - if (routingContext != null && routingContext.normalizedPath().endsWith("code-flow-user-info")) { + if (routingContext != null && + (routingContext.normalizedPath().endsWith("code-flow-user-info-only") + || routingContext.normalizedPath().endsWith("code-flow-user-info-github") + || routingContext.normalizedPath().endsWith("code-flow-user-info-dynamic-github"))) { QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); UserInfo userInfo = identity.getAttribute("userinfo"); builder.setPrincipal(new Principal() { diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java new file mode 100644 index 00000000000000..010b453bee188f --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java @@ -0,0 +1,46 @@ +package io.quarkus.it.keycloak; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.Provider; +import io.quarkus.oidc.TenantConfigResolver; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomTenantConfigResolver implements TenantConfigResolver { + + @Inject + @ConfigProperty(name = "keycloak.url") + String keycloakUrl; + + @Override + public Uni resolve(RoutingContext context, + OidcRequestContext requestContext) { + String path = context.normalizedPath(); + if (path.endsWith("code-flow-user-info-dynamic-github")) { + + OidcTenantConfig config = new OidcTenantConfig(); + config.setTenantId("code-flow-user-info-dynamic-github"); + + config.setProvider(Provider.GITHUB); + + config.setAuthServerUrl(keycloakUrl + "/realms/quarkus/"); + config.setAuthorizationPath("/"); + config.setUserInfoPath("protocol/openid-connect/userinfo"); + config.setClientId("quarkus-web-app"); + config.getCredentials() + .setSecret("AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"); + + return Uni.createFrom().item(config); + } + + return Uni.createFrom().nullItem(); + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index a7b239323f982d..cf1030a0a41728 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -17,9 +17,12 @@ public String resolve(RoutingContext context) { if (path.endsWith("code-flow") || path.endsWith("code-flow/logout")) { return "code-flow"; } - if (path.endsWith("code-flow-user-info")) { + if (path.endsWith("code-flow-user-info-only")) { return "code-flow-user-info-only"; } + if (path.endsWith("code-flow-user-info-github")) { + return "code-flow-user-info-github"; + } if (path.endsWith("bearer")) { return "bearer"; } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 56b7c4fd303849..18764bac367f58 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -23,7 +23,7 @@ quarkus.oidc.code-flow.application-type=web-app quarkus.oidc.code-flow-user-info-only.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.code-flow-user-info-only.discovery-enabled=false quarkus.oidc.code-flow-user-info-only.authorization-path=/ -quarkus.oidc.code-flow-user-info-only.token-path=oauth2-tokens +quarkus.oidc.code-flow-user-info-only.token-path=access_token quarkus.oidc.code-flow-user-info-only.user-info-path=protocol/openid-connect/userinfo quarkus.oidc.code-flow-user-info-only.authentication.id-token-required=false quarkus.oidc.code-flow-user-info-only.authentication.user-info-required=true @@ -31,6 +31,13 @@ quarkus.oidc.code-flow-user-info-only.client-id=quarkus-web-app quarkus.oidc.code-flow-user-info-only.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow quarkus.oidc.code-flow-user-info-only.application-type=web-app +quarkus.oidc.code-flow-user-info-github.provider=github +quarkus.oidc.code-flow-user-info-github.auth-server-url=${keycloak.url}/realms/quarkus/ +quarkus.oidc.code-flow-user-info-github.authorization-path=/ +quarkus.oidc.code-flow-user-info-github.user-info-path=protocol/openid-connect/userinfo +quarkus.oidc.code-flow-user-info-github.client-id=quarkus-web-app +quarkus.oidc.code-flow-user-info-github.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow + quarkus.oidc.token-cache.max-size=1 quarkus.oidc.bearer.auth-server-url=${keycloak.url}/realms/quarkus/ 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 ce8085f5a7f64f..307a13dc370447 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 @@ -60,9 +60,15 @@ public void testCodeFlow() throws IOException { @Test public void testCodeFlowUserInfo() throws IOException { defineCodeFlowAuthorizationOauth2TokenStub(); + doTestCodeFlowUserInfo("code-flow-user-info-only"); + doTestCodeFlowUserInfo("code-flow-user-info-github"); + doTestCodeFlowUserInfo("code-flow-user-info-dynamic-github"); + } + + private void doTestCodeFlowUserInfo(String tenantId) throws IOException { try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); - HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info"); + HtmlPage page = webClient.getPage("http://localhost:8081/" + tenantId); HtmlForm form = page.getFormByName("form"); form.getInputByName("username").type("alice"); @@ -72,7 +78,7 @@ public void testCodeFlowUserInfo() throws IOException { assertEquals("alice:alice, cache size: 1", page.getBody().asText()); - assertNotNull(getSessionCookie(webClient, "code-flow-user-info-only")); + assertNotNull(getSessionCookie(webClient, tenantId)); webClient.getCookieManager().clearCookies(); } } @@ -84,7 +90,7 @@ private WebClient createWebClient() { } private void defineCodeFlowAuthorizationOauth2TokenStub() { - wireMockServer.stubFor(WireMock.post("/auth/realms/quarkus/oauth2-tokens") + wireMockServer.stubFor(WireMock.post("/auth/realms/quarkus/access_token") .withRequestBody(containing("authorization_code")) .willReturn(WireMock.aResponse() .withHeader("Content-Type", "application/json")