mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-18 18:37:54 -05:00
Resolve bug: Authorization_details added to token-response even when should not be
closes #44961 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
parent
d03bba598c
commit
17a2678438
4 changed files with 152 additions and 9 deletions
|
|
@ -110,6 +110,7 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
|||
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.dpop.DPoP;
|
||||
import org.keycloak.saml.processing.api.util.DeflateUtil;
|
||||
|
|
@ -794,8 +795,8 @@ public class OID4VCIssuerEndpoint {
|
|||
|
||||
if (credentialIdentifier != null) {
|
||||
|
||||
// Retrieve the associated credential offer state
|
||||
//
|
||||
// First check if the credential identifier exists
|
||||
// This allows proper error reporting for unknown identifiers
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
|
||||
if (offerState == null) {
|
||||
|
|
@ -805,8 +806,39 @@ public class OID4VCIssuerEndpoint {
|
|||
}
|
||||
|
||||
// Get the credential_configuration_id from AuthorizationDetails
|
||||
//
|
||||
// First check if offer state has authorization_details (for pre-authorized flows)
|
||||
OID4VCAuthorizationDetailsResponse authDetails = offerState.getAuthorizationDetails();
|
||||
|
||||
// Validate authorization_details: either in token or in offer state
|
||||
// For pre-authorized flows, offer state is the source of truth
|
||||
// For authorization code flows, token must contain authorization_details
|
||||
AccessToken accessToken = authResult.token();
|
||||
Object tokenAuthDetails = accessToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (tokenAuthDetails == null && authDetails == null) {
|
||||
var errorMessage = "Access token does not contain authorization_details and offer state has no authorization_details. " +
|
||||
"Only tokens issued with authorization_details can be used for credential requests with credential_identifier.";
|
||||
LOGGER.debugf(errorMessage);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Use authorization_details from offer state if available, otherwise from token
|
||||
// For pre-authorized flows, offer state is authoritative
|
||||
if (authDetails == null) {
|
||||
// Extract from token if offer state doesn't have it
|
||||
// This should be rare but handle it for robustness
|
||||
if (tokenAuthDetails instanceof List) {
|
||||
List<AuthorizationDetailsResponse> tokenAuthDetailsList = (List<AuthorizationDetailsResponse>) tokenAuthDetails;
|
||||
if (!tokenAuthDetailsList.isEmpty() && tokenAuthDetailsList.get(0) instanceof OID4VCAuthorizationDetailsResponse) {
|
||||
authDetails = (OID4VCAuthorizationDetailsResponse) tokenAuthDetailsList.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authDetails == null) {
|
||||
var errorMessage = "No authorization_details found in offer state or token";
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
String credConfigId = authDetails.getCredentialConfigurationId();
|
||||
if (credConfigId == null) {
|
||||
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
|
||||
|
|
|
|||
|
|
@ -351,6 +351,13 @@ public class TokenManager {
|
|||
|
||||
AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session,
|
||||
validation.userSession, validation.clientSessionCtx).offlineToken( TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())).accessToken(validation.newToken);
|
||||
|
||||
// Copy authorization_details from refresh token to new access token if present
|
||||
Object authorizationDetails = refreshToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (authorizationDetails != null) {
|
||||
validation.newToken.setOtherClaims(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
|
||||
}
|
||||
|
||||
if (clientConfig.isUseRefreshToken()) {
|
||||
//refresh token must have same scope as old refresh token (type, scope, expiration)
|
||||
responseBuilder.generateRefreshToken(refreshToken, clientSession);
|
||||
|
|
@ -361,6 +368,11 @@ public class TokenManager {
|
|||
responseBuilder.getRefreshToken().setAuthorization(validation.newToken.getAuthorization());
|
||||
}
|
||||
|
||||
// Ensure authorization_details are also in the new refresh token if present
|
||||
if (authorizationDetails != null && clientConfig.isUseRefreshToken() && responseBuilder.getRefreshToken() != null) {
|
||||
responseBuilder.getRefreshToken().setOtherClaims(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
|
||||
}
|
||||
|
||||
String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE);
|
||||
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
||||
responseBuilder.generateIDToken().generateAccessTokenHash();
|
||||
|
|
@ -1223,6 +1235,11 @@ public class TokenManager {
|
|||
.map(ClientModel::getClientId)
|
||||
.collect(Collectors.toSet()));
|
||||
}
|
||||
// Copy authorization_details from access token to refresh token if present
|
||||
Object authorizationDetails = accessToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (authorizationDetails != null) {
|
||||
refreshToken.getOtherClaims().put(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
|
||||
}
|
||||
Boolean bindOnlyRefreshToken = session.getAttributeOrDefault(DPoPUtil.DPOP_BINDING_ONLY_REFRESH_TOKEN_SESSION_ATTRIBUTE, false);
|
||||
if (bindOnlyRefreshToken) {
|
||||
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import org.keycloak.models.ClientScopeModel;
|
|||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.TokenManager;
|
||||
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
|
||||
|
|
@ -227,16 +228,12 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
|
|||
}
|
||||
|
||||
// If no authorization_details were processed from the request, try to process stored authorization_details
|
||||
// (e.g., from PAR flow where authorization_details was in authorization request but not in token request)
|
||||
if (authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty()) {
|
||||
try {
|
||||
authorizationDetailsResponse = processStoredAuthorizationDetails(userSession, clientSessionCtx);
|
||||
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
|
||||
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
|
||||
} else {
|
||||
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), clientSessionCtx);
|
||||
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
|
||||
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
|
||||
}
|
||||
}
|
||||
} catch (CorsErrorResponseException e) {
|
||||
// Re-throw CorsErrorResponseException as it's already properly formatted for HTTP response
|
||||
|
|
@ -244,10 +241,33 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
|
|||
}
|
||||
}
|
||||
|
||||
// Only generate authorization_details from credential offer if:
|
||||
// 1. No authorization_details were processed yet, AND
|
||||
// 2. There's a credential offer note in the client session (indicating this is a credential offer flow)
|
||||
// This prevents generating authorization_details for regular SSO logins that don't request OID4VCI
|
||||
if ((authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty())
|
||||
&& clientSession.getNote(OID4VCIssuerEndpoint.CREDENTIAL_CONFIGURATION_IDS_NOTE) != null) {
|
||||
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), clientSessionCtx);
|
||||
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
|
||||
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Call hook for post-processing authorization details (e.g., creating state objects)
|
||||
afterAuthorizationDetailsProcessed(userSession, clientSessionCtx, authorizationDetailsResponse);
|
||||
|
||||
return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, s -> {return new TokenResponseContext(formParams, parseResult, clientSessionCtx, s);});
|
||||
return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, s -> {
|
||||
// Add authorization_details to the access token and refresh token if they were processed
|
||||
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
|
||||
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
|
||||
s.getAccessToken().setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
|
||||
// Also add to refresh token if one is generated
|
||||
if (s.getRefreshToken() != null) {
|
||||
s.getRefreshToken().setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
|
||||
}
|
||||
}
|
||||
return new TokenResponseContext(formParams, parseResult, clientSessionCtx, s);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.SIGNING_KEY_ID;
|
|||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Base class for authorization code flow tests with authorization details and claims validation.
|
||||
|
|
@ -140,6 +142,78 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
|
|||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that verifies that a second regular SSO login should NOT return authorization_details
|
||||
* from a previous OID4VCI login.
|
||||
*/
|
||||
@Test
|
||||
public void testSecondSSOLoginDoesNotReturnAuthorizationDetails() throws Exception {
|
||||
Oid4vcTestContext ctx = prepareOid4vcTestContext();
|
||||
|
||||
// ===== STEP 1: First login with OID4VCI (should return authorization_details) =====
|
||||
AccessTokenResponse firstTokenResponse = authzCodeFlow(ctx);
|
||||
String credentialIdentifier = assertTokenResponse(firstTokenResponse);
|
||||
assertNotNull("Credential identifier should be present in first token", credentialIdentifier);
|
||||
|
||||
// ===== STEP 2: Second login - Regular SSO (should NOT return authorization_details) =====
|
||||
// Second login WITHOUT OID4VCI scope and WITHOUT authorization_details.
|
||||
oauth.client(client.getClientId());
|
||||
oauth.scope(OAuth2Constants.SCOPE_OPENID);
|
||||
oauth.openLoginForm();
|
||||
|
||||
String secondCode = oauth.parseLoginResponse().getCode();
|
||||
assertNotNull("Second authorization code should not be null", secondCode);
|
||||
|
||||
// Exchange second code for tokens WITHOUT authorization_details
|
||||
HttpPost postSecondToken = new HttpPost(ctx.openidConfig.getTokenEndpoint());
|
||||
List<NameValuePair> secondTokenParameters = new LinkedList<>();
|
||||
secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
|
||||
secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CODE, secondCode));
|
||||
secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
|
||||
secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
|
||||
secondTokenParameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, "password"));
|
||||
// NOTE: NO authorization_details parameter in this request
|
||||
|
||||
UrlEncodedFormEntity secondTokenFormEntity = new UrlEncodedFormEntity(secondTokenParameters, StandardCharsets.UTF_8);
|
||||
postSecondToken.setEntity(secondTokenFormEntity);
|
||||
|
||||
AccessTokenResponse secondTokenResponse;
|
||||
try (CloseableHttpResponse tokenHttpResponse = httpClient.execute(postSecondToken)) {
|
||||
assertEquals("Second token exchange should succeed", HttpStatus.SC_OK, tokenHttpResponse.getStatusLine().getStatusCode());
|
||||
String tokenResponseBody = IOUtils.toString(tokenHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
secondTokenResponse = JsonSerialization.readValue(tokenResponseBody, AccessTokenResponse.class);
|
||||
}
|
||||
|
||||
// ===== STEP 3: Verify second token does NOT have authorization_details =====
|
||||
Map<String, Object> secondTokenMap = JsonSerialization.readValue(JsonSerialization.writeValueAsString(secondTokenResponse), Map.class);
|
||||
Object secondAuthDetailsObj = secondTokenMap.get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
|
||||
assertNull("Second token (regular SSO) should NOT have authorization_details", secondAuthDetailsObj);
|
||||
|
||||
// ===== STEP 4: Verify second token cannot be used for credential requests =====
|
||||
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialRequest credentialRequest = new CredentialRequest();
|
||||
credentialRequest.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
|
||||
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + secondTokenResponse.getToken());
|
||||
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
|
||||
postCredential.setEntity(new StringEntity(JsonSerialization.writeValueAsString(credentialRequest), StandardCharsets.UTF_8));
|
||||
|
||||
// Credential request with second token should fail
|
||||
// The second token doesn't have the OID4VCI scope, so it should fail at scope check
|
||||
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
|
||||
assertEquals("Credential request with token without OID4VCI scope should fail",
|
||||
HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
|
||||
|
||||
String errorBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue("Error should indicate scope check failure. Actual error: " + errorBody,
|
||||
errorBody.contains("Scope check failure"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Test for the whole authorization_code flow with the credentialRequest using credential_configuration_id
|
||||
@Test
|
||||
public void testCompleteFlowWithClaimsValidationAuthorizationCode_credentialRequestWithConfigurationId() throws Exception {
|
||||
|
|
|
|||
Loading…
Reference in a new issue