Skip to content

Commit

Permalink
Merge pull request #29715 from michalvavrik/feature/oidc-service-app-…
Browse files Browse the repository at this point in the history
…user-info-token-verification

Allow token verification with user info when no introspection endpoint is available
  • Loading branch information
sberyozkin authored Dec 14, 2022
2 parents 99bc9db + cb095d4 commit 7c6bb9b
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 35 deletions.
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`.

=== 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

0 comments on commit 7c6bb9b

Please sign in to comment.