From d35d326f986122773c36a8a2f342e2602bc2321e Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Thu, 2 Jan 2025 16:32:33 +0000 Subject: [PATCH] Use access token expires_in to refresh code flow tokens --- .../oidc/runtime/OidcIdentityProvider.java | 56 ++++++++----- .../io/quarkus/oidc/runtime/OidcProvider.java | 33 ++++---- .../oidc/runtime/OidcTenantConfig.java | 5 +- ...owTokenIntrospectionExpiresInResource.java | 21 +++++ .../src/main/resources/application.properties | 11 +++ .../keycloak/CodeFlowAuthorizationTest.java | 82 ++++++++++++++++++- 6 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionExpiresInResource.java 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 a850d77373dfc8..83168e876df781 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 @@ -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; @@ -199,7 +200,7 @@ private Uni verifyPrimaryTokenUni(Map r } else { final boolean idToken = isIdToken(request); Uni result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken, - userInfo); + false, userInfo); if (!idToken && resolvedContext.oidcConfig().token().binding().certificate()) { return result.onItem().transform(new Function() { @@ -269,7 +270,7 @@ public Uni 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); @@ -346,7 +347,7 @@ private Uni 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); @@ -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); @@ -429,7 +430,7 @@ private static boolean isIdToken(TokenAuthenticationRequest request) { } private static boolean tokenAutoRefreshPrepared(TokenVerificationResult result, Map 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 @@ -440,9 +441,15 @@ 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. + expiry = now + ((AuthorizationCodeTokens) requestData.get(AuthorizationCodeTokens.class.getName())) + .getAccessTokenExpiresIn(); + } if (expiry != null) { final long refreshTokenTimeSkew = oidcConfig.token().refreshTokenTimeSkew().get().getSeconds(); - final long now = System.currentTimeMillis() / 1000; return now + refreshTokenTimeSkew > expiry; } } @@ -478,15 +485,21 @@ private Uni verifyCodeFlowAccessTokenUni(Map verifyTokenUni(Map 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"); @@ -504,12 +517,12 @@ private Uni verifyTokenUni(Map 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; @@ -522,7 +535,7 @@ private Uni verifyTokenUni(Map 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); @@ -531,7 +544,7 @@ private Uni verifyTokenUni(Map 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); } } @@ -545,21 +558,21 @@ private Uni verifySelfSignedTokenUni(TenantConfigContex } private Uni 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 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) { @@ -577,19 +590,19 @@ private static boolean fallbackToIntrospectionIfNoMatchingKey(Throwable f, Tenan } private Uni introspectTokenUni(TenantConfigContext resolvedContext, final String token, - boolean fallbackFromJwkMatch) { + Long expiresIn, boolean fallbackFromJwkMatch) { TokenIntrospectionCache tokenIntrospectionCache = tenantResolver.getTokenIntrospectionCache(); Uni 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>() { @Override public Uni get() { - return newTokenIntrospectionUni(resolvedContext, token, fallbackFromJwkMatch); + return newTokenIntrospectionUni(resolvedContext, token, expiresIn, fallbackFromJwkMatch); } }); } @@ -597,8 +610,9 @@ public Uni get() { } private Uni newTokenIntrospectionUni(TenantConfigContext resolvedContext, String token, - boolean fallbackFromJwkMatch) { - Uni tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, fallbackFromJwkMatch); + Long expiresIn, boolean fallbackFromJwkMatch) { + Uni tokenIntrospectionUni = resolvedContext.provider().introspectToken(token, expiresIn, + fallbackFromJwkMatch); if (tenantResolver.getTokenIntrospectionCache() == null || !resolvedContext.oidcConfig().allowTokenIntrospectionCache()) { return tokenIntrospectionUni; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index c50748cf8e9360..6c1a49dc3a656a 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -348,7 +348,7 @@ public Uni apply(VerificationKeyResolver reso }); } - public Uni introspectToken(String token, boolean fallbackFromJwkMatch) { + public Uni 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 " : "") @@ -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) { + // 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) { @@ -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(); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java index bdbeb99f785fdf..c3b589941c6d3d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java @@ -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 internalIdTokenLifespan(); /** diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionExpiresInResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionExpiresInResource.java new file mode 100644 index 00000000000000..b2a482b322ca7f --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionExpiresInResource.java @@ -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(); + } +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index ebf73424c37313..ca7acfbcd0ff98 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -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/ 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 ed6fce21ac10ab..c7079e58a69491 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 @@ -28,6 +28,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.PrivateKey; +import java.time.Instant; import java.util.Date; import java.util.Set; import java.util.StringTokenizer; @@ -349,7 +350,7 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { Cookie stateCookie = getStateCookie(webClient, "code-flow-user-info-github-cached-in-idtoken"); Date stateCookieDate = stateCookie.getExpires(); - final long nowInSecs = System.currentTimeMillis() / 1000; + final long nowInSecs = nowInSecs(); final long sessionCookieLifespan = stateCookieDate.toInstant().getEpochSecond() - nowInSecs; // 5 mins is default assertTrue(sessionCookieLifespan >= 299 && sessionCookieLifespan <= 304); @@ -489,7 +490,8 @@ public void run() throws Throwable { } @Test - public void testCodeFlowTokenIntrospection() throws Exception { + public void testCodeFlowTokenIntrospectionActiveRefresh() throws Exception { + // This stub does not return an access token expires_in property defineCodeFlowTokenIntrospectionStub(); try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); @@ -503,7 +505,10 @@ public void testCodeFlowTokenIntrospection() throws Exception { assertEquals("alice:alice", textPage.getContent()); - // refresh + // Refresh + // The internal ID token lifespan is 5 mins + // Configured refresh token skew is 298 secs = 5 mins - 2 secs + // Therefore, after waiting for 3 secs, an active refresh is happening Thread.sleep(3000); textPage = webClient.getPage("http://localhost:8081/code-flow-token-introspection"); assertEquals("admin:admin", textPage.getContent()); @@ -514,6 +519,36 @@ public void testCodeFlowTokenIntrospection() throws Exception { clearCache(); } + @Test + public void testCodeFlowTokenIntrospectionExpiresInRefresh() throws Exception { + // This stub does return an access token expires_in property + defineCodeFlowTokenIntrospectionExpiresInStub(); + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-token-introspection-expires-in"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + TextPage textPage = form.getInputByValue("login").click(); + + assertEquals("alice", textPage.getContent()); + + // Refresh the expired token + // The internal ID token lifespan is 5 mins, refresh token skew is not configured, + // code flow access token expires in 3 seconds from now. Therefore, after waiting for 5 secs + // the refresh is triggered because it is allowed in the config and token expires_in property is returned. + Thread.sleep(5000); + textPage = webClient.getPage("http://localhost:8081/code-flow-token-introspection-expires-in"); + assertEquals("bob", textPage.getContent()); + + webClient.getCookieManager().clearCookies(); + } + + clearCache(); + } + private void doTestCodeFlowUserInfo(String tenantId, long internalIdTokenLifetime, boolean cacheUserInfoInIdToken, boolean tenantConfigResolver, int inMemoryCacheSize, int userInfoRequests) throws Exception { try (final WebClient webClient = createWebClient()) { @@ -741,6 +776,47 @@ private void defineCodeFlowTokenIntrospectionStub() { + "}"))); } + private static long nowInSecs() { + return Instant.now().getEpochSecond(); + } + + private void defineCodeFlowTokenIntrospectionExpiresInStub() { + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/access_token_expires_in") + .withRequestBody(containing("authorization_code")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"id_token\": \"" + + OidcWiremockTestResource.generateJwtToken("alice", Set.of(), "sub", "ID", + Set.of("quarkus-web-app")) + + "\"," + + " \"access_token\": \"alice\"," + + " \"expires_in\":" + 3 + "," + + " \"refresh_token\": \"refresh_expires_in\"" + + "}"))); + + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/introspect_expires_in") + .withRequestBody(containing("token=alice")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"username\": \"alice\"," + + " \"exp\":" + (nowInSecs() + 3) + "," + + " \"active\": true" + + "}"))); + + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/access_token_expires_in") + .withRequestBody(containing("refresh_token=refresh_expires_in")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"bob\"" + + "}"))); + } + private void defineCodeFlowLogoutStub() { wireMockServer.stubFor( get(urlPathMatching("/auth/realms/quarkus/protocol/openid-connect/end-session"))