Make sure persistent userSession not needed in preAuthorized code grant type

closes #44534

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2026-02-19 19:08:41 +01:00
committed by Marek Posolda
parent 337e94d5a4
commit 2b47045369
5 changed files with 102 additions and 22 deletions
@@ -815,23 +815,20 @@ public class OID4VCIssuerEndpoint {
// Get the credential_configuration_id from the offer state authorization details
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
if (authDetails == null) {
authDetails = getAuthorizationDetailFromToken(accessToken);
}
if (authDetails == null) {
var errorMessage = "No authorization_details found in offer state or token";
var errorMessage = "No authorization_details found in offer state";
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Validate that authorization_details from the token matches the offer state
// This ensures the correct access token is being used for the credential request
OID4VCAuthorizationDetail tokenAuthDetails = getAuthorizationDetailFromToken(accessToken);
if (tokenAuthDetails != null && !tokenAuthDetails.equals(authDetails)) {
if (tokenAuthDetails == null) {
var errorMessage = "No authorization_details found in token";
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
if (!tokenAuthDetails.equals(authDetails)) {
var errorMessage = "Authorization details in access token do not match the credential offer state. " +
"The access token may not be the one issued for this credential offer.";
LOGGER.debugf(errorMessage);
@@ -0,0 +1,54 @@
package org.keycloak.protocol.oid4vc.utils;
import java.util.List;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.utils.StringUtil;
import org.jboss.logging.Logger;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
public class OID4VCUtil {
private static final Logger logger = Logger.getLogger(OID4VCUtil.class);
private OID4VCUtil() {
}
/**
* Find OID4VCI client scope by credential config ID
*
* @param session Keycloak session
* @param realmModel realm
* @param credentialConfigId credential configuration ID
* @return Found OID4VCI client scope
*/
public static ClientScopeModel getClientScopeByCredentialConfigId(KeycloakSession session, RealmModel realmModel, String credentialConfigId) {
if (StringUtil.isBlank(credentialConfigId)) {
return null;
}
List<ClientScopeModel> clientScopes = session.clientScopes()
.getClientScopesByProtocol(realmModel, OID4VC_PROTOCOL)
.filter(it -> credentialConfigId.equals(it.getAttribute(CredentialScopeModel.CONFIGURATION_ID)))
.toList();
if (clientScopes.size() > 1) {
List<String> clientScopeNames = clientScopes.stream()
.map(ClientScopeModel::getName)
.toList();
logger.warnf("Multiple client scopes find with credential config ID '%s' in the realm '%s'. Please make sure that credential-config-id is unique across client scopes. Found client scopes: %s",
credentialConfigId, realmModel.getName(), clientScopeNames);
return null;
} else if (clientScopes.isEmpty()) {
logger.warnf("No client scopes find with credential config ID '%s' in the realm '%s'",
credentialConfigId, realmModel.getName());
return null;
} else {
return clientScopes.get(0);
}
}
}
@@ -29,6 +29,7 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
@@ -37,6 +38,7 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.utils.OID4VCUtil;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
import org.keycloak.representations.AccessToken;
@@ -48,6 +50,8 @@ import org.keycloak.utils.MediaType;
import org.jboss.logging.Logger;
import static org.keycloak.events.Details.REASON;
import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION;
import static org.keycloak.services.util.DefaultClientSessionContext.fromClientSessionAndScopeParameter;
public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
@@ -76,7 +80,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
if (code == null) {
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request
String errorMessage = "Missing parameter: " + PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
@@ -85,7 +89,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
var offerState = offerStorage.findOfferStateByCode(session, code);
if (offerState == null) {
var errorMessage = "No credential offer state for code: " + code;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
@@ -101,13 +105,13 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
var userModel = session.users().getUserById(realm, appUserId);
if (userModel == null) {
var errorMessage = "No user with ID: " + appUserId;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
if (!userModel.isEnabled()) {
var errorMessage = "User '" + userModel.getUsername() + "' disabled";
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
@@ -116,14 +120,14 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
ClientModel clientModel = realm.getClientByClientId(appClientId);
if (clientModel == null) {
var errorMessage = "No client model for: " + appClientId;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
UserSessionModel userSession = session.sessions().createUserSession(null, realm, userModel, userModel.getUsername(),
null, "pre-authorized-code", false, null,
null, UserSessionModel.SessionPersistenceState.PERSISTENT);
null, UserSessionModel.SessionPersistenceState.TRANSIENT);
AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, clientModel, userSession);
String credentialConfigurationIds = JsonSerialization.valueAsString(credOffer.getCredentialConfigurationIds());
@@ -146,7 +150,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
// Validate empty authorization_details - if parameter is provided but empty, reject it
if (authorizationDetailsParam != null && (authorizationDetailsParam.trim().isEmpty() || "[]".equals(authorizationDetailsParam.trim()))) {
var errorMessage = "Invalid authorization_details: parameter cannot be empty";
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
event.detail(REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
@@ -165,13 +169,13 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
if (authorizationDetailsResponses.size() != 1) {
boolean emptyAuthDetails = authorizationDetailsResponses.isEmpty();
String errorMessage = (emptyAuthDetails ? "No" : "Multiple") + " authorization details";
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
event.detail(REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
// Add authorization_details to the OfferState and otherClaims
var authDetails = (OID4VCAuthorizationDetail) authorizationDetailsResponses.get(0);
OID4VCAuthorizationDetail authDetails = (OID4VCAuthorizationDetail) authorizationDetailsResponses.get(0);
offerState.setAuthorizationDetails(authDetails);
offerStorage.replaceOfferState(session, offerState);
@@ -182,6 +186,20 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
userSession,
sessionContext);
// Add the scope referenced by the credential from specified credential offer to the token scopes
String credentialConfigId = authDetails.getCredentialConfigurationId();
ClientScopeModel clientScope = OID4VCUtil.getClientScopeByCredentialConfigId(session, realm, credentialConfigId);
if (clientScope == null) {
String errorMessage = "Client scope was not found for credential configuration ID: " + credentialConfigId;
event.detail(Details.CREDENTIAL_TYPE, credentialConfigId);
event.detail(REASON, errorMessage)
.error(UNKNOWN_CREDENTIAL_CONFIGURATION.getValue());
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
accessToken.setScope(clientScope.getName());
accessToken.setSessionId(null);
accessToken.setAuthorizationDetails(authorizationDetailsResponses);
// Set audience to credential endpoint for pre-authorized tokens
@@ -200,6 +218,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
try {
tokenResponse = responseBuilder.build();
tokenResponse.setAuthorizationDetails(authorizationDetailsResponses);
tokenResponse.setScope(clientScope.getName());
} catch (RuntimeException re) {
String errorMessage = "Cannot get encryption KEK";
if (errorMessage.equals(re.getMessage())) {
@@ -178,9 +178,9 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String error = credentialResponse.getError();
String errorDescription = credentialResponse.getErrorDescription();
assertEquals("Credential request should fail with unknown credential configuration when OID4VCI scope is missing",
assertEquals("Credential request should fail with unknown credential configuration when OID4VCI scope is missing or authorization_details missing from the token",
"UNKNOWN_CREDENTIAL_CONFIGURATION", error);
assertEquals("Scope check failure", errorDescription);
assertEquals("No authorization_details found in token", errorDescription);
}
// Test for the whole authorization_code flow with the credentialRequest using credential_configuration_id
@@ -38,6 +38,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
@@ -66,6 +67,7 @@ import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
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;
import static org.junit.Assert.fail;
@@ -307,6 +309,14 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
.authorizationDetails(authDetails)
.send();
// Assert no session referenced in the access token and token response.
AccessToken parsedToken = oauth.verifyToken(tokenResponse.getAccessToken(), AccessToken.class);
assertNull(parsedToken.getSessionId());
assertNull(tokenResponse.getSessionState());
// Assert scope in the token matches with the credential requested
assertEquals(parsedToken.getScope(), getCredentialClientScope().getName());
assertEquals(tokenResponse.getScope(), getCredentialClientScope().getName());
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusCode());
List<OID4VCAuthorizationDetail> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
@@ -895,7 +905,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest()
.credentialIdentifier(credentialIdentifier)
.bearerToken(token)
.bearerToken(tokenResponse.getAccessToken())
.send();
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());