Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow token verification with user info when no introspection endpoint is available #29715

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/main/asciidoc/security-openid-connect-providers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ quarkus.oidc.client-id=<Client ID>
quarkus.oidc.credentials.secret=<Secret>
----

TIP: You can also use GitHub provider with `quarkus.oidc.application-type=service`, just set `quarkus.oidc.verify-access-token-with-user-info` configuration property to `true`.

michalvavrik marked this conversation as resolved.
Show resolved Hide resolved
=== Google

In order to set up OIDC for Google you need to create a new project in your https://console.cloud.google.com/projectcreate[Google Cloud Platform console]:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.quarkus.oidc.test;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.test.QuarkusUnitTest;

public class OpaqueTokenVerificationWithUserInfoValidationTest {

@RegisterExtension
static final QuarkusUnitTest test = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addAsResource(new StringAsset("quarkus.oidc.token.verify-access-token-with-user-info=true\n"),
"application.properties"))
.assertException(t -> {
Throwable e = t;
ConfigurationException te = null;
while (e != null) {
if (e instanceof ConfigurationException) {
te = (ConfigurationException) e;
break;
}
e = e.getCause();
}
assertNotNull(te);
// assert UserInfo is required
assertTrue(
te.getMessage()
.contains("UserInfo is not required but 'verifyAccessTokenWithUserInfo' is enabled"),
te.getMessage());
});

@Test
public void test() {
Assertions.fail();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;

import io.quarkus.oidc.common.runtime.OidcCommonConfig;
import io.quarkus.oidc.common.runtime.OidcConstants;
Expand Down Expand Up @@ -1189,6 +1188,16 @@ public static Token fromAudience(String... audience) {
@ConfigItem(defaultValue = "true")
public boolean allowOpaqueTokenIntrospection = true;

/**
* Indirectly verify that the opaque (binary) access token is valid by using it to request UserInfo.
* Opaque access token is considered valid if the provider accepted this token and returned a valid UserInfo.
* You should only enable this option if the opaque access tokens have to be accepted but OpenId Connect
* provider does not have a token introspection endpoint.
* This property will have no effect when JWT tokens have to be verified.
*/
@ConfigItem(defaultValue = "false")
public boolean verifyAccessTokenWithUserInfo;

public Optional<String> getIssuer() {
return issuer;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,38 +99,40 @@ private Uni<SecurityIdentity> validateAllTokensWithOidcServer(RoutingContext ver
TokenAuthenticationRequest request,
TenantConfigContext resolvedContext) {

Uni<TokenVerificationResult> codeAccessTokenUni = verifyCodeFlowAccessTokenUni(vertxContext, request, resolvedContext);
Uni<UserInfo> userInfo = resolvedContext.oidcConfig.authentication.isUserInfoRequired().orElse(false)
? getUserInfoUni(vertxContext, request, resolvedContext)
: NULL_USER_INFO_UNI;

return codeAccessTokenUni.onItemOrFailure().transformToUni(
new BiFunction<TokenVerificationResult, Throwable, Uni<? extends SecurityIdentity>>() {
return userInfo.onItemOrFailure().transformToUni(
new BiFunction<UserInfo, Throwable, Uni<? extends SecurityIdentity>>() {
@Override
public Uni<SecurityIdentity> apply(TokenVerificationResult codeAccessToken, Throwable t) {
public Uni<SecurityIdentity> apply(UserInfo userInfo, Throwable t) {
if (t != null) {
return Uni.createFrom().failure(new AuthenticationFailedException(t));
}
return validateTokenWithOidcServer(vertxContext, request, resolvedContext, codeAccessToken);
return validateTokenWithOidcServer(vertxContext, request, resolvedContext, userInfo);
}
});
}

private Uni<SecurityIdentity> validateTokenWithOidcServer(RoutingContext vertxContext, TokenAuthenticationRequest request,
TenantConfigContext resolvedContext, TokenVerificationResult codeAccessTokenResult) {
TenantConfigContext resolvedContext, UserInfo userInfo) {

if (codeAccessTokenResult != null) {
vertxContext.put(CODE_ACCESS_TOKEN_RESULT, codeAccessTokenResult);
}

Uni<UserInfo> userInfo = resolvedContext.oidcConfig.authentication.isUserInfoRequired().orElse(false)
? getUserInfoUni(vertxContext, request, resolvedContext)
: NULL_USER_INFO_UNI;
Uni<TokenVerificationResult> codeAccessTokenUni = verifyCodeFlowAccessTokenUni(vertxContext, request, resolvedContext,
userInfo);

return userInfo.onItemOrFailure().transformToUni(
new BiFunction<UserInfo, Throwable, Uni<? extends SecurityIdentity>>() {
return codeAccessTokenUni.onItemOrFailure().transformToUni(
new BiFunction<TokenVerificationResult, Throwable, Uni<? extends SecurityIdentity>>() {
@Override
public Uni<SecurityIdentity> apply(UserInfo userInfo, Throwable t) {
public Uni<SecurityIdentity> apply(TokenVerificationResult codeAccessToken, Throwable t) {
if (t != null) {
return Uni.createFrom().failure(new AuthenticationFailedException(t));
}

if (codeAccessToken != null) {
vertxContext.put(CODE_ACCESS_TOKEN_RESULT, codeAccessToken);
}

return createSecurityIdentityWithOidcServer(vertxContext, request, resolvedContext, userInfo);
}
});
Expand All @@ -148,7 +150,7 @@ private Uni<SecurityIdentity> createSecurityIdentityWithOidcServer(RoutingContex
tokenUni = verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken());
}
} else {
tokenUni = verifyTokenUni(resolvedContext, request.getToken().getToken());
tokenUni = verifyTokenUni(resolvedContext, request.getToken().getToken(), userInfo);
}

return tokenUni.onItemOrFailure()
Expand Down Expand Up @@ -194,28 +196,40 @@ public Uni<SecurityIdentity> apply(TokenVerificationResult result, Throwable t)
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder();
builder.addCredential(tokenCred);
OidcUtils.setSecurityIdentityUserInfo(builder, userInfo);
OidcUtils.setSecurityIdentityIntrospection(builder, result.introspectionResult);
OidcUtils.setSecurityIdentityConfigMetadata(builder, resolvedContext);
String principalMember = "";
if (result.introspectionResult.contains(OidcConstants.INTROSPECTION_TOKEN_USERNAME)) {
principalMember = OidcConstants.INTROSPECTION_TOKEN_USERNAME;
} else if (result.introspectionResult.contains(OidcConstants.INTROSPECTION_TOKEN_SUB)) {
// fallback to "sub", if "username" is not present
principalMember = OidcConstants.INTROSPECTION_TOKEN_SUB;
final String userName;
if (result.introspectionResult == null) {
if (resolvedContext.oidcConfig.token.allowJwtIntrospection) {
userName = "";
} else {
// we don't expect this to ever happen
LOG.debug("Illegal state - token introspection result is not available.");
return Uni.createFrom().failure(new AuthenticationFailedException());
}
} else {
OidcUtils.setSecurityIdentityIntrospection(builder, result.introspectionResult);
String principalMember = "";
if (result.introspectionResult.contains(OidcConstants.INTROSPECTION_TOKEN_USERNAME)) {
principalMember = OidcConstants.INTROSPECTION_TOKEN_USERNAME;
} else if (result.introspectionResult.contains(OidcConstants.INTROSPECTION_TOKEN_SUB)) {
// fallback to "sub", if "username" is not present
principalMember = OidcConstants.INTROSPECTION_TOKEN_SUB;
}
userName = principalMember.isEmpty() ? ""
: result.introspectionResult.getString(principalMember);
if (result.introspectionResult.contains(OidcConstants.TOKEN_SCOPE)) {
for (String role : result.introspectionResult.getString(OidcConstants.TOKEN_SCOPE)
.split(" ")) {
builder.addRole(role.trim());
}
}
}
final String userName = principalMember.isEmpty() ? ""
: result.introspectionResult.getString(principalMember);
builder.setPrincipal(new Principal() {
@Override
public String getName() {
return userName;
}
});
if (result.introspectionResult.contains(OidcConstants.TOKEN_SCOPE)) {
for (String role : result.introspectionResult.getString(OidcConstants.TOKEN_SCOPE).split(" ")) {
builder.addRole(role.trim());
}
}
if (userInfo != null) {
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig,
new JsonObject(userInfo.getJsonObject().toString()));
Expand Down Expand Up @@ -271,23 +285,34 @@ private static JsonObject getRolesJson(RoutingContext vertxContext, TenantConfig

private Uni<TokenVerificationResult> verifyCodeFlowAccessTokenUni(RoutingContext vertxContext,
TokenAuthenticationRequest request,
TenantConfigContext resolvedContext) {
TenantConfigContext resolvedContext, UserInfo userInfo) {
if (request.getToken() instanceof IdTokenCredential
&& (resolvedContext.oidcConfig.authentication.verifyAccessToken
|| resolvedContext.oidcConfig.roles.source.orElse(null) == Source.accesstoken)) {
final String codeAccessToken = (String) vertxContext.get(OidcConstants.ACCESS_TOKEN_VALUE);
return verifyTokenUni(resolvedContext, codeAccessToken);
return verifyTokenUni(resolvedContext, codeAccessToken, userInfo);
} else {
return NULL_CODE_ACCESS_TOKEN_UNI;
}
}

private Uni<TokenVerificationResult> verifyTokenUni(TenantConfigContext resolvedContext, String token) {
private Uni<TokenVerificationResult> verifyTokenUni(TenantConfigContext resolvedContext, String token, UserInfo userInfo) {
if (OidcUtils.isOpaqueToken(token)) {
if (!resolvedContext.oidcConfig.token.allowOpaqueTokenIntrospection) {
LOG.debug("Token is opaque but the opaque token introspection is not allowed");
throw new AuthenticationFailedException();
}
// verify opaque access token with UserInfo if enabled and introspection URI is absent
if (resolvedContext.oidcConfig.token.verifyAccessTokenWithUserInfo
&& resolvedContext.provider.getMetadata().getIntrospectionUri() == null) {
if (userInfo == null) {
return Uni.createFrom().failure(
new AuthenticationFailedException("Opaque access token verification failed as user info is null."));
} else {
// valid token verification result
return Uni.createFrom().item(new TokenVerificationResult(null, null));
}
}
LOG.debug("Starting the opaque token introspection");
return introspectTokenUni(resolvedContext, token);
} else if (resolvedContext.provider.getMetadata().getJsonWebKeySetUri() == null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ private Uni<TenantConfigContext> createTenantContext(Vertx vertx, OidcTenantConf
}
}

if (oidcConfig.token.verifyAccessTokenWithUserInfo) {
if (!oidcConfig.authentication.isUserInfoRequired().orElse(false)) {
throw new ConfigurationException(
"UserInfo is not required but 'verifyAccessTokenWithUserInfo' is enabled");
}
if (!oidcConfig.isDiscoveryEnabled().orElse(true)) {
if (oidcConfig.userInfoPath.isEmpty()) {
throw new ConfigurationException(
"UserInfo path is missing but 'verifyAccessTokenWithUserInfo' is enabled");
}
if (oidcConfig.introspectionPath.isPresent()) {
throw new ConfigurationException(
"Introspection path is configured and 'verifyAccessTokenWithUserInfo' is enabled, these options are mutually exclusive");
}
}
}

return createOidcProvider(oidcConfig, tlsConfig, vertx)
.onItem().transform(p -> new TenantConfigContext(p, oidcConfig));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.it.keycloak;

import javax.annotation.security.PermitAll;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
Expand Down Expand Up @@ -48,4 +49,11 @@ public String accessGitHubCachedInIdToken() {
public String accessDynamicGitHub() {
return access();
}

@GET
@PermitAll
@Path("/clear-token-cache")
public void clearTokenCache() {
tokenCache.clearCache();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRe
if (routingContext != null &&
(routingContext.normalizedPath().endsWith("code-flow-user-info-only")
|| routingContext.normalizedPath().endsWith("code-flow-user-info-github")
|| routingContext.normalizedPath().endsWith("bearer-user-info-github-service")
|| routingContext.normalizedPath().endsWith("code-flow-user-info-dynamic-github")
|| routingContext.normalizedPath().endsWith("code-flow-user-info-github-cached-in-idtoken"))) {
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public String resolve(RoutingContext context) {
if (path.endsWith("code-flow-user-info-github")) {
return "code-flow-user-info-github";
}
if (path.endsWith("bearer-user-info-github-service")) {
return "bearer-user-info-github-service";
}
if (path.endsWith("code-flow-user-info-github-cached-in-idtoken")) {
return "code-flow-user-info-github-cached-in-idtoken";
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.quarkus.it.keycloak;

import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.UserInfo;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;

@Authenticated
@Path("/bearer-user-info-github-service")
public class OpaqueGithubResource {

@Inject
UserInfo userInfo;

@Inject
SecurityIdentity identity;

@Inject
AccessTokenCredential accessTokenCredential;

@GET
public String access() {
return String.format("%s:%s:%s", identity.getPrincipal().getName(), userInfo.getString("preferred_username"),
accessTokenCredential.getToken());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ quarkus.oidc.code-flow-user-info-github.code-grant.headers.X-Custom=XCustomHeade
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.bearer-user-info-github-service.provider=github
quarkus.oidc.bearer-user-info-github-service.token.verify-access-token-with-user-info=true
quarkus.oidc.bearer-user-info-github-service.application-type=service
quarkus.oidc.bearer-user-info-github-service.auth-server-url=${keycloak.url}/realms/quarkus/
quarkus.oidc.bearer-user-info-github-service.user-info-path=github/userinfo
quarkus.oidc.bearer-user-info-github-service.client-id=quarkus-web-app
quarkus.oidc.bearer-user-info-github-service.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow

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=/
Expand Down
Loading