Skip to content

Commit

Permalink
Merge pull request #24649 from sberyozkin/userinfo_in_faked_id_token
Browse files Browse the repository at this point in the history
Allow to inline UserInfo in internal IdToken
  • Loading branch information
sberyozkin authored Mar 31, 2022
2 parents 180fabf + f1df7dd commit 6346b35
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ Note this user session can not be extended forever - the returning user with the

`io.quarkus.oidc.OidcSession` is a wrapper around the current `IdToken`. It can help to perform a <<local-logout, Local Logout>>, retrieve the current session's tenant identifier and check when the session will expire. More useful methods will be added to it over time.

[[token-state-manager]]
==== TokenStateManager

OIDC `CodeAuthenticationMechanism` is using the default `io.quarkus.oidc.TokenStateManager` interface implementation to keep the ID, access and refresh tokens returned in the authorization code or refresh grant responses in a session cookie. It makes Quarkus OIDC endpoints completely stateless.
Expand Down Expand Up @@ -697,6 +698,8 @@ Configuring the endpoint to request <<user-info,UserInfo>> is the only way `quar

Note that requiring <<user-info,UserInfo>> involves making a remote call on every request - therefore you may want to consider caching `UserInfo` data, see <<token-introspection-userinfo-cache,Token Introspection and UserInfo Cache> for more details.

Alternatively, you may want to request that `UserInfo` is embedded into the internal generated `IdToken`with the `quarkus.oidc.cache-user-info-in-idtoken=true` property - the advantage of this approach is that by default no cached `UserInfo` state will be kept with the endpoint - instead it will be stored in a session cookie. You may also want to consider encrypting `IdToken` in this case if `UserInfo` contains sensitive data, please see <<token-state-manager,Encrypt Tokens With TokenStateManager>> for more information.

Also, OAuth2 servers may not support a well-known configuration endpoint in which case the discovery has to be disabled and the authorization, token, and introspection and/or userinfo endpoint paths have to be configured manually.

Here is how you can integrate `quarkus-oidc` with `GitHub` after you have link:https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app[created a GitHub OAuth application]. Configure your Quarkus endpoint like this:
Expand All @@ -713,6 +716,9 @@ quarkus.oidc.credentials.secret=github_app_clientsecret
# Consider enabling UserInfo Cache
# quarkus.oidc.token-cache.max-size=1000
# quarkus.oidc.token-cache.time-to-live=5M
#
# Or having UserInfo cached inside IdToken itself
# quarkus.oidc.cache-user-info-in-idtoken=true
----

This is all what is needed for an endpoint like this one to return the currently authenticated user's profile with `GET http://localhost:8080/github/userinfo` and access it as the individual `UserInfo` properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ public class OidcTenantConfig extends OidcCommonConfig {
@ConfigItem(defaultValue = "true")
public boolean allowUserInfoCache = true;

/**
* Allow inlining UserInfo in IdToken instead of caching it in the token cache.
* This property is only checked when an internal IdToken is generated when Oauth2 providers do not return IdToken.
* Inlining UserInfo in the generated IdToken allows to store it in the session cookie and avoids introducing a cached
* state.
*/
@ConfigItem(defaultValue = "false")
public boolean cacheUserInfoInIdtoken = false;

@ConfigGroup
public static class Logout {

Expand Down Expand Up @@ -637,7 +646,7 @@ public enum ResponseMode {
/**
* Requires that ID token is available when the authorization code flow completes.
* Disable this property only when you need to use the authorization code flow with OAuth2 providers which do not return
* ID token.
* ID token - an internal IdToken will be generated in such cases.
*/
@ConfigItem(defaultValueDocumentation = "true")
public Optional<Boolean> idTokenRequired = Optional.empty();
Expand Down Expand Up @@ -1090,4 +1099,12 @@ public boolean isAllowUserInfoCache() {
public void setAllowUserInfoCache(boolean allowUserInfoCache) {
this.allowUserInfoCache = allowUserInfoCache;
}

public boolean isCacheUserInfoInIdtoken() {
return cacheUserInfoInIdtoken;
}

public void setCacheUserInfoInIdtoken(boolean cacheUserInfoInIdtoken) {
this.cacheUserInfoInIdtoken = cacheUserInfoInIdtoken;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import io.quarkus.oidc.OidcTenantConfig.Authentication;
import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.UserInfo;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.AuthenticationCompletionException;
Expand All @@ -39,6 +40,7 @@
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.jwt.build.JwtClaimsBuilder;
import io.smallrye.jwt.util.KeyUtils;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.subscription.UniEmitter;
Expand Down Expand Up @@ -467,14 +469,13 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
return Uni.createFrom().failure(new AuthenticationCompletionException(tOuter));
}

boolean internalIdToken = false;
boolean internalIdToken = !configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true);
if (tokens.getIdToken() == null) {
if (configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true)) {
if (!internalIdToken) {
return Uni.createFrom()
.failure(new AuthenticationCompletionException("ID Token is not available"));
} else {
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig));
internalIdToken = true;
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, null));
}
}

Expand All @@ -487,6 +488,11 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
.call(new Function<SecurityIdentity, Uni<?>>() {
@Override
public Uni<Void> apply(SecurityIdentity identity) {
if (internalIdToken && configContext.oidcConfig.allowUserInfoCache
&& configContext.oidcConfig.cacheUserInfoInIdtoken) {
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig,
identity.getAttribute(OidcUtils.USER_INFO_ATTRIBUTE)));
}
return processSuccessfulAuthentication(context, configContext,
tokens, identity);
}
Expand Down Expand Up @@ -561,8 +567,12 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta
return null;
}

private String generateInternalIdToken(OidcTenantConfig oidcConfig) {
return Jwt.claims().jws().header(INTERNAL_IDTOKEN_HEADER, true)
private String generateInternalIdToken(OidcTenantConfig oidcConfig, UserInfo userInfo) {
JwtClaimsBuilder builder = Jwt.claims();
if (userInfo != null) {
builder.claim(OidcUtils.USER_INFO_ATTRIBUTE, userInfo.getJsonObject());
}
return builder.jws().header(INTERNAL_IDTOKEN_HEADER, true)
.sign(KeyUtils.createSecretKeyFromSecret(OidcCommonUtils.clientSecret(oidcConfig.credentials)));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,14 @@ public Uni<SecurityIdentity> apply(UserInfo userInfo, Throwable t) {
private Uni<SecurityIdentity> createSecurityIdentityWithOidcServer(RoutingContext vertxContext,
TokenAuthenticationRequest request, TenantConfigContext resolvedContext, final UserInfo userInfo) {
Uni<TokenVerificationResult> tokenUni = null;
if ((request.getToken() instanceof IdTokenCredential) && ((IdTokenCredential) request.getToken()).isInternal()) {
tokenUni = verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken());
if (isInternalIdToken(request)) {
if (vertxContext.get(NEW_AUTHENTICATION) == Boolean.TRUE) {
// No need to verify it in this case as 'CodeAuthenticationMechanism' has just created it
tokenUni = Uni.createFrom()
.item(new TokenVerificationResult(OidcUtils.decodeJwtContent(request.getToken().getToken()), null));
} else {
tokenUni = verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken());
}
} else {
tokenUni = verifyTokenUni(resolvedContext, request.getToken().getToken());
}
Expand Down Expand Up @@ -218,6 +224,10 @@ public String getName() {
});
}

private static boolean isInternalIdToken(TokenAuthenticationRequest request) {
return (request.getToken() instanceof IdTokenCredential) && ((IdTokenCredential) request.getToken()).isInternal();
}

private static boolean tokenAutoRefreshPrepared(JsonObject tokenJson, RoutingContext vertxContext,
OidcTenantConfig oidcConfig) {
if (tokenJson != null
Expand Down Expand Up @@ -350,6 +360,14 @@ private static Uni<SecurityIdentity> validateTokenWithoutOidcServer(TokenAuthent

private Uni<UserInfo> getUserInfoUni(RoutingContext vertxContext, TokenAuthenticationRequest request,
TenantConfigContext resolvedContext) {
if (isInternalIdToken(request) && resolvedContext.oidcConfig.cacheUserInfoInIdtoken) {
JsonObject userInfo = OidcUtils.decodeJwtContent(request.getToken().getToken())
.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE);
if (userInfo != null) {
return Uni.createFrom().item(new UserInfo(userInfo.encode()));
}
}

String accessToken = vertxContext.get(OidcConstants.ACCESS_TOKEN_VALUE);
if (accessToken == null) {
accessToken = request.getToken().getToken();
Expand All @@ -369,7 +387,8 @@ private Uni<UserInfo> getUserInfoUni(RoutingContext vertxContext, TokenAuthentic

private Uni<UserInfo> newUserInfoUni(TenantConfigContext resolvedContext, String accessToken) {
Uni<UserInfo> userInfoUni = resolvedContext.provider.getUserInfo(accessToken);
if (tenantResolver.getUserInfoCache() == null || !resolvedContext.oidcConfig.allowUserInfoCache) {
if (tenantResolver.getUserInfoCache() == null || !resolvedContext.oidcConfig.allowUserInfoCache
|| resolvedContext.oidcConfig.cacheUserInfoInIdtoken) {
return userInfoUni;
} else {
return userInfoUni.call(new Function<UserInfo, Uni<?>>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public String accessGitHub() {
return access();
}

@GET
@Path("/code-flow-user-info-github-cached-in-idtoken")
public String accessGitHubCachedInIdToken() {
return access();
}

@GET
@Path("/code-flow-user-info-dynamic-github")
public String accessDynamicGitHub() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ 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("code-flow-user-info-dynamic-github"))) {
|| 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);
UserInfo userInfo = identity.getAttribute("userinfo");
builder.setPrincipal(new Principal() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public String resolve(RoutingContext context) {
if (path.endsWith("code-flow-user-info-github")) {
return "code-flow-user-info-github";
}
if (path.endsWith("code-flow-user-info-github-cached-in-idtoken")) {
return "code-flow-user-info-github-cached-in-idtoken";
}
if (path.endsWith("bearer")) {
return "bearer";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ quarkus.oidc.code-flow-user-info-github.user-info-path=protocol/openid-connect/u
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.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=/
quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.user-info-path=protocol/openid-connect/userinfo
quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.cache-user-info-in-idtoken=true
quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.client-id=quarkus-web-app
quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow


quarkus.oidc.token-cache.max-size=1

quarkus.oidc.bearer.auth-server-url=${keycloak.url}/realms/quarkus/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkus.oidc.runtime.OidcUtils;
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.vertx.core.json.JsonObject;

@QuarkusTest
@QuarkusTestResource(OidcWiremockTestResource.class)
Expand Down Expand Up @@ -82,6 +84,8 @@ public void testCodeFlowUserInfo() throws IOException {
doTestCodeFlowUserInfo("code-flow-user-info-only");
doTestCodeFlowUserInfo("code-flow-user-info-github");
doTestCodeFlowUserInfo("code-flow-user-info-dynamic-github");

doTestCodeFlowUserInfoCashedInIdToken();
}

private void doTestCodeFlowUserInfo(String tenantId) throws IOException {
Expand All @@ -97,7 +101,31 @@ private void doTestCodeFlowUserInfo(String tenantId) throws IOException {

assertEquals("alice:alice, cache size: 1", page.getBody().asText());

assertNotNull(getSessionCookie(webClient, tenantId));
Cookie sessionCookie = getSessionCookie(webClient, tenantId);
assertNotNull(sessionCookie);
JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]);
assertNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE));
webClient.getCookieManager().clearCookies();
}
}

private void doTestCodeFlowUserInfoCashedInIdToken() throws IOException {
try (final WebClient webClient = createWebClient()) {
webClient.getOptions().setRedirectEnabled(true);
HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken");

HtmlForm form = page.getFormByName("form");
form.getInputByName("username").type("alice");
form.getInputByName("password").type("alice");

page = form.getInputByValue("login").click();

assertEquals("alice:alice, cache size: 0", page.getBody().asText());

Cookie sessionCookie = getSessionCookie(webClient, "code-flow-user-info-github-cached-in-idtoken");
assertNotNull(sessionCookie);
JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]);
assertNotNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE));
webClient.getCookieManager().clearCookies();
}
}
Expand Down

0 comments on commit 6346b35

Please sign in to comment.