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 9eafd4a0336c1..28764fc7ad49c 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 @@ -132,7 +132,8 @@ public Uni apply(UserInfo userInfo, Throwable t) { @Override public Uni apply(TokenVerificationResult codeAccessTokenResult, Throwable t) { if (t != null) { - return Uni.createFrom().failure(new AuthenticationFailedException(t)); + return Uni.createFrom().failure(t instanceof AuthenticationFailedException ? t + : new AuthenticationFailedException(t)); } if (codeAccessTokenResult != null) { if (tokenAutoRefreshPrepared(codeAccessTokenResult, vertxContext, 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 da91862b37068..8c40298b1ff29 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 @@ -227,16 +227,10 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl } if (!Boolean.TRUE.equals(introspectionResult.getBoolean(OidcConstants.INTROSPECTION_TOKEN_ACTIVE))) { LOG.debugf("Token issued to client %s is not active", oidcConfig.clientId.get()); + verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP)); throw new AuthenticationFailedException(); } - if (isTokenExpired(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_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)); - } + verifyTokenExpiry(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_EXP)); try { verifyTokenAge(introspectionResult.getLong(OidcConstants.INTROSPECTION_TOKEN_IAT)); } catch (InvalidJwtException ex) { @@ -246,6 +240,17 @@ 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)); + } + } + }); } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java new file mode 100644 index 0000000000000..4bfd58e5de927 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.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.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/code-flow-token-introspection") +@Authenticated +public class CodeFlowTokenIntrospectionResource { + + @Inject + SecurityIdentity identity; + + @GET + public String access() { + return identity.getPrincipal().getName(); + } +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java index d7f3f2e626aa6..eac19b8572c56 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java @@ -23,6 +23,7 @@ public Uni augment(SecurityIdentity identity, AuthenticationRe || 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-token-introspection") || routingContext.normalizedPath().endsWith("code-flow-user-info-github-cached-in-idtoken"))) { QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); UserInfo userInfo = identity.getAttribute("userinfo"); diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 49d09dfb2e969..c6c0d806461dd 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -38,6 +38,9 @@ public String resolve(RoutingContext context) { if (path.endsWith("code-flow-user-info-github-cached-in-idtoken")) { return "code-flow-user-info-github-cached-in-idtoken"; } + if (path.endsWith("code-flow-token-introspection")) { + return "code-flow-token-introspection"; + } if (path.endsWith("bearer")) { return "bearer"; } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 99d39eec302a1..cd675540342b3 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -97,6 +97,18 @@ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.client-id=quarkus-web- quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +quarkus.oidc.code-flow-token-introspection.provider=github +quarkus.oidc.code-flow-token-introspection.auth-server-url=${keycloak.url}/realms/quarkus/ +quarkus.oidc.code-flow-token-introspection.authorization-path=/ +quarkus.oidc.code-flow-token-introspection.user-info-path=protocol/openid-connect/userinfo +quarkus.oidc.code-flow-token-introspection.introspection-path=protocol/openid-connect/token/introspect +quarkus.oidc.code-flow-token-introspection.token.refresh-expired=true +quarkus.oidc.code-flow-token-introspection.token.refresh-token-time-skew=298 +quarkus.oidc.code-flow-token-introspection.authentication.verify-access-token=true +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.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 7bbd13ba2e3cb..d3273facb0eb8 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 @@ -214,6 +214,30 @@ public void testCodeFlowUserInfo() throws Exception { doTestCodeFlowUserInfoCashedInIdToken(); } + @Test + public void testCodeFlowTokenIntrospection() throws Exception { + defineCodeFlowTokenIntrospectionStub(); + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-token-introspection"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + page = form.getInputByValue("login").click(); + + assertEquals("alice", page.getBody().asNormalizedText()); + + // refresh + Thread.sleep(3000); + page = webClient.getPage("http://localhost:8081/code-flow-token-introspection"); + assertEquals("admin", page.getBody().asNormalizedText()); + + webClient.getCookieManager().clearCookies(); + } + } + private void doTestCodeFlowUserInfo(String tenantId, long internalIdTokenLifetime) throws IOException { try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); @@ -297,6 +321,28 @@ private void defineCodeFlowAuthorizationOauth2TokenStub() { } + private void defineCodeFlowTokenIntrospectionStub() { + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/access_token") + .withHeader("X-Custom", matching("XTokenIntrospection")) + .withRequestBody(containing("authorization_code")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"alice\"," + + " \"refresh_token\": \"refresh5678\"" + + "}"))); + + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/access_token") + .withRequestBody(containing("refresh_token=refresh5678")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"admin\"" + + "}"))); + } + private void defineCodeFlowLogoutStub() { wireMockServer.stubFor( get(urlPathMatching("/auth/realms/quarkus/protocol/openid-connect/end-session")) diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index 5a2bd28f60ace..71c1ea6802d79 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -121,13 +121,9 @@ public Map start() { " ]\n" + "}"))); - server.stubFor( - get(urlEqualTo("/auth/realms/quarkus/protocol/openid-connect/userinfo")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody("{\n" + - " \"preferred_username\": \"alice\"" - + "}"))); + defineUserInfoStubForOpaqueToken("alice"); + defineUserInfoStubForOpaqueToken("admin"); + defineUserInfoStubForJwt(); // define the mock for the introspect endpoint @@ -221,7 +217,30 @@ public Map start() { return conf; } + private void defineUserInfoStubForOpaqueToken(String user) { + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus/protocol/openid-connect/userinfo")) + .withHeader("Authorization", matching("Bearer " + user)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"preferred_username\": \"" + user + "\"" + + "}"))); + } + + private void defineUserInfoStubForJwt() { + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus/protocol/openid-connect/userinfo")) + .withHeader("Authorization", containing("Bearer ey")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"preferred_username\": \"alice\"" + + "}"))); + } + private void defineValidIntrospectionMockTokenStubForUserWithRoles(String user, Set roles) { + long exp = now() + 300; server.stubFor(WireMock.post("/auth/realms/quarkus/protocol/openid-connect/token/introspect") .withRequestBody(matching("token=" + user + "&token_type_hint=access_token")) .willReturn(WireMock @@ -230,7 +249,12 @@ private void defineValidIntrospectionMockTokenStubForUserWithRoles(String user, .withBody( "{\"active\":true,\"scope\":\"" + roles.stream().collect(joining(" ")) + "\",\"username\":\"" + user - + "\",\"iat\":1,\"exp\":999999999999,\"expires_in\":999999999999,\"client_id\":\"my_client_id\"}"))); + + "\",\"iat\":1,\"exp\":" + exp + ",\"expires_in\":" + exp + + ",\"client_id\":\"my_client_id\"}"))); + } + + private static final long now() { + return System.currentTimeMillis(); } private void defineInvalidIntrospectionMockTokenStubForUserWithRoles(String user, Set roles) {