From 2b47045369806cc7d0c7dd5e6b2230b3ed7650ac Mon Sep 17 00:00:00 2001 From: mposolda Date: Thu, 19 Feb 2026 19:08:41 +0100 Subject: [PATCH] Make sure persistent userSession not needed in preAuthorized code grant type closes #44534 Signed-off-by: mposolda --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 17 +++--- .../protocol/oid4vc/utils/OID4VCUtil.java | 54 +++++++++++++++++++ .../grants/PreAuthorizedCodeGrantType.java | 37 +++++++++---- .../OID4VCAuthorizationCodeFlowTestBase.java | 4 +- ...ID4VCAuthorizationDetailsFlowTestBase.java | 12 ++++- 5 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/utils/OID4VCUtil.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index eab19141fc5..7b7759dd149 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/utils/OID4VCUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/OID4VCUtil.java new file mode 100644 index 00000000000..63e42ef4622 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/utils/OID4VCUtil.java @@ -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 clientScopes = session.clientScopes() + .getClientScopesByProtocol(realmModel, OID4VC_PROTOCOL) + .filter(it -> credentialConfigId.equals(it.getAttribute(CredentialScopeModel.CONFIGURATION_ID))) + .toList(); + if (clientScopes.size() > 1) { + List 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); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index cabe42dc1d6..40173230f80 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -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())) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java index 1898802aeb5..37b8ea88ed1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java @@ -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 diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java index 20ed25a9b0b..8a753362de4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java @@ -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 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());