Skip to content

Commit

Permalink
Merge pull request #45337 from sberyozkin/oidc_refresh_opaque_access_…
Browse files Browse the repository at this point in the history
…token

Use access token expires_in to refresh code flow tokens
  • Loading branch information
sberyozkin authored Jan 12, 2025
2 parents 0b4c147 + d13deae commit e62c059
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.jose4j.lang.UnresolvableKeyException;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.AuthorizationCodeTokens;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcTenantConfig;
Expand Down Expand Up @@ -199,7 +200,7 @@ private Uni<TokenVerificationResult> verifyPrimaryTokenUni(Map<String, Object> r
} else {
final boolean idToken = isIdToken(request);
Uni<TokenVerificationResult> result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken,
userInfo);
false, userInfo);
if (!idToken && resolvedContext.oidcConfig().token().binding().certificate()) {
return result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {

Expand Down Expand Up @@ -269,7 +270,7 @@ public Uni<SecurityIdentity> apply(TokenVerificationResult codeAccessTokenResult
}
if (codeAccessTokenResult != null) {
if (tokenAutoRefreshPrepared(codeAccessTokenResult, requestData,
resolvedContext.oidcConfig())) {
resolvedContext.oidcConfig(), true)) {
return Uni.createFrom().failure(new TokenAutoRefreshException(null));
}
requestData.put(OidcUtils.CODE_ACCESS_TOKEN_RESULT, codeAccessTokenResult);
Expand Down Expand Up @@ -346,7 +347,7 @@ private Uni<SecurityIdentity> createSecurityIdentityWithOidcServer(TokenVerifica
// If the primary token is a bearer access token then there's no point of checking if
// it should be refreshed as RT is only available for the code flow tokens
if (isIdToken(request)
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig())) {
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig(), false)) {
return Uni.createFrom().failure(new TokenAutoRefreshException(securityIdentity));
} else {
return Uni.createFrom().item(securityIdentity);
Expand Down Expand Up @@ -412,7 +413,7 @@ public String getName() {
// If the primary token is a bearer access token then there's no point of checking if
// it should be refreshed as RT is only available for the code flow tokens
if (isIdToken(request)
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig())) {
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig(), false)) {
return Uni.createFrom().failure(new TokenAutoRefreshException(identity));
}
return Uni.createFrom().item(identity);
Expand All @@ -429,7 +430,7 @@ private static boolean isIdToken(TokenAuthenticationRequest request) {
}

private static boolean tokenAutoRefreshPrepared(TokenVerificationResult result, Map<String, Object> requestData,
OidcTenantConfig oidcConfig) {
OidcTenantConfig oidcConfig, boolean codeFlowAccessToken) {
if (result != null && oidcConfig.token().refreshExpired()
&& oidcConfig.token().refreshTokenTimeSkew().isPresent()
&& requestData.get(REFRESH_TOKEN_GRANT_RESPONSE) != Boolean.TRUE
Expand All @@ -440,9 +441,18 @@ private static boolean tokenAutoRefreshPrepared(TokenVerificationResult result,
} else if (result.introspectionResult != null) {
expiry = result.introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP);
}
final long now = System.currentTimeMillis() / 1000;
if (expiry == null && codeFlowAccessToken) {
// JWT or introspection response `exp` property has a number of seconds since epoch.
// The code flow access token `expires_in` property is relative to the current time.
Long expiresIn = ((AuthorizationCodeTokens) requestData.get(AuthorizationCodeTokens.class.getName()))
.getAccessTokenExpiresIn();
if (expiresIn != null) {
expiry = now + expiresIn;
}
}
if (expiry != null) {
final long refreshTokenTimeSkew = oidcConfig.token().refreshTokenTimeSkew().get().getSeconds();
final long now = System.currentTimeMillis() / 1000;
return now + refreshTokenTimeSkew > expiry;
}
}
Expand Down Expand Up @@ -478,15 +488,21 @@ private Uni<TokenVerificationResult> verifyCodeFlowAccessTokenUni(Map<String, Ob
&& (resolvedContext.oidcConfig().authentication().verifyAccessToken()
|| resolvedContext.oidcConfig().roles().source().orElse(null) == Source.accesstoken)) {
final String codeAccessToken = (String) requestData.get(OidcConstants.ACCESS_TOKEN_VALUE);
return verifyTokenUni(requestData, resolvedContext, new AccessTokenCredential(codeAccessToken), false, userInfo);
return verifyTokenUni(requestData, resolvedContext, new AccessTokenCredential(codeAccessToken), false, true,
userInfo);
} else {
return NULL_CODE_ACCESS_TOKEN_UNI;
}
}

private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestData, TenantConfigContext resolvedContext,
TokenCredential tokenCred, boolean enforceAudienceVerification, UserInfo userInfo) {
TokenCredential tokenCred, boolean enforceAudienceVerification, boolean codeFlowAccessToken, UserInfo userInfo) {
final String token = tokenCred.getToken();
Long expiresIn = null;
if (codeFlowAccessToken) {
expiresIn = ((AuthorizationCodeTokens) requestData.get(AuthorizationCodeTokens.class.getName()))
.getAccessTokenExpiresIn();
}
if (OidcUtils.isOpaqueToken(token)) {
if (!resolvedContext.oidcConfig().token().allowOpaqueTokenIntrospection()) {
LOG.debug("Token is opaque but the opaque token introspection is not allowed");
Expand All @@ -504,12 +520,12 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
}
}
LOG.debug("Starting the opaque token introspection");
return introspectTokenUni(resolvedContext, token, false);
return introspectTokenUni(resolvedContext, token, expiresIn, false);
} else if (resolvedContext.provider().getMetadata().getJsonWebKeySetUri() == null
|| resolvedContext.oidcConfig().token().requireJwtIntrospectionOnly()) {
// Verify JWT token with the remote introspection
LOG.debug("Starting the JWT token introspection");
return introspectTokenUni(resolvedContext, token, false);
return introspectTokenUni(resolvedContext, token, expiresIn, false);
} else if (resolvedContext.oidcConfig().jwks().resolveEarly()) {
// Verify JWT token with the local JWK keys with a possible remote introspection fallback
final String nonce = tokenCred instanceof IdTokenCredential ? (String) requestData.get(OidcConstants.NONCE) : null;
Expand All @@ -522,7 +538,7 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
if (t.getCause() instanceof UnresolvableKeyException) {
LOG.debug("No matching JWK key is found, refreshing and repeating the token verification");
return refreshJwksAndVerifyTokenUni(resolvedContext, token, enforceAudienceVerification,
resolvedContext.oidcConfig().token().subjectRequired(), nonce);
resolvedContext.oidcConfig().token().subjectRequired(), nonce, expiresIn);
} else {
LOG.debugf("Token verification has failed: %s", t.getMessage());
return Uni.createFrom().failure(t);
Expand All @@ -531,7 +547,7 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
} else {
final String nonce = (String) requestData.get(OidcConstants.NONCE);
return resolveJwksAndVerifyTokenUni(resolvedContext, tokenCred, enforceAudienceVerification,
resolvedContext.oidcConfig().token().subjectRequired(), nonce);
resolvedContext.oidcConfig().token().subjectRequired(), nonce, expiresIn);
}
}

Expand All @@ -545,21 +561,21 @@ private Uni<TokenVerificationResult> verifySelfSignedTokenUni(TenantConfigContex
}

private Uni<TokenVerificationResult> refreshJwksAndVerifyTokenUni(TenantConfigContext resolvedContext, String token,
boolean enforceAudienceVerification, boolean subjectRequired, String nonce) {
boolean enforceAudienceVerification, boolean subjectRequired, String nonce, Long expiresIn) {
return resolvedContext.provider()
.refreshJwksAndVerifyJwtToken(token, enforceAudienceVerification, subjectRequired, nonce)
.onFailure(f -> fallbackToIntrospectionIfNoMatchingKey(f, resolvedContext))
.recoverWithUni(f -> introspectTokenUni(resolvedContext, token, true));
.recoverWithUni(f -> introspectTokenUni(resolvedContext, token, expiresIn, true));
}

private Uni<TokenVerificationResult> resolveJwksAndVerifyTokenUni(TenantConfigContext resolvedContext,
TokenCredential tokenCred,
boolean enforceAudienceVerification, boolean subjectRequired, String nonce) {
boolean enforceAudienceVerification, boolean subjectRequired, String nonce, Long expiresIn) {
return resolvedContext.provider()
.getKeyResolverAndVerifyJwtToken(tokenCred, enforceAudienceVerification, subjectRequired, nonce,
(tokenCred instanceof IdTokenCredential))
.onFailure(f -> fallbackToIntrospectionIfNoMatchingKey(f, resolvedContext))
.recoverWithUni(f -> introspectTokenUni(resolvedContext, tokenCred.getToken(), true));
.recoverWithUni(f -> introspectTokenUni(resolvedContext, tokenCred.getToken(), expiresIn, true));
}

private static boolean fallbackToIntrospectionIfNoMatchingKey(Throwable f, TenantConfigContext resolvedContext) {
Expand All @@ -577,28 +593,29 @@ private static boolean fallbackToIntrospectionIfNoMatchingKey(Throwable f, Tenan
}

private Uni<TokenVerificationResult> introspectTokenUni(TenantConfigContext resolvedContext, final String token,
boolean fallbackFromJwkMatch) {
Long expiresIn, boolean fallbackFromJwkMatch) {
TokenIntrospectionCache tokenIntrospectionCache = tenantResolver.getTokenIntrospectionCache();
Uni<TokenIntrospection> tokenIntrospectionUni = tokenIntrospectionCache == null ? null
: tokenIntrospectionCache
.getIntrospection(token, resolvedContext.oidcConfig(), getIntrospectionRequestContext);
if (tokenIntrospectionUni == null) {
tokenIntrospectionUni = newTokenIntrospectionUni(resolvedContext, token, fallbackFromJwkMatch);
tokenIntrospectionUni = newTokenIntrospectionUni(resolvedContext, token, expiresIn, fallbackFromJwkMatch);
} else {
tokenIntrospectionUni = tokenIntrospectionUni.onItem().ifNull()
.switchTo(new Supplier<Uni<? extends TokenIntrospection>>() {
@Override
public Uni<TokenIntrospection> get() {
return newTokenIntrospectionUni(resolvedContext, token, fallbackFromJwkMatch);
return newTokenIntrospectionUni(resolvedContext, token, expiresIn, fallbackFromJwkMatch);
}
});
}
return tokenIntrospectionUni.onItem().transform(t -> new TokenVerificationResult(null, t));
}

private Uni<TokenIntrospection> newTokenIntrospectionUni(TenantConfigContext resolvedContext, String token,
boolean fallbackFromJwkMatch) {
Uni<TokenIntrospection> tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, fallbackFromJwkMatch);
Long expiresIn, boolean fallbackFromJwkMatch) {
Uni<TokenIntrospection> tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, expiresIn,
fallbackFromJwkMatch);
if (tenantResolver.getTokenIntrospectionCache() == null
|| !resolvedContext.oidcConfig().allowTokenIntrospectionCache()) {
return tokenIntrospectionUni;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ public Uni<? extends TokenVerificationResult> apply(VerificationKeyResolver reso
});
}

public Uni<TokenIntrospection> introspectToken(String token, boolean fallbackFromJwkMatch) {
public Uni<TokenIntrospection> introspectToken(String token, Long expiresIn, boolean fallbackFromJwkMatch) {
if (client.getMetadata().getIntrospectionUri() == null) {
String errorMessage = String.format("Token issued to client %s "
+ (fallbackFromJwkMatch ? "does not have a matching verification key and it " : "")
Expand All @@ -366,12 +366,17 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl
if (t != null) {
throw new AuthenticationFailedException(t);
}
Long introspectionExpiresIn = introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP);
if (introspectionExpiresIn == null && expiresIn != null) {
// expires_in is relative to the current time
introspectionExpiresIn = now() + expiresIn;
}
if (!introspectionResult.isActive()) {
verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP));
verifyTokenExpiry(introspectionExpiresIn);
throw new AuthenticationFailedException(
String.format("Token issued to client %s is not active", oidcConfig.clientId().get()));
}
verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP));
verifyTokenExpiry(introspectionExpiresIn);
try {
verifyTokenAge(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_IAT));
} catch (InvalidJwtException ex) {
Expand Down Expand Up @@ -402,20 +407,20 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl
return introspectionResult;
}

private void verifyTokenExpiry(Long exp) {
if (isTokenExpired(exp)) {
String error = String.format("Token issued to client %s has expired",
oidcConfig.clientId().get());
LOG.debugf(error);
throw new AuthenticationFailedException(
new InvalidJwtException(error,
List.of(new ErrorCodeValidator.Error(ErrorCodes.EXPIRED, error)), null));
}
}

});
}

private void verifyTokenExpiry(Long exp) {
if (isTokenExpired(exp)) {
String error = String.format("Token issued to client %s has expired",
oidcConfig.clientId().get());
LOG.debugf(error);
throw new AuthenticationFailedException(
new InvalidJwtException(error,
List.of(new ErrorCodeValidator.Error(ErrorCodes.EXPIRED, error)), null));
}
}

private boolean isTokenExpired(Long exp) {
return exp != null && now() / 1000 > exp + getLifespanGrace();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -819,9 +819,10 @@ enum ResponseMode {

/**
* Internal ID token lifespan.
* This property is only checked when an internal IdToken is generated when Oauth2 providers do not return IdToken.
* This property is only checked when an internal IdToken is generated when OAuth2 providers do not return IdToken.
* If this property is not configured then an access token `expires_in` property
* in the OAuth2 authorization code flow response is used to set an internal IdToken lifespan.
*/
@ConfigDocDefault("5M")
Optional<Duration> internalIdTokenLifespan();

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.quarkus.it.keycloak;

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

import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.security.Authenticated;

@Path("/code-flow-token-introspection-expires-in")
@Authenticated
public class CodeFlowTokenIntrospectionExpiresInResource {

@Inject
TokenIntrospection tokenIntrospection;

@GET
public String access() {
return tokenIntrospection.getUsername();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ quarkus.oidc.code-flow-token-introspection.client-id=quarkus-web-app
quarkus.oidc.code-flow-token-introspection.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow
quarkus.oidc.code-flow-token-introspection.code-grant.headers.X-Custom=XTokenIntrospection

quarkus.oidc.code-flow-token-introspection-expires-in.auth-server-url=${keycloak.url}/realms/quarkus/
quarkus.oidc.code-flow-token-introspection-expires-in.application-type=web-app
quarkus.oidc.code-flow-token-introspection-expires-in.authentication.user-info-required=false
quarkus.oidc.code-flow-token-introspection-expires-in.authorization-path=/
quarkus.oidc.code-flow-token-introspection-expires-in.token-path=/access_token_expires_in
quarkus.oidc.code-flow-token-introspection-expires-in.introspection-path=/introspect_expires_in
quarkus.oidc.code-flow-token-introspection-expires-in.token.refresh-expired=true
quarkus.oidc.code-flow-token-introspection-expires-in.authentication.verify-access-token=true
quarkus.oidc.code-flow-token-introspection-expires-in.client-id=quarkus-web-app
quarkus.oidc.code-flow-token-introspection-expires-in.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow

quarkus.oidc.token-cache.max-size=1

quarkus.oidc.bearer.auth-server-url=${keycloak.url}/realms/quarkus/
Expand Down
Loading

0 comments on commit e62c059

Please sign in to comment.