Skip to content

Commit

Permalink
Allow token verification with user info when no introspection endpoint
Browse files Browse the repository at this point in the history
closes: #20911
  • Loading branch information
michalvavrik committed Dec 7, 2022
1 parent 1dc1a77 commit 48e0ea8
Show file tree
Hide file tree
Showing 12 changed files with 206 additions and 4 deletions.
2 changes: 2 additions & 0 deletions docs/src/main/asciidoc/security-openid-connect-providers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ quarkus.oidc.client-id=<Client ID>
quarkus.oidc.credentials.secret=<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]:
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> getIssuer() {
return issuer;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ private Uni<SecurityIdentity> 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()
Expand Down Expand Up @@ -276,18 +276,29 @@ private Uni<TokenVerificationResult> 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<TokenVerificationResult> verifyTokenUni(TenantConfigContext resolvedContext, String token) {
private Uni<TokenVerificationResult> 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
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ private Uni<TenantConfigContext> 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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.it.keycloak;

import javax.annotation.security.PermitAll;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
Expand Down Expand Up @@ -48,4 +49,11 @@ public String accessGitHubCachedInIdToken() {
public String accessDynamicGitHub() {
return access();
}

@GET
@PermitAll
@Path("/clear-token-cache")
public void clearTokenCache() {
tokenCache.clearCache();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public Uni<SecurityIdentity> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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=/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
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;

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;

@QuarkusTest
@QuarkusTestResource(OidcWiremockTestResource.class)
public class BearerOpaqueTokenAuthorizationTest {

@OidcWireMock
WireMockServer wireMockServer;

@Test
public void testSecureAccessSuccessPreferredUsername() {
for (String username : Arrays.asList("alice", "admin")) {
Expand Down Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.net.URL;
import java.util.Set;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import com.gargoylesoftware.htmlunit.SilentCssErrorHandler;
Expand Down Expand Up @@ -44,6 +45,17 @@ public class CodeFlowAuthorizationTest {
@OidcWireMock
WireMockServer wireMockServer;

@BeforeAll
public static void clearCache() {
// clear token cache to make tests idempotent as we experienced failures
// on Windows when BearerTokenAuthorizationTest run before CodeFlowAuthorizationTest
RestAssured
.given()
.get("http://localhost:8081/clear-token-cache")
.then()
.statusCode(204);
}

@Test
public void testCodeFlow() throws IOException {
defineCodeFlowLogoutStub();
Expand Down

0 comments on commit 48e0ea8

Please sign in to comment.