Skip to content

Commit

Permalink
Add support for application/jwt media-type in token introspection (ke…
Browse files Browse the repository at this point in the history
…ycloak#29842)

Fixes keycloak#29841

Signed-off-by: Thomas Darimont <[email protected]>
  • Loading branch information
thomasdarimont authored Jun 3, 2024
1 parent 536534d commit 35a4a17
Show file tree
Hide file tree
Showing 7 changed files with 85 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -2999,6 +2999,8 @@ includeInLightweight.label=Add to lightweight access token
includeInLightweight.tooltip=Should the claim be added to the 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
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
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.
Expand Down
9 changes: 9 additions & 0 deletions js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ export const AdvancedSettings = ({
labelIcon={t("lightweightAccessTokenHelp")}
stringify
/>

<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(
"attributes.client.introspection.response.allow.jwt.claim.enabled",
)}
label={t("supportJwtClaimInIntrospectionResponse")}
labelIcon={t("supportJwtClaimInIntrospectionResponseHelp")}
stringify
/>
<FormGroup
label={t("acrToLoAMapping")}
fieldId="acrToLoAMapping"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED = "client.introspection.response.allow.jwt.claim.enabled";

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc;

import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.ws.rs.core.HttpHeaders;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
Expand All @@ -31,6 +32,7 @@
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
Expand Down Expand Up @@ -68,6 +70,8 @@ public Response introspect(String token, EventBuilder eventBuilder) {
accessToken = verifyAccessToken(token, eventBuilder, false);
UserSessionModel userSession = tokenManager.getValidUserSessionIfTokenIsValid(session, realm, accessToken, eventBuilder);

ClientModel client = session.getContext().getClient();

ObjectNode tokenMetadata;
if (userSession != null) {
accessToken = transformAccessToken(accessToken, userSession);
Expand Down Expand Up @@ -107,6 +111,13 @@ public Response introspect(String token, EventBuilder eventBuilder) {

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();
} catch (Exception e) {
String clientId = accessToken != null ? accessToken.getIssuedFor() : "unknown";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public TokenIntrospectionEndpoint(KeycloakSession session, EventBuilder event) {

@POST
@NoCache
@Produces(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, org.keycloak.utils.MediaType.APPLICATION_JWT})
public Response introspect() {
event.event(EventType.INTROSPECT_TOKEN);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode;

import jakarta.ws.rs.core.HttpHeaders;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
Expand All @@ -36,6 +37,7 @@
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.Errors;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
Expand Down Expand Up @@ -63,6 +65,7 @@
import org.keycloak.util.TokenUtil;

import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.utils.MediaType;

import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
Expand All @@ -73,6 +76,7 @@

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

Expand All @@ -93,6 +97,7 @@ public void configureTestRealm(RealmRepresentation testRealm) {
ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, "confidential-cli");
confApp.setSecret("secret1");
confApp.setServiceAccountsEnabled(Boolean.TRUE);
confApp.setAttributes(Map.of(Constants.SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED,"true"));

ClientRepresentation pubApp = KeycloakModelUtils.createClient(testRealm, "public-cli");
pubApp.setPublicClient(Boolean.TRUE);
Expand Down Expand Up @@ -357,6 +362,29 @@ public void testIntrospectAccessTokenWithoutScope() throws Exception {
}
}

@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
public void testIntrospectAccessTokenES256() throws Exception {
testIntrospectAccessToken(Algorithm.ES256);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.keycloak.testsuite.oidc;

import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.HttpHeaders;
import org.jboss.logging.Logger;
import org.junit.Assert;
import org.junit.Before;
Expand All @@ -26,6 +27,7 @@
import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile;
import org.keycloak.models.Constants;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
Expand Down Expand Up @@ -56,6 +58,7 @@
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.ProtocolMapperUtil;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;

import java.io.IOException;
import java.util.ArrayList;
Expand Down Expand Up @@ -103,6 +106,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
public void clientConfiguration() {
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).updateAttribute(Constants.SUPPORT_JWT_CLAIM_IN_INTROSPECTION_RESPONSE_ENABLED, "true");
}

@Override
Expand Down Expand Up @@ -194,6 +198,34 @@ public void accessTokenTrueIntrospectionTrueTest() throws IOException {
}
}

@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
public void offlineTokenTest() throws IOException {
ProtocolMappersResource protocolMappers = setProtocolMappers(false, true, true);
Expand Down

0 comments on commit 35a4a17

Please sign in to comment.