Skip to content

Commit e62c059

Browse files
authored
Merge pull request quarkusio#45337 from sberyozkin/oidc_refresh_opaque_access_token
Use access token expires_in to refresh code flow tokens
2 parents 0b4c147 + d13deae commit e62c059

File tree

6 files changed

+185
-40
lines changed

6 files changed

+185
-40
lines changed

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.jose4j.lang.UnresolvableKeyException;
1818

1919
import io.quarkus.oidc.AccessTokenCredential;
20+
import io.quarkus.oidc.AuthorizationCodeTokens;
2021
import io.quarkus.oidc.IdTokenCredential;
2122
import io.quarkus.oidc.OIDCException;
2223
import io.quarkus.oidc.OidcTenantConfig;
@@ -199,7 +200,7 @@ private Uni<TokenVerificationResult> verifyPrimaryTokenUni(Map<String, Object> r
199200
} else {
200201
final boolean idToken = isIdToken(request);
201202
Uni<TokenVerificationResult> result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken,
202-
userInfo);
203+
false, userInfo);
203204
if (!idToken && resolvedContext.oidcConfig().token().binding().certificate()) {
204205
return result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {
205206

@@ -269,7 +270,7 @@ public Uni<SecurityIdentity> apply(TokenVerificationResult codeAccessTokenResult
269270
}
270271
if (codeAccessTokenResult != null) {
271272
if (tokenAutoRefreshPrepared(codeAccessTokenResult, requestData,
272-
resolvedContext.oidcConfig())) {
273+
resolvedContext.oidcConfig(), true)) {
273274
return Uni.createFrom().failure(new TokenAutoRefreshException(null));
274275
}
275276
requestData.put(OidcUtils.CODE_ACCESS_TOKEN_RESULT, codeAccessTokenResult);
@@ -346,7 +347,7 @@ private Uni<SecurityIdentity> createSecurityIdentityWithOidcServer(TokenVerifica
346347
// If the primary token is a bearer access token then there's no point of checking if
347348
// it should be refreshed as RT is only available for the code flow tokens
348349
if (isIdToken(request)
349-
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig())) {
350+
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig(), false)) {
350351
return Uni.createFrom().failure(new TokenAutoRefreshException(securityIdentity));
351352
} else {
352353
return Uni.createFrom().item(securityIdentity);
@@ -412,7 +413,7 @@ public String getName() {
412413
// If the primary token is a bearer access token then there's no point of checking if
413414
// it should be refreshed as RT is only available for the code flow tokens
414415
if (isIdToken(request)
415-
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig())) {
416+
&& tokenAutoRefreshPrepared(result, requestData, resolvedContext.oidcConfig(), false)) {
416417
return Uni.createFrom().failure(new TokenAutoRefreshException(identity));
417418
}
418419
return Uni.createFrom().item(identity);
@@ -429,7 +430,7 @@ private static boolean isIdToken(TokenAuthenticationRequest request) {
429430
}
430431

431432
private static boolean tokenAutoRefreshPrepared(TokenVerificationResult result, Map<String, Object> requestData,
432-
OidcTenantConfig oidcConfig) {
433+
OidcTenantConfig oidcConfig, boolean codeFlowAccessToken) {
433434
if (result != null && oidcConfig.token().refreshExpired()
434435
&& oidcConfig.token().refreshTokenTimeSkew().isPresent()
435436
&& requestData.get(REFRESH_TOKEN_GRANT_RESPONSE) != Boolean.TRUE
@@ -440,9 +441,18 @@ private static boolean tokenAutoRefreshPrepared(TokenVerificationResult result,
440441
} else if (result.introspectionResult != null) {
441442
expiry = result.introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP);
442443
}
444+
final long now = System.currentTimeMillis() / 1000;
445+
if (expiry == null && codeFlowAccessToken) {
446+
// JWT or introspection response `exp` property has a number of seconds since epoch.
447+
// The code flow access token `expires_in` property is relative to the current time.
448+
Long expiresIn = ((AuthorizationCodeTokens) requestData.get(AuthorizationCodeTokens.class.getName()))
449+
.getAccessTokenExpiresIn();
450+
if (expiresIn != null) {
451+
expiry = now + expiresIn;
452+
}
453+
}
443454
if (expiry != null) {
444455
final long refreshTokenTimeSkew = oidcConfig.token().refreshTokenTimeSkew().get().getSeconds();
445-
final long now = System.currentTimeMillis() / 1000;
446456
return now + refreshTokenTimeSkew > expiry;
447457
}
448458
}
@@ -478,15 +488,21 @@ private Uni<TokenVerificationResult> verifyCodeFlowAccessTokenUni(Map<String, Ob
478488
&& (resolvedContext.oidcConfig().authentication().verifyAccessToken()
479489
|| resolvedContext.oidcConfig().roles().source().orElse(null) == Source.accesstoken)) {
480490
final String codeAccessToken = (String) requestData.get(OidcConstants.ACCESS_TOKEN_VALUE);
481-
return verifyTokenUni(requestData, resolvedContext, new AccessTokenCredential(codeAccessToken), false, userInfo);
491+
return verifyTokenUni(requestData, resolvedContext, new AccessTokenCredential(codeAccessToken), false, true,
492+
userInfo);
482493
} else {
483494
return NULL_CODE_ACCESS_TOKEN_UNI;
484495
}
485496
}
486497

487498
private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestData, TenantConfigContext resolvedContext,
488-
TokenCredential tokenCred, boolean enforceAudienceVerification, UserInfo userInfo) {
499+
TokenCredential tokenCred, boolean enforceAudienceVerification, boolean codeFlowAccessToken, UserInfo userInfo) {
489500
final String token = tokenCred.getToken();
501+
Long expiresIn = null;
502+
if (codeFlowAccessToken) {
503+
expiresIn = ((AuthorizationCodeTokens) requestData.get(AuthorizationCodeTokens.class.getName()))
504+
.getAccessTokenExpiresIn();
505+
}
490506
if (OidcUtils.isOpaqueToken(token)) {
491507
if (!resolvedContext.oidcConfig().token().allowOpaqueTokenIntrospection()) {
492508
LOG.debug("Token is opaque but the opaque token introspection is not allowed");
@@ -504,12 +520,12 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
504520
}
505521
}
506522
LOG.debug("Starting the opaque token introspection");
507-
return introspectTokenUni(resolvedContext, token, false);
523+
return introspectTokenUni(resolvedContext, token, expiresIn, false);
508524
} else if (resolvedContext.provider().getMetadata().getJsonWebKeySetUri() == null
509525
|| resolvedContext.oidcConfig().token().requireJwtIntrospectionOnly()) {
510526
// Verify JWT token with the remote introspection
511527
LOG.debug("Starting the JWT token introspection");
512-
return introspectTokenUni(resolvedContext, token, false);
528+
return introspectTokenUni(resolvedContext, token, expiresIn, false);
513529
} else if (resolvedContext.oidcConfig().jwks().resolveEarly()) {
514530
// Verify JWT token with the local JWK keys with a possible remote introspection fallback
515531
final String nonce = tokenCred instanceof IdTokenCredential ? (String) requestData.get(OidcConstants.NONCE) : null;
@@ -522,7 +538,7 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
522538
if (t.getCause() instanceof UnresolvableKeyException) {
523539
LOG.debug("No matching JWK key is found, refreshing and repeating the token verification");
524540
return refreshJwksAndVerifyTokenUni(resolvedContext, token, enforceAudienceVerification,
525-
resolvedContext.oidcConfig().token().subjectRequired(), nonce);
541+
resolvedContext.oidcConfig().token().subjectRequired(), nonce, expiresIn);
526542
} else {
527543
LOG.debugf("Token verification has failed: %s", t.getMessage());
528544
return Uni.createFrom().failure(t);
@@ -531,7 +547,7 @@ private Uni<TokenVerificationResult> verifyTokenUni(Map<String, Object> requestD
531547
} else {
532548
final String nonce = (String) requestData.get(OidcConstants.NONCE);
533549
return resolveJwksAndVerifyTokenUni(resolvedContext, tokenCred, enforceAudienceVerification,
534-
resolvedContext.oidcConfig().token().subjectRequired(), nonce);
550+
resolvedContext.oidcConfig().token().subjectRequired(), nonce, expiresIn);
535551
}
536552
}
537553

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

547563
private Uni<TokenVerificationResult> refreshJwksAndVerifyTokenUni(TenantConfigContext resolvedContext, String token,
548-
boolean enforceAudienceVerification, boolean subjectRequired, String nonce) {
564+
boolean enforceAudienceVerification, boolean subjectRequired, String nonce, Long expiresIn) {
549565
return resolvedContext.provider()
550566
.refreshJwksAndVerifyJwtToken(token, enforceAudienceVerification, subjectRequired, nonce)
551567
.onFailure(f -> fallbackToIntrospectionIfNoMatchingKey(f, resolvedContext))
552-
.recoverWithUni(f -> introspectTokenUni(resolvedContext, token, true));
568+
.recoverWithUni(f -> introspectTokenUni(resolvedContext, token, expiresIn, true));
553569
}
554570

555571
private Uni<TokenVerificationResult> resolveJwksAndVerifyTokenUni(TenantConfigContext resolvedContext,
556572
TokenCredential tokenCred,
557-
boolean enforceAudienceVerification, boolean subjectRequired, String nonce) {
573+
boolean enforceAudienceVerification, boolean subjectRequired, String nonce, Long expiresIn) {
558574
return resolvedContext.provider()
559575
.getKeyResolverAndVerifyJwtToken(tokenCred, enforceAudienceVerification, subjectRequired, nonce,
560576
(tokenCred instanceof IdTokenCredential))
561577
.onFailure(f -> fallbackToIntrospectionIfNoMatchingKey(f, resolvedContext))
562-
.recoverWithUni(f -> introspectTokenUni(resolvedContext, tokenCred.getToken(), true));
578+
.recoverWithUni(f -> introspectTokenUni(resolvedContext, tokenCred.getToken(), expiresIn, true));
563579
}
564580

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

579595
private Uni<TokenVerificationResult> introspectTokenUni(TenantConfigContext resolvedContext, final String token,
580-
boolean fallbackFromJwkMatch) {
596+
Long expiresIn, boolean fallbackFromJwkMatch) {
581597
TokenIntrospectionCache tokenIntrospectionCache = tenantResolver.getTokenIntrospectionCache();
582598
Uni<TokenIntrospection> tokenIntrospectionUni = tokenIntrospectionCache == null ? null
583599
: tokenIntrospectionCache
584600
.getIntrospection(token, resolvedContext.oidcConfig(), getIntrospectionRequestContext);
585601
if (tokenIntrospectionUni == null) {
586-
tokenIntrospectionUni = newTokenIntrospectionUni(resolvedContext, token, fallbackFromJwkMatch);
602+
tokenIntrospectionUni = newTokenIntrospectionUni(resolvedContext, token, expiresIn, fallbackFromJwkMatch);
587603
} else {
588604
tokenIntrospectionUni = tokenIntrospectionUni.onItem().ifNull()
589605
.switchTo(new Supplier<Uni<? extends TokenIntrospection>>() {
590606
@Override
591607
public Uni<TokenIntrospection> get() {
592-
return newTokenIntrospectionUni(resolvedContext, token, fallbackFromJwkMatch);
608+
return newTokenIntrospectionUni(resolvedContext, token, expiresIn, fallbackFromJwkMatch);
593609
}
594610
});
595611
}
596612
return tokenIntrospectionUni.onItem().transform(t -> new TokenVerificationResult(null, t));
597613
}
598614

599615
private Uni<TokenIntrospection> newTokenIntrospectionUni(TenantConfigContext resolvedContext, String token,
600-
boolean fallbackFromJwkMatch) {
601-
Uni<TokenIntrospection> tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, fallbackFromJwkMatch);
616+
Long expiresIn, boolean fallbackFromJwkMatch) {
617+
Uni<TokenIntrospection> tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, expiresIn,
618+
fallbackFromJwkMatch);
602619
if (tenantResolver.getTokenIntrospectionCache() == null
603620
|| !resolvedContext.oidcConfig().allowTokenIntrospectionCache()) {
604621
return tokenIntrospectionUni;

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ public Uni<? extends TokenVerificationResult> apply(VerificationKeyResolver reso
348348
});
349349
}
350350

351-
public Uni<TokenIntrospection> introspectToken(String token, boolean fallbackFromJwkMatch) {
351+
public Uni<TokenIntrospection> introspectToken(String token, Long expiresIn, boolean fallbackFromJwkMatch) {
352352
if (client.getMetadata().getIntrospectionUri() == null) {
353353
String errorMessage = String.format("Token issued to client %s "
354354
+ (fallbackFromJwkMatch ? "does not have a matching verification key and it " : "")
@@ -366,12 +366,17 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl
366366
if (t != null) {
367367
throw new AuthenticationFailedException(t);
368368
}
369+
Long introspectionExpiresIn = introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP);
370+
if (introspectionExpiresIn == null && expiresIn != null) {
371+
// expires_in is relative to the current time
372+
introspectionExpiresIn = now() + expiresIn;
373+
}
369374
if (!introspectionResult.isActive()) {
370-
verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP));
375+
verifyTokenExpiry(introspectionExpiresIn);
371376
throw new AuthenticationFailedException(
372377
String.format("Token issued to client %s is not active", oidcConfig.clientId().get()));
373378
}
374-
verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP));
379+
verifyTokenExpiry(introspectionExpiresIn);
375380
try {
376381
verifyTokenAge(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_IAT));
377382
} catch (InvalidJwtException ex) {
@@ -402,20 +407,20 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl
402407
return introspectionResult;
403408
}
404409

405-
private void verifyTokenExpiry(Long exp) {
406-
if (isTokenExpired(exp)) {
407-
String error = String.format("Token issued to client %s has expired",
408-
oidcConfig.clientId().get());
409-
LOG.debugf(error);
410-
throw new AuthenticationFailedException(
411-
new InvalidJwtException(error,
412-
List.of(new ErrorCodeValidator.Error(ErrorCodes.EXPIRED, error)), null));
413-
}
414-
}
415-
416410
});
417411
}
418412

413+
private void verifyTokenExpiry(Long exp) {
414+
if (isTokenExpired(exp)) {
415+
String error = String.format("Token issued to client %s has expired",
416+
oidcConfig.clientId().get());
417+
LOG.debugf(error);
418+
throw new AuthenticationFailedException(
419+
new InvalidJwtException(error,
420+
List.of(new ErrorCodeValidator.Error(ErrorCodes.EXPIRED, error)), null));
421+
}
422+
}
423+
419424
private boolean isTokenExpired(Long exp) {
420425
return exp != null && now() / 1000 > exp + getLifespanGrace();
421426
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -819,9 +819,10 @@ enum ResponseMode {
819819

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

827828
/**
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.quarkus.it.keycloak;
2+
3+
import jakarta.inject.Inject;
4+
import jakarta.ws.rs.GET;
5+
import jakarta.ws.rs.Path;
6+
7+
import io.quarkus.oidc.TokenIntrospection;
8+
import io.quarkus.security.Authenticated;
9+
10+
@Path("/code-flow-token-introspection-expires-in")
11+
@Authenticated
12+
public class CodeFlowTokenIntrospectionExpiresInResource {
13+
14+
@Inject
15+
TokenIntrospection tokenIntrospection;
16+
17+
@GET
18+
public String access() {
19+
return tokenIntrospection.getUsername();
20+
}
21+
}

integration-tests/oidc-wiremock/src/main/resources/application.properties

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ quarkus.oidc.code-flow-token-introspection.client-id=quarkus-web-app
159159
quarkus.oidc.code-flow-token-introspection.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow
160160
quarkus.oidc.code-flow-token-introspection.code-grant.headers.X-Custom=XTokenIntrospection
161161

162+
quarkus.oidc.code-flow-token-introspection-expires-in.auth-server-url=${keycloak.url}/realms/quarkus/
163+
quarkus.oidc.code-flow-token-introspection-expires-in.application-type=web-app
164+
quarkus.oidc.code-flow-token-introspection-expires-in.authentication.user-info-required=false
165+
quarkus.oidc.code-flow-token-introspection-expires-in.authorization-path=/
166+
quarkus.oidc.code-flow-token-introspection-expires-in.token-path=/access_token_expires_in
167+
quarkus.oidc.code-flow-token-introspection-expires-in.introspection-path=/introspect_expires_in
168+
quarkus.oidc.code-flow-token-introspection-expires-in.token.refresh-expired=true
169+
quarkus.oidc.code-flow-token-introspection-expires-in.authentication.verify-access-token=true
170+
quarkus.oidc.code-flow-token-introspection-expires-in.client-id=quarkus-web-app
171+
quarkus.oidc.code-flow-token-introspection-expires-in.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow
172+
162173
quarkus.oidc.token-cache.max-size=1
163174

164175
quarkus.oidc.bearer.auth-server-url=${keycloak.url}/realms/quarkus/

0 commit comments

Comments
 (0)