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?
|
||||
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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
@ -31,6 +32,7 @@ import org.keycloak.events.EventType;
|
|||
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;
|
||||
|
@ -68,6 +70,8 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi
|
|||
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);
|
||||
|
@ -107,6 +111,13 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi
|
|||
|
||||
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";
|
||||
|
|
|
@ -68,7 +68,7 @@ public class TokenIntrospectionEndpoint {
|
|||
|
||||
@POST
|
||||
@NoCache
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Produces({MediaType.APPLICATION_JSON, org.keycloak.utils.MediaType.APPLICATION_JWT})
|
||||
public Response introspect() {
|
||||
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.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;
|
||||
|
@ -36,6 +37,7 @@ import org.keycloak.admin.client.resource.ClientScopesResource;
|
|||
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;
|
||||
|
@ -63,6 +65,7 @@ import org.keycloak.util.JsonSerialization;
|
|||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.keycloak.utils.MediaType;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.ArrayList;
|
||||
|
@ -73,6 +76,7 @@ import java.util.Optional;
|
|||
|
||||
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;
|
||||
|
||||
|
@ -93,6 +97,7 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
|
|||
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);
|
||||
|
@ -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
|
||||
public void testIntrospectAccessTokenES256() throws Exception {
|
||||
testIntrospectAccessToken(Algorithm.ES256);
|
||||
|
|
|
@ -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;
|
||||
|
@ -26,6 +27,7 @@ import org.keycloak.admin.client.resource.ClientScopeResource;
|
|||
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;
|
||||
|
@ -56,6 +58,7 @@ import org.keycloak.testsuite.util.KeycloakModelUtils;
|
|||
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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
public void offlineTokenTest() throws IOException {
|
||||
ProtocolMappersResource protocolMappers = setProtocolMappers(false, true, true);
|
||||
|
|
Loading…
Reference in a new issue