Add support for application/jwt media-type in token introspection (#29842)

Fixes #29841

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
Thomas Darimont 2024-06-03 19:06:21 +02:00 committed by GitHub
parent 536534dd25
commit 35a4a17aa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 85 additions and 1 deletions

View file

@ -2999,6 +2999,8 @@ includeInLightweight.label=Add to lightweight access token
includeInLightweight.tooltip=Should the claim be added to the lightweight access token? includeInLightweight.tooltip=Should the claim be added to the lightweight access token?
lightweightAccessToken=Always use lightweight access token lightweightAccessToken=Always use lightweight access token
lightweightAccessTokenHelp=If it is On, lightweight access tokens are always used. If it is Off, they are not used by default, but it is still possible to enable them with client policy executor lightweightAccessTokenHelp=If it is On, lightweight access tokens are always used. If it is Off, they are not used by default, but it is still possible to enable them with client policy executor
supportJwtClaimInIntrospectionResponse=Support JWT claim in Introspection Response
supportJwtClaimInIntrospectionResponseHelp=If it is On, introspection requests which use the header 'Accept: application/jwt' will also contain a claim named "jwt" with the claims of the introspection result encoded as JWT access token.
welcomeTabTitle=Welcome welcomeTabTitle=Welcome
welcomeTo=Welcome to {{realmDisplayInfo}} welcomeTo=Welcome to {{realmDisplayInfo}}
welcomeText=Keycloak provides user federation, strong authentication, user management, fine-grained authorization, and more. Add authentication to applications and secure services with minimum effort. No need to deal with storing users or authenticating users. welcomeText=Keycloak provides user federation, strong authentication, user management, fine-grained authorization, and more. Add authentication to applications and secure services with minimum effort. No need to deal with storing users or authenticating users.

View file

@ -192,6 +192,15 @@ export const AdvancedSettings = ({
labelIcon={t("lightweightAccessTokenHelp")} labelIcon={t("lightweightAccessTokenHelp")}
stringify stringify
/> />
<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(
"attributes.client.introspection.response.allow.jwt.claim.enabled",
)}
label={t("supportJwtClaimInIntrospectionResponse")}
labelIcon={t("supportJwtClaimInIntrospectionResponseHelp")}
stringify
/>
<FormGroup <FormGroup
label={t("acrToLoAMapping")} label={t("acrToLoAMapping")}
fieldId="acrToLoAMapping" fieldId="acrToLoAMapping"

View file

@ -172,6 +172,8 @@ public final class Constants {
public static final String USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED = "client.use.lightweight.access.token.enabled"; public static final String USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED = "client.use.lightweight.access.token.enabled";
public static final String SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED = "client.introspection.response.allow.jwt.claim.enabled";
public static final String TOTP_SECRET_KEY = "TOTP_SECRET_KEY"; public static final String TOTP_SECRET_KEY = "TOTP_SECRET_KEY";
// Sent to clients when authentication session expired, but user is already logged-in in current browser // Sent to clients when authentication session expired, but user is already logged-in in current browser

View file

@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc; package org.keycloak.protocol.oidc;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.ws.rs.core.HttpHeaders;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
@ -31,6 +32,7 @@ import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.ImpersonationSessionNote; import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -68,6 +70,8 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi
accessToken = verifyAccessToken(token, eventBuilder, false); accessToken = verifyAccessToken(token, eventBuilder, false);
UserSessionModel userSession = tokenManager.getValidUserSessionIfTokenIsValid(session, realm, accessToken, eventBuilder); UserSessionModel userSession = tokenManager.getValidUserSessionIfTokenIsValid(session, realm, accessToken, eventBuilder);
ClientModel client = session.getContext().getClient();
ObjectNode tokenMetadata; ObjectNode tokenMetadata;
if (userSession != null) { if (userSession != null) {
accessToken = transformAccessToken(accessToken, userSession); accessToken = transformAccessToken(accessToken, userSession);
@ -107,6 +111,13 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi
tokenMetadata.put("active", userSession != null); tokenMetadata.put("active", userSession != null);
// if consumer requests application/jwt return a JWT representation of the introspection contents in an jwt field
boolean isJwtRequest = org.keycloak.utils.MediaType.APPLICATION_JWT.equals(session.getContext().getRequestHeaders().getHeaderString(HttpHeaders.ACCEPT));
if (isJwtRequest && Boolean.parseBoolean(client.getAttribute(Constants.SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED))) {
// consumers can use this to convert an opaque token into an JWT based token
tokenMetadata.put("jwt", session.tokens().encode(accessToken));
}
return Response.ok(JsonSerialization.writeValueAsBytes(tokenMetadata)).type(MediaType.APPLICATION_JSON_TYPE).build(); return Response.ok(JsonSerialization.writeValueAsBytes(tokenMetadata)).type(MediaType.APPLICATION_JSON_TYPE).build();
} catch (Exception e) { } catch (Exception e) {
String clientId = accessToken != null ? accessToken.getIssuedFor() : "unknown"; String clientId = accessToken != null ? accessToken.getIssuedFor() : "unknown";

View file

@ -68,7 +68,7 @@ public class TokenIntrospectionEndpoint {
@POST @POST
@NoCache @NoCache
@Produces(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, org.keycloak.utils.MediaType.APPLICATION_JWT})
public Response introspect() { public Response introspect() {
event.event(EventType.INTROSPECT_TOKEN); event.event(EventType.INTROSPECT_TOKEN);

View file

@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode; import com.fasterxml.jackson.databind.node.TextNode;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.output.ByteArrayOutputStream; import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.http.NameValuePair; import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.entity.UrlEncodedFormEntity;
@ -36,6 +37,7 @@ import org.keycloak.admin.client.resource.ClientScopesResource;
import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.Algorithm;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation;
@ -63,6 +65,7 @@ import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.utils.MediaType;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.util.ArrayList; import java.util.ArrayList;
@ -73,6 +76,7 @@ import java.util.Optional;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -93,6 +97,7 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, "confidential-cli"); ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, "confidential-cli");
confApp.setSecret("secret1"); confApp.setSecret("secret1");
confApp.setServiceAccountsEnabled(Boolean.TRUE); confApp.setServiceAccountsEnabled(Boolean.TRUE);
confApp.setAttributes(Map.of(Constants.SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED,"true"));
ClientRepresentation pubApp = KeycloakModelUtils.createClient(testRealm, "public-cli"); ClientRepresentation pubApp = KeycloakModelUtils.createClient(testRealm, "public-cli");
pubApp.setPublicClient(Boolean.TRUE); pubApp.setPublicClient(Boolean.TRUE);
@ -357,6 +362,29 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
} }
} }
@Test
public void testIntrospectAccessTokenReturnedAsJwt() throws Exception {
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
EventRepresentation loginEvent = events.expectLogin().assertEvent();
AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
// request the introspection result to be returned as JWT
oauth.requestHeaders(Map.of(HttpHeaders.ACCEPT, MediaType.APPLICATION_JWT));
String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "secret1", accessTokenResponse.getAccessToken());
TokenMetadataRepresentation rep = JsonSerialization.readValue(tokenResponse, TokenMetadataRepresentation.class);
assertTrue(rep.isActive());
assertEquals("test-user@localhost", rep.getUserName());
assertEquals("test-app", rep.getClientId());
assertEquals(loginEvent.getUserId(), rep.getSubject());
assertNotNull(rep.getOtherClaims().get("jwt"));
// Assert expected scope
AbstractOIDCScopeTest.assertScopes("openid email profile", rep.getScope());
}
@Test @Test
public void testIntrospectAccessTokenES256() throws Exception { public void testIntrospectAccessTokenES256() throws Exception {
testIntrospectAccessToken(Algorithm.ES256); testIntrospectAccessToken(Algorithm.ES256);

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.oidc; package org.keycloak.testsuite.oidc;
import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.HttpHeaders;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
@ -26,6 +27,7 @@ import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.ProtocolMappersResource; import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.models.Constants;
import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -56,6 +58,7 @@ import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.ProtocolMapperUtil; import org.keycloak.testsuite.util.ProtocolMapperUtil;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@ -103,6 +106,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
public void clientConfiguration() { public void clientConfiguration() {
ClientManager.realm(adminClient.realm(REALM_NAME)).clientId(TEST_CLIENT).directAccessGrant(true).setServiceAccountsEnabled(true); ClientManager.realm(adminClient.realm(REALM_NAME)).clientId(TEST_CLIENT).directAccessGrant(true).setServiceAccountsEnabled(true);
ClientManager.realm(adminClient.realm(REALM_NAME)).clientId(RESOURCE_SERVER_CLIENT_ID).directAccessGrant(true); ClientManager.realm(adminClient.realm(REALM_NAME)).clientId(RESOURCE_SERVER_CLIENT_ID).directAccessGrant(true);
ClientManager.realm(adminClient.realm(REALM_NAME)).clientId(RESOURCE_SERVER_CLIENT_ID).updateAttribute(Constants.SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED, "true");
} }
@Override @Override
@ -194,6 +198,34 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
} }
} }
@Test
public void accessTokenTrueIntrospectionReturnedAsJwt() throws IOException {
ProtocolMappersResource protocolMappers = setProtocolMappers(true, true, true);
try {
oauth.nonce("123456");
oauth.scope("address");
oauth.clientId(TEST_CLIENT);
OAuthClient.AccessTokenResponse response = browserLogin(TEST_CLIENT_SECRET, TEST_USER_NAME, TEST_USER_PASSWORD).tokenResponse;
String accessToken = response.getAccessToken();
logger.debug("accessToken:" + accessToken);
assertAccessToken(oauth.verifyToken(accessToken), true, true, false);
oauth.clientId(RESOURCE_SERVER_CLIENT_ID);
// request JWT in introspection response
oauth.requestHeaders(Map.of(HttpHeaders.ACCEPT, MediaType.APPLICATION_JWT));
String tokenResponse = oauth.introspectAccessTokenWithClientCredential(RESOURCE_SERVER_CLIENT_ID, RESOURCE_SERVER_CLIENT_PASSWORD, accessToken);
logger.debug("tokenResponse:" + tokenResponse);
AccessToken introspectionResult = JsonSerialization.readValue(tokenResponse, AccessToken.class);
assertTokenIntrospectionResponse(introspectionResult, true, true, false);
Assert.assertNotNull(introspectionResult.getOtherClaims().get("jwt"));
} finally {
deleteProtocolMappers(protocolMappers);
}
}
@Test @Test
public void offlineTokenTest() throws IOException { public void offlineTokenTest() throws IOException {
ProtocolMappersResource protocolMappers = setProtocolMappers(false, true, true); ProtocolMappersResource protocolMappers = setProtocolMappers(false, true, true);