mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
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:
+7
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-9
@@ -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())) {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user