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:
forkimenjeckayang 2026-01-07 10:53:13 +00:00 committed by Marek Posolda
parent d03bba598c
commit 17a2678438
4 changed files with 152 additions and 9 deletions

View file

@ -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";

View file

@ -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);

View file

@ -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

View file

@ -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 {