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:
parent
536534dd25
commit
35a4a17aa5
7 changed files with 85 additions and 1 deletions
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue