diff --git a/docs/src/main/asciidoc/security-openid-connect-providers.adoc b/docs/src/main/asciidoc/security-openid-connect-providers.adoc index 0672a7b2896c59..c5aa15fa424ec9 100644 --- a/docs/src/main/asciidoc/security-openid-connect-providers.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-providers.adoc @@ -49,6 +49,8 @@ quarkus.oidc.client-id= quarkus.oidc.credentials.secret= ---- +TIP: You can also use GitHub provider with `quarkus.oidc.application-type=service`, just set `quarkus.oidc.verify-opaque-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]: diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OpaqueTokenVerificationWithUserInfoValidationTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OpaqueTokenVerificationWithUserInfoValidationTest.java new file mode 100644 index 00000000000000..1c93d40b68c64c --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/OpaqueTokenVerificationWithUserInfoValidationTest.java @@ -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-opaque-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 'verifyOpaqueAccessTokenWithUserInfo' is enabled"), + te.getMessage()); + }); + + @Test + public void test() { + Assertions.fail(); + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index b8a69106aea5d9..83995a7e19e910 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -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; @@ -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 verifyOpaqueAccessTokenWithUserInfo; + public Optional getIssuer() { return issuer; } 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 91a9ece1300678..aef7c4301e2559 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 @@ -148,7 +148,7 @@ private Uni 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() @@ -276,18 +276,29 @@ private Uni verifyCodeFlowAccessTokenUni(RoutingContext && (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, null); } else { return NULL_CODE_ACCESS_TOKEN_UNI; } } - private Uni verifyTokenUni(TenantConfigContext resolvedContext, String token) { + private Uni 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.verifyOpaqueAccessTokenWithUserInfo + && 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 with empty JWT content + return Uni.createFrom().item(new TokenVerificationResult(null, new TokenIntrospection("{}"))); + } + } LOG.debug("Starting the opaque token introspection"); return introspectTokenUni(resolvedContext, token); } else if (resolvedContext.provider.getMetadata().getJsonWebKeySetUri() == null diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index d0db470486f0d6..84560e5bef24ce 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -214,6 +214,23 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf } } + if (oidcConfig.token.verifyOpaqueAccessTokenWithUserInfo) { + if (!oidcConfig.authentication.isUserInfoRequired().orElse(false)) { + throw new ConfigurationException( + "UserInfo is not required but 'verifyOpaqueAccessTokenWithUserInfo' is enabled"); + } + if (!oidcConfig.isDiscoveryEnabled().orElse(true)) { + if (oidcConfig.userInfoPath.isEmpty()) { + throw new ConfigurationException( + "UserInfo path is missing but 'verifyOpaqueAccessTokenWithUserInfo' is enabled"); + } + if (oidcConfig.introspectionPath.isPresent()) { + throw new ConfigurationException( + "Introspection path is configured and 'verifyOpaqueAccessTokenWithUserInfo is enabled, these options are mutually exclusive"); + } + } + } + return createOidcProvider(oidcConfig, tlsConfig, vertx) .onItem().transform(p -> new TenantConfigContext(p, oidcConfig)); } 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 5271cc2bdccae8..84eae694557c0b 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 @@ -21,6 +21,7 @@ public Uni 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); 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 1473b7fb802a98..61ee4ff44d3268 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 @@ -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"; } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OpaqueGithubResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OpaqueGithubResource.java new file mode 100644 index 00000000000000..fd61ecb38e6e3a --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/OpaqueGithubResource.java @@ -0,0 +1,26 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +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; + + @GET + public String access() { + return identity.getPrincipal().getName() + ":" + userInfo.getString("preferred_username"); + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index ee4cc8c82d988b..48b1925f13c427 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -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-opaque-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=/ diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerOpaqueTokenAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerOpaqueTokenAuthorizationTest.java index 88fdac3650729e..a856624bf70e1f 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerOpaqueTokenAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerOpaqueTokenAuthorizationTest.java @@ -1,5 +1,9 @@ package io.quarkus.it.keycloak; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.hamcrest.Matchers.equalTo; import java.util.Arrays; @@ -7,8 +11,12 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import com.github.tomakehurst.wiremock.WireMockServer; + +import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.server.OidcWireMock; import io.quarkus.test.oidc.server.OidcWiremockTestResource; import io.restassured.RestAssured; @@ -16,6 +24,9 @@ @QuarkusTestResource(OidcWiremockTestResource.class) public class BearerOpaqueTokenAuthorizationTest { + @OidcWireMock + WireMockServer wireMockServer; + @Test public void testSecureAccessSuccessPreferredUsername() { for (String username : Arrays.asList("alice", "admin")) { @@ -64,4 +75,54 @@ public void testExpiredBearerToken() { .statusCode(401); } + @Test + public void testGitHubBearerTokenSuccess() { + final String validToken = OidcConstants.BEARER_SCHEME + " ghu_XirRniLaPuW53pDylNnAPOPBm14taM0C9HP4"; + wireMockServer.stubFor( + get(urlEqualTo("/auth/realms/quarkus/github/userinfo")) + .withHeader("Authorization", matching(validToken)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"preferred_username\": \"alice\"" + + "}"))); + + RestAssured.given() + .header("Authorization", validToken) + .get("/bearer-user-info-github-service") + .then() + .statusCode(200) + .body(Matchers.equalTo("alice:alice")); + } + + @Test + public void testGitHubBearerTokenUnauthorized() { + final String invalidToken = OidcConstants.BEARER_SCHEME + " Invalid"; + wireMockServer.stubFor( + get(urlEqualTo("/auth/realms/quarkus/github/userinfo")) + .withHeader("Authorization", matching(invalidToken)) + .willReturn(aResponse().withStatus(401))); + + RestAssured.given() + .header("Authorization", invalidToken) + .get("/bearer-user-info-github-service") + .then() + .statusCode(401); + } + + @Test + public void testGitHubBearerTokenNullUserInfo() { + final String validToken = OidcConstants.BEARER_SCHEME + " ghu_AAAAniLaPuW53pDylNnAPOPBm14ta7777777"; + wireMockServer.stubFor( + get(urlEqualTo("/auth/realms/quarkus/github/userinfo")) + .withHeader("Authorization", matching(validToken)) + .willReturn(aResponse().withStatus(200).withBody((String) null))); + + RestAssured.given() + .header("Authorization", validToken) + .get("/bearer-user-info-github-service") + .then() + .statusCode(401); + } + }