[OID4VCI] CredentialEndpoint can be invoked with incorrect access token (#45816)

closes #44670
closes #44580


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang 2026-02-02 19:29:40 +01:00 committed by GitHub
parent 9462f0f00b
commit 3adcca44a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1030 additions and 258 deletions

View file

@ -38,6 +38,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.provider.Provider;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.cors.Cors;
/**
@ -70,6 +71,22 @@ public interface OAuth2GrantType extends Provider {
*/
Response process(Context context);
/**
* Check if the token issued from this grant type is allowed for the current request.
* This allows grant types to restrict token usage to specific endpoints or contexts.
* The default implementation returns {@code true}, meaning tokens are allowed at all endpoints.
* Grant types that need to restrict token usage (e.g., pre-authorized code tokens that should
* only be accepted at the credential endpoint) should override this method to implement
* specific endpoint restrictions.
*
* @param session the Keycloak session
* @param token the access token
* @return true if the token is allowed for the current request, false otherwise
*/
default boolean isTokenAllowed(KeycloakSession session, AccessToken token) {
return true;
}
public static class Context {
protected KeycloakSession session;
protected RealmModel realm;

View file

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.issuance;
import java.util.List;
import java.util.Objects;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
@ -53,4 +54,18 @@ public class OID4VCAuthorizationDetailResponse extends OID4VCAuthorizationDetail
", claims=" + getClaims() +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
OID4VCAuthorizationDetailResponse that = (OID4VCAuthorizationDetailResponse) o;
return Objects.equals(credentialIdentifiers, that.credentialIdentifiers);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), credentialIdentifiers);
}
}

View file

@ -767,20 +767,19 @@ public class OID4VCIssuerEndpoint {
// checkClientEnabled call after authentication
checkClientEnabled();
// Both credential_configuration_id and credential_identifier are optional.
// If the credential_configuration_id is present, credential_identifier can't be present.
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
// This implementation will privilege the presence of credential_identifier.
// Per OID4VCI specification, credential_identifier is required when authorization_details are present.
// Since both pre-authorized and authorization code flows always include credential_identifiers
// in authorization_details, we always require credential_identifier in the credential request.
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
String credentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
// Check if at least one of both is available.
if (credentialIdentifier == null && credentialConfigurationId == null) {
String errorMessage = "Missing both credential_configuration_id and credential_identifier. At least one must be specified.";
// credential_identifier is required
if (credentialIdentifier == null) {
String errorMessage = "Missing credential_identifier in credential request. " +
"Per OID4VCI specification, credential_identifier must be used when authorization_details are present.";
LOGGER.debugf(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
CredentialScopeModel requestedCredential;
@ -789,97 +788,100 @@ public class OID4VCIssuerEndpoint {
// When the CredentialRequest contains a credential identifier the caller must have gone through the
// CredentialOffer process or otherwise have set up a valid CredentialOfferState
if (credentialIdentifier != null) {
AccessToken accessToken = authResult.token();
// 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) {
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
}
// Get the credential_configuration_id from AuthorizationDetails
// First check if offer state has authorization_details (for pre-authorized flows)
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(authResult.token());
}
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";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Find the credential configuration in the Issuer's metadata
//
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
if (credConfig == null) {
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Verify the user login session
//
if (!userModel.getId().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Verify the login client
//
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Find the configured scope in the login client
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
if (clientScope == null) {
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
requestedCredential = new CredentialScopeModel(clientScope);
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
} else if (credentialConfigurationId != null) {
// Use credential_configuration_id for direct lookup
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
});
eventBuilder.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
authDetails = getAuthorizationDetailFromToken(authResult.token()); // Get authorization_details always from token for this case
} else {
// Neither provided - this should not happen due to earlier validation
String errorMessage = "Missing both credential_configuration_id and credential_identifier";
// 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) {
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
}
// 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";
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
OID4VCAuthorizationDetailResponse tokenAuthDetails = getAuthorizationDetailFromToken(accessToken);
if (tokenAuthDetails != null && !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);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_TOKEN);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Validate that the credential_identifier in the request matches one in the authorization_details
List<String> credentialIdentifiers = authDetails.getCredentialIdentifiers();
if (credentialIdentifiers == null || !credentialIdentifiers.contains(credentialIdentifier)) {
var errorMessage = "Credential identifier '" + credentialIdentifier + "' not found in authorization_details. " +
"The credential_identifier must match one from the authorization_details in the token.";
LOGGER.debugf(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
}
String credConfigId = authDetails.getCredentialConfigurationId();
if (credConfigId == null) {
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Find the credential configuration in the Issuer's metadata
//
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
if (credConfig == null) {
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Verify the user login session
//
if (!userModel.getId().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Verify the login client
//
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Find the configured scope in the login client
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
if (clientScope == null) {
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
requestedCredential = new CredentialScopeModel(clientScope);
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
checkScope(requestedCredential);
SupportedCredentialConfiguration supportedCredential =
@ -1496,18 +1498,21 @@ public class OID4VCIssuerEndpoint {
return new CredentialScopeModel(clientScopeModel);
}
private Response getErrorResponse(ErrorType errorType) {
return getErrorResponse(errorType, null);
}
private Response getErrorResponse(ErrorType errorType, String errorDescription) {
private Response.ResponseBuilder getErrorResponseBuilder(ErrorType errorType, String errorDescription) {
var errorResponse = new ErrorResponse();
errorResponse.setError(errorType).setErrorDescription(errorDescription);
return Response
.status(Response.Status.BAD_REQUEST)
.entity(errorResponse)
.type(MediaType.APPLICATION_JSON)
.build();
.type(MediaType.APPLICATION_JSON);
}
private Response getErrorResponse(ErrorType errorType) {
return getErrorResponseBuilder(errorType, null).build();
}
private Response getErrorResponse(ErrorType errorType, String errorDescription) {
return getErrorResponseBuilder(errorType, errorDescription).build();
}
// builds the unsigned credential by applying all protocol mappers.

View file

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.model;
import java.util.List;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -68,4 +69,17 @@ public class ClaimsDescription {
public boolean isMandatory() {
return mandatory != null ? mandatory : false;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ClaimsDescription that = (ClaimsDescription) o;
return Objects.equals(path, that.path) && Objects.equals(mandatory, that.mandatory);
}
@Override
public int hashCode() {
return Objects.hash(path, mandatory);
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.model;
import java.util.List;
import java.util.Objects;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
@ -64,4 +65,19 @@ public class OID4VCAuthorizationDetail extends AuthorizationDetailsJSONRepresent
", claims=" + claims +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
OID4VCAuthorizationDetail that = (OID4VCAuthorizationDetail) o;
return Objects.equals(credentialConfigurationId, that.credentialConfigurationId)
&& Objects.equals(claims, that.claims);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), credentialConfigurationId, claims);
}
}

View file

@ -1232,7 +1232,6 @@ public class TokenManager {
.map(ClientModel::getClientId)
.collect(Collectors.toSet()));
}
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

@ -31,9 +31,11 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
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.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
@ -138,12 +140,24 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
event.client(clientModel)
.user(userModel);
// Check if authorization_details parameter was explicitly provided
String authorizationDetailsParam = formParams.getFirst(OAuth2Constants.AUTHORIZATION_DETAILS);
// 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);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
// Process authorization_details using provider discovery
List<AuthorizationDetailsJSONRepresentation> authorizationDetailsResponses = processAuthorizationDetails(userSession, sessionContext);
LOGGER.debugf("Initial authorization_details processing result: %s", authorizationDetailsResponses);
// If no authorization_details were processed from the request, try to generate them from credential offer
if (authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) {
// (only if authorization_details parameter was not explicitly provided)
if ((authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) && authorizationDetailsParam == null) {
authorizationDetailsResponses = handleMissingAuthorizationDetails(userSession, sessionContext);
}
@ -170,6 +184,10 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
accessToken.setAuthorizationDetails(authorizationDetailsResponses);
// Set audience to credential endpoint for pre-authorized tokens
String credentialEndpoint = OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
accessToken.audience(credentialEndpoint);
AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(
clientSession.getRealm(),
clientSession.getClient(),
@ -199,4 +217,26 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
public EventType getEventType() {
return EventType.CODE_TO_TOKEN;
}
/**
* Restrict pre-authorized tokens to the VC credential endpoint.
*/
@Override
public boolean isTokenAllowed(KeycloakSession session, AccessToken token) {
// Check if the request path ends with the credential endpoint path
boolean isCredentialEndpoint = Optional.ofNullable(session.getContext().getUri())
.map(uri -> uri.getPath())
.map(path -> path.endsWith("/" + OID4VCIssuerEndpoint.CREDENTIAL_PATH))
.orElse(false);
if (!isCredentialEndpoint) {
return false;
}
// Check if token has exactly one audience and it matches the credential endpoint
// Being strict about audience prevents potential security issues with multi-audience tokens
String expectedAudience = OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
String[] audiences = token.getAudience();
return audiences != null && audiences.length == 1 && expectedAudience.equals(audiences[0]);
}
}

View file

@ -33,6 +33,8 @@ import org.keycloak.models.RealmModel;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.util.TokenUtil;
import org.jboss.logging.Logger;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -129,6 +131,8 @@ public class AppAuthManager extends AuthenticationManager {
}
public static class BearerTokenAuthenticator {
private static final Logger logger = Logger.getLogger(BearerTokenAuthenticator.class);
private KeycloakSession session;
private RealmModel realm;
private UriInfo uriInfo;
@ -193,7 +197,10 @@ public class AppAuthManager extends AuthenticationManager {
// audience can be null
return verifyIdentityToken(session, realm, uriInfo, connection, true, true, audience, false, tokenString, headers,
verifier -> DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString)));
verifier -> {
DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString));
verifier.withChecks(GrantTypeEndpointRestrictionValidator.check(session));
});
}
}

View file

@ -0,0 +1,125 @@
package org.keycloak.services.managers;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuthErrorException;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.DefaultTokenContextEncoderProvider;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.protocol.oidc.grants.OAuth2GrantType;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.ErrorResponseException;
import org.jboss.logging.Logger;
/**
* Validates that tokens are only used on endpoints allowed by their grant type.
* This ensures Pre-Authorized Code tokens are restricted to the credential endpoint,
* and other grant types only access their intended endpoints.
*/
public class GrantTypeEndpointRestrictionValidator implements TokenVerifier.Predicate<AccessToken> {
private static final Logger logger = Logger.getLogger(GrantTypeEndpointRestrictionValidator.class);
private final KeycloakSession session;
private GrantTypeEndpointRestrictionValidator(KeycloakSession session) {
this.session = session;
}
/**
* Creates a TokenVerifier.Predicate for grant type endpoint restriction validation.
* Can be used with TokenVerifier.withChecks() for inline verification.
*
* @param session The Keycloak session
* @return A predicate that validates grant type restrictions
*/
public static TokenVerifier.Predicate<AccessToken> check(KeycloakSession session) {
return new GrantTypeEndpointRestrictionValidator(session);
}
@Override
public boolean test(AccessToken token) throws VerificationException {
validate(token);
return true;
}
/**
* Validates that the token is allowed for the current endpoint based on its grant type.
*
* @param token The access token to validate
* @throws VerificationException if token validation fails
* @throws ErrorResponseException if server configuration is broken
*/
private void validate(AccessToken token) throws VerificationException {
try {
// Get the grant type from the token
String grantType = recoverGrantType(token);
// If no specific grant type, allow the token for backward compatibility
if (grantType == null) {
return;
}
// Get the grant type provider to verify endpoint restrictions
OAuth2GrantType grantTypeProvider = session.getProvider(OAuth2GrantType.class, grantType);
if (grantTypeProvider == null) {
// This is a server configuration error - grant type provider not registered
logger.errorf("Grant type restriction provider not available for: %s - server misconfiguration", grantType);
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR,
"Internal error: grant type restriction provider not available", Response.Status.INTERNAL_SERVER_ERROR);
}
if (!grantTypeProvider.isTokenAllowed(session, token)) {
throw new VerificationException("Token is not allowed for this endpoint. Grant type: " + grantType);
}
} catch (ErrorResponseException | VerificationException e) {
throw e;
} catch (Exception e) {
logger.errorf(e, "Error checking grant type restriction");
throw new VerificationException("Error verifying grant type restrictions: " + e.getMessage(), e);
}
}
/**
* Recover the grant type from the token context.
* Handles legacy tokens and various token formats gracefully.
*
* @param token The access token to extract grant type from
* @return The grant type, or null if no specific grant type is assigned
* @throws VerificationException if token context is invalid
* @throws ErrorResponseException if server configuration is broken
*/
private String recoverGrantType(AccessToken token) throws VerificationException {
TokenContextEncoderProvider encoder = session.getProvider(TokenContextEncoderProvider.class);
if (encoder == null) {
logger.error("Token context encoder provider not available - server misconfiguration");
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR,
"Internal error: token context encoder not available", Response.Status.INTERNAL_SERVER_ERROR);
}
AccessTokenContext tokenContext;
try {
tokenContext = encoder.getTokenContextFromTokenId(token.getId());
} catch (IllegalArgumentException e) {
// Token ID format is invalid or unknown - treat as legacy token
logger.debugf("Cannot decode token context from token ID, treating as legacy token: %s", e.getMessage());
return null;
}
if (tokenContext == null) {
throw new VerificationException("Invalid token context");
}
String grantType = tokenContext.getGrantType();
if (grantType == null || grantType.isEmpty() || DefaultTokenContextEncoderProvider.UNKNOWN.equals(grantType)) {
// Standard Keycloak token without specific grant-type context.
// We allow these to maintain backward compatibility with standard OIDC flows.
return null;
}
return grantType;
}
}

View file

@ -23,6 +23,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.jose.jws.JWSInput;
@ -49,6 +50,7 @@ import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.directory.api.util.Strings;
import org.apache.http.HttpStatus;
import org.junit.Test;
@ -128,7 +130,6 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialWithoutOffer() throws Exception {
var ctx = newTestContext(false, null, appUsername);
OID4VCAuthorizationDetailResponse authDetail = new OID4VCAuthorizationDetailResponse();
@ -140,7 +141,31 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// https://github.com/keycloak/keycloak/issues/44320
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, convertToAuthzDetail(authDetail));
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail);
// Extract credential_identifier from the access token's authorization_details
JsonWebToken tokenDecoded = new JWSInput(accessToken).readJsonContent(JsonWebToken.class);
Object tokenAuthDetails = tokenDecoded.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
assertNotNull("authorization_details not found in access token", tokenAuthDetails);
// When authorization_details are sent in token request, they are returned in token response with credential_identifiers
// The credential request MUST use credential_identifier (not credential_configuration_id)
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = JsonSerialization.readValue(
JsonSerialization.writeValueAsString(tokenAuthDetails),
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
}
);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
List<String> credentialIdentifiers = authDetailResponse.getCredentialIdentifiers();
assertNotNull("credential_identifiers should be present", credentialIdentifiers);
assertFalse("credential_identifiers should not be empty", credentialIdentifiers.isEmpty());
String credentialIdentifier = credentialIdentifiers.get(0);
var credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
CredentialResponse credResponse = sendCredentialRequest(ctx, accessToken, credentialRequest);
verifyCredentialResponse(ctx, credResponse);
}
@ -283,11 +308,12 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
String scope = credConfig.getScope();
String accessToken = getBearerToken(clientId, userId, scope);
AccessTokenResponse tokenResponse = getBearerTokenResponse(clientId, userId, scope);
String accessToken = tokenResponse.getAccessToken();
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, credOffer);
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, tokenResponse, credOffer);
verifyCredentialResponse(ctx, credResponse);
}
} finally {
@ -299,13 +325,59 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// Private ---------------------------------------------------------------------------------------------------------
private String getBearerToken(String clientId, String username, String scope) {
private AccessTokenResponse getBearerTokenResponse(String clientId, String username, String scope) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
if (client.isDirectAccessGrantsEnabled()) {
return getBearerTokenDirectAccess(oauth, client, username, scope).getAccessToken();
} else {
return getBearerTokenCodeFlow(oauth, client, username, scope).getAccessToken();
// For credential scopes, we need to request authorization_details to get credential_identifier
if (scope != null && scope.equals(credScopeName)) {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(getCredentialIssuerMetadata().getCredentialIssuer()));
// Set the redirect URI from the client's configuration
if (client.getRedirectUris() != null && !client.getRedirectUris().isEmpty()) {
oauth.redirectUri(client.getRedirectUris().get(0));
}
String authCode = getAuthorizationCode(oauth, client, username, scope);
return getBearerToken(oauth, authCode, authDetail);
}
// For non-credential scopes, use the appropriate flow based on client configuration
if (client.isDirectAccessGrantsEnabled()) {
return getBearerTokenDirectAccess(oauth, client, username, scope);
} else {
return getBearerTokenCodeFlow(oauth, client, username, scope);
}
}
private List<OID4VCAuthorizationDetailResponse> extractAuthorizationDetails(AccessTokenResponse tokenResponse) {
// First check if already populated in token response
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
return authDetailsResponse;
}
// Otherwise, extract from JWT access token
try {
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authDetails != null) {
return JsonSerialization.readValue(
JsonSerialization.writeValueAsString(authDetails),
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
}
);
}
} catch (Exception e) {
// Ignore - authorization_details not present or couldn't be parsed
}
return null;
}
private String getBearerToken(String clientId, String username, String scope) {
return getBearerTokenResponse(clientId, username, scope).getAccessToken();
}
private String getBearerToken(String clientId, String username, String scope, OID4VCAuthorizationDetail... authDetail) {
@ -402,12 +474,29 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, CredentialsOffer credOffer) throws Exception {
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, AccessTokenResponse tokenResponse, CredentialsOffer credOffer) throws Exception {
List<String> credConfigIds = credOffer.getCredentialConfigurationIds();
if (credConfigIds.size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
var credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
// Extract authorization_details (from token response or JWT)
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = extractAuthorizationDetails(tokenResponse);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
// If authorization_details are present, credential_identifier is required
if (authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
credentialRequest.setCredentialIdentifier(credentialIdentifier);
} else {
throw new IllegalStateException("authorization_details present but no credential_identifier found");
}
} else {
// No authorization_details, use credential_configuration_id
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
}
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}

View file

@ -17,6 +17,7 @@ import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
@ -24,19 +25,25 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidato
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidatorFactory;
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import org.jboss.logging.Logger;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.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.assertTrue;
import static org.junit.Assert.fail;
@ -138,7 +145,20 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
final String scopeName = jwtTypeCredentialClientScope.getName();
String configIdFromScope = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
final String credConfigId = configIdFromScope != null ? configIdFromScope : scopeName;
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
String cNonce = getCNonce();
testingClient.server(TEST_REALM_NAME).run(session -> {
@ -160,8 +180,8 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
Proofs proofs = new Proofs().setAttestation(List.of(attestationJwt));
CredentialRequest request = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setProofs(proofs);
.setCredentialIdentifier(credentialIdentifier)
.setProofs(proofs);
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@ -47,8 +48,14 @@ import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
import org.keycloak.util.JsonSerialization;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test;
@ -58,6 +65,7 @@ import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
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.
@ -182,15 +190,38 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
}
// Test for the whole authorization_code flow with the credentialRequest using credential_configuration_id
// Note: When authorization_details are present in the token, credential_identifier must be used instead
// This test verifies that using credential_configuration_id fails when authorization_details are present
@Test
public void testCompleteFlowWithClaimsValidationAuthorizationCode_credentialRequestWithConfigurationId() throws Exception {
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
return credentialRequest;
};
Oid4vcTestContext ctx = prepareOid4vcTestContext();
testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier);
// Perform authorization code flow to get authorization code (includes authorization_details)
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential request
events.clear();
// Request the credential using credential_configuration_id (should fail when authorization_details are present)
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getAccessToken());
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals("Using credential_configuration_id with token that has authorization_details should fail",
HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
String errorBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error should indicate that credential_identifier must be used. Actual error: " + errorBody,
errorBody.contains("credential_identifier") || errorBody.contains("authorization_details"));
}
}
// Test for the whole authorization_code flow with the credentialRequest using credential_identifier

View file

@ -184,11 +184,12 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
}
// Step 5: Request the actual credential using the identifier
// When authorization_details are present in the token, credential_identifier must be used
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc()
.credentialRequest()
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
.bearerToken(tokenResponse.getAccessToken())
.credentialConfigurationId(credentialConfigurationId)
.credentialIdentifier(credentialIdentifier)
.send();
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());

View file

@ -39,7 +39,9 @@ import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
@ -47,7 +49,11 @@ import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
import org.keycloak.util.JsonSerialization;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -70,6 +76,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
@Rule
public AssertEvents events = new AssertEvents(this);
@Before
public void enableDirectAccessGrants() {
// Enable direct access grants for test-app client to allow password grant
ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true);
}
protected static class Oid4vcTestContext {
CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer;
@ -379,7 +391,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals("invalid_authorization_details", tokenResponse.getError());
assertTrue("Error description should indicate missing credential_configuration_id. Actual: " + tokenResponse.getErrorDescription(),
tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details")
&& tokenResponse.getErrorDescription().contains("credential_configuration_id is required"));
&& tokenResponse.getErrorDescription().contains("credential_configuration_id is required"));
}
@Test
@ -411,7 +423,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals("invalid_authorization_details", tokenResponse.getError());
assertTrue("Error description should indicate invalid claims path. Actual: " + tokenResponse.getErrorDescription(),
tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details")
&& tokenResponse.getErrorDescription().contains("path is required"));
&& tokenResponse.getErrorDescription().contains("path is required"));
}
@Test
@ -429,7 +441,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
.send();
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode());
assertEquals("invalid_authorization_details", tokenResponse.getError());
assertEquals("invalid_request", tokenResponse.getError());
assertNotNull("Error description should be present", tokenResponse.getErrorDescription());
}
@ -526,7 +538,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest()
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
.bearerToken(token)
.bearerToken(tokenResponse.getAccessToken())
.credentialIdentifier(credentialIdentifier)
.send();
@ -558,48 +570,90 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
}
}
@Test
public void testPreAuthorizedCodeTokenEndpointRestriction() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode();
// Step 3: Request a credential using the credentialConfigurationId
//
{
// Clear events before credential request
events.clear();
// Step 1: Get pre-authorized code token
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
.preAuthorizedCodeGrantRequest(preAuthorizedCode.getPreAuthorizedCode())
.endpoint(ctx.openidConfig.getTokenEndpoint())
.send();
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest()
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
.bearerToken(token)
.credentialConfigurationId(credentialConfigurationId)
.send();
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
String preAuthorizedToken = accessTokenResponse.getAccessToken();
assertNotNull("Access token should be present", preAuthorizedToken);
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
// Verify CREDENTIAL_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should be present", credentialIdentifier);
// Parse the credential response
CredentialResponse parsedResponse = credentialResponse.getCredentialResponse();
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Step 2: Verify token works at credential endpoint (should succeed)
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc()
.credentialRequest()
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
.bearerToken(preAuthorizedToken)
.credentialIdentifier(credentialIdentifier)
.send();
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
assertEquals("Pre-authorized code token should work at credential endpoint",
HttpStatus.SC_OK, credentialResponse.getStatusCode());
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Step 3: Verify token is rejected at Account REST API endpoint (uses BearerTokenAuthenticator)
// Account endpoint uses BearerTokenAuthenticator which enforces the restriction
// Use versioned path to get REST API (not HTML UI)
String accountEndpoint = OAuthClient.AUTH_SERVER_ROOT + "/realms/" + oauth.getRealm() + "/account/v1";
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
HttpGet getAccount = new HttpGet(accountEndpoint);
getAccount.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + preAuthorizedToken);
getAccount.addHeader(HttpHeaders.ACCEPT, "application/json");
try (CloseableHttpResponse accountResponse = httpClient.execute(getAccount)) {
assertEquals("Pre-authorized code token should be rejected at account endpoint",
HttpStatus.SC_UNAUTHORIZED, accountResponse.getStatusLine().getStatusCode());
}
// Step 4: Verify token is rejected at Admin REST API endpoint (uses BearerTokenAuthenticator)
// Admin endpoint uses BearerTokenAuthenticator which enforces the restriction
String adminEndpoint = oauth.AUTH_SERVER_ROOT + "/admin/realms/" + oauth.getRealm();
HttpGet getAdmin = new HttpGet(adminEndpoint);
getAdmin.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + preAuthorizedToken);
try (CloseableHttpResponse adminResponse = httpClient.execute(getAdmin)) {
assertEquals("Pre-authorized code token should be rejected at admin endpoint",
HttpStatus.SC_UNAUTHORIZED, adminResponse.getStatusLine().getStatusCode());
}
}
@Test
public void testStandardTokenAllowedAtEndpoint() throws Exception {
// Verify that a standard OIDC token (e.g. from password grant)
// which has an "UNKNOWN" grant type context, is ALLOWED (backward compatibility).
// This ensures the fail-closed logic doesn't accidentally block standard Keycloak flows.
// 1. Get standard token
oauth.realm("test");
oauth.client("test-app", "password");
org.keycloak.testsuite.util.oauth.AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password");
String accessToken = response.getAccessToken();
// 2. Use at Account API (which would be restricted if it were a pre-authorized token)
String accountEndpoint = OAuthClient.AUTH_SERVER_ROOT + "/realms/test/account";
HttpGet getAccount = new HttpGet(accountEndpoint);
getAccount.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
getAccount.addHeader(HttpHeaders.ACCEPT, "application/json");
try (CloseableHttpResponse accountResponse = httpClient.execute(getAccount)) {
// Should be 200 OK because standard tokens are allowed
assertEquals("Standard token should be allowed at account endpoint",
HttpStatus.SC_OK, accountResponse.getStatusLine().getStatusCode());
}
}

View file

@ -39,15 +39,19 @@ import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.KeyManager;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption;
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
@ -55,11 +59,13 @@ import org.apache.http.HttpStatus;
import org.jboss.logging.Logger;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_ENCRYPTION_PARAMETERS;
import static org.keycloak.utils.MediaType.APPLICATION_JWT;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@ -77,7 +83,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
public void testRequestCredentialWithEncryption() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
@ -95,7 +115,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setCredentialIdentifier(credentialIdentifier)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@ -172,7 +192,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
public void testEncryptedCredentialRequest() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
// Enable request encryption requirement
@ -197,7 +231,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setCredentialIdentifier(credentialIdentifier)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@ -238,7 +272,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
public void testEncryptedCredentialRequestWithCompression() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
// Enable request encryption and compression
@ -266,7 +314,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Create credential request with response encryption parameters
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setCredentialIdentifier(credentialIdentifier)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@ -341,10 +389,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testCredentialIssuanceWithEncryption() throws Exception {
// Integration test for the full credential issuance flow with encryption
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
(testClientId, testScope) -> {
String scopeName = jwtTypeCredentialClientScope.getName();
return getBearerToken(oauth.clientId(testClientId).openid(false).scope(scopeName));
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
oauth.clientId(testClientId).openid(false).scope(scopeName);
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
return tokenResponse.getAccessToken();
},
m -> {
String accessToken = (String) m.get("accessToken");
@ -479,7 +538,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
public void testRequestCredentialWithInvalidJWK() throws Throwable {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
@ -488,7 +561,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Invalid JWK (missing modulus but WITH alg parameter)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setCredentialIdentifier(credentialIdentifier)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A256GCM")

View file

@ -47,6 +47,7 @@ import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OAuth2Constants;
import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientResource;
@ -65,6 +66,7 @@ import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwe.JWEHeader;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
@ -111,7 +113,13 @@ import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.logging.Logger;
@ -513,6 +521,27 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
// 2. Using the code to get accesstoken
String token = f.apply(testClientId, testScope);
// Extract credential_identifier from the token (client-side parsing)
String credentialIdentifier = null;
try {
JsonWebToken jwt = new JWSInput(token).readJsonContent(JsonWebToken.class);
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authDetails != null) {
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = JsonSerialization.readValue(
JsonSerialization.writeValueAsString(authDetails),
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
}
);
if (!authDetailsResponse.isEmpty() &&
authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to extract credential_identifier from token", e);
}
// 3. Get the credential configuration id from issuer metadata at .wellKnown
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class),
@ -528,9 +557,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
CredentialRequest request = new CredentialRequest();
request.setCredentialConfigurationId(oid4vciIssuerConfig.getCredentialsSupported()
.get(testCredentialConfigurationId)
.getId());
// Use credential_identifier if available, otherwise use configuration_id for error testing
if (credentialIdentifier != null) {
request.setCredentialIdentifier(credentialIdentifier);
} else {
request.setCredentialConfigurationId(testCredentialConfigurationId);
}
assertEquals(testFormat,
oid4vciIssuerConfig.getCredentialsSupported()
@ -620,6 +652,32 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
}
protected void requestCredentialWithIdentifier(String token,
String credentialEndpoint,
String credentialIdentifier,
CredentialResponseHandler responseHandler,
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
CredentialRequest request = new CredentialRequest();
request.setCredentialIdentifier(credentialIdentifier);
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request),
ContentType.APPLICATION_JSON);
HttpPost postCredential = new HttpPost(credentialEndpoint);
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.setEntity(stringEntity);
CredentialResponse credentialResponse;
try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
}
// Use response handler to customize checks based on formats.
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
}
public CredentialIssuer getCredentialIssuerMetadata() {
final String endpoint = getRealmMetadataPath(TEST_REALM_NAME);
CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc()

View file

@ -36,6 +36,7 @@ import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
@ -52,6 +53,7 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.JwtProof;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.Proofs;
@ -72,6 +74,7 @@ import org.apache.http.HttpStatus;
import org.junit.Assert;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;
import static org.junit.Assert.assertEquals;
@ -301,7 +304,18 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
try {
withCausePropagation(() -> {
@ -314,7 +328,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
CredentialRequest credentialRequest =
new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId);
new CredentialRequest().setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
@ -349,13 +363,27 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testRequestCredential() {
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient.server(TEST_REALM_NAME).run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId);
.setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@ -399,10 +427,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
try {
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to unknown credential identifier");
Assert.fail("Expected BadRequestException due to missing credential identifier or configuration id");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID, error.getError());
assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST, error.getError());
}
});
}
@ -464,19 +492,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// 5. Get an access token for the pre-authorized code
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
.preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())
.endpoint(openidConfig.getTokenEndpoint())
.send();
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
String theToken = accessTokenResponse.getAccessToken();
assertNotNull("Access token should be present", theToken);
// 6. Get the credential
// Extract credential_identifier from authorization_details in token response
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should be present", credentialIdentifier);
// 6. Get the credential using credential_identifier (required when authorization_details are present)
credentialsOffer.getCredentialConfigurationIds().stream()
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
.forEach(supportedCredential -> {
try {
requestCredential(theToken,
requestCredentialWithIdentifier(theToken,
credentialIssuer.getCredentialEndpoint(),
supportedCredential,
credentialIdentifier,
new CredentialResponseHandler(),
jwtTypeCredentialClientScope);
} catch (IOException e) {
@ -488,46 +525,46 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
}
@Test
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() {
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
};
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception {
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
assertEquals("Credential configuration id should match",
jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID),
credentialRequest.getCredentialConfigurationId());
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
(testClientId, testScope) -> {
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
},
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
if (response.getStatus() != 200) {
String errorBody = response.readEntity(String.class);
System.out.println("Error Response: " + errorBody);
}
assertEquals(200, response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
CredentialResponse.class);
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
assertEquals(200, response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
.get("vc"),
VerifiableCredential.class);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
} catch (VerificationException | IOException e) {
throw new RuntimeException(e);
}
};
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
.get("vc"),
VerifiableCredential.class);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
} catch (VerificationException | IOException e) {
throw new RuntimeException(e);
}
});
}
@Test
@ -581,8 +618,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
assertNotNull("Error response should not be null", errorJson);
assertTrue("Error response should mention UNKNOWN_CREDENTIAL_CONFIGURATION or scope",
errorJson.contains("UNKNOWN_CREDENTIAL_CONFIGURATION") || errorJson.contains("scope"));
assertTrue("Error response should mention INVALID_CREDENTIAL_REQUEST or scope",
errorJson.contains("INVALID_CREDENTIAL_REQUEST") || errorJson.contains("scope"));
}
};
@ -596,7 +633,19 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testRequestMultipleCredentialsWithProofs() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
String cNonce = getCNonce();
testingClient.server(TEST_REALM_NAME).run(session -> {
@ -611,7 +660,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest request = new CredentialRequest()
.setCredentialConfigurationId(credConfigId)
.setCredentialIdentifier(credentialIdentifier)
.setProofs(proofs);
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
@ -837,7 +886,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Create a credential request with a non-existent configuration ID
// This will test the unknown_credential_configuration error
// This will test the invalid_credential_request error when no authorization_details present
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId("unknown-configuration-id");
@ -848,7 +897,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
Assert.fail("Expected BadRequestException due to unknown credential configuration");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, error.getError());
assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST, error.getError());
}
});
}
@ -859,7 +908,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
*/
@Test
public void testRequestCredentialWhenNoCredentialBuilderForFormat() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("credential_identifier should be present", credentialIdentifier);
testingClient.server(TEST_REALM_NAME).run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
@ -867,9 +931,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// Prepare endpoint with no credential builders to simulate missing builder for the configured format
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
// Use the known configuration id for the JWT VC test scope
// Use the credential identifier from the token
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(jwtTypeCredentialConfigurationIdName);
.setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@ -889,10 +953,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
*/
@Test
public void testProofToProofsConversion() throws Exception {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
final String scopeName = jwtTypeCredentialClientScope.getName();
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
testingClient.server(TEST_REALM_NAME).run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
@ -900,7 +976,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// Test 1: Create a request with single proof field - should be converted to proofs array
CredentialRequest requestWithProof = new CredentialRequest()
.setCredentialConfigurationId(credentialConfigurationId);
.setCredentialIdentifier(credentialIdentifier);
// Create a single proof object
JwtProof singleProof = new JwtProof()
@ -922,7 +998,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// Test 2: Create a request with both proof and proofs fields - should fail validation
CredentialRequest requestWithBoth = new CredentialRequest()
.setCredentialConfigurationId(credentialConfigurationId);
.setCredentialIdentifier(credentialIdentifier);
requestWithBoth.setProof(singleProof);
@ -965,7 +1041,18 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// Extract serializable data before lambda
final String scopeName = optionalScope.getName();
final String configId = optionalScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(configId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
testingClient.server(TEST_REALM_NAME).run(session -> {
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
@ -973,7 +1060,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(configId);
.setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);

View file

@ -33,6 +33,7 @@ import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
@ -48,6 +49,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
@ -67,6 +69,7 @@ import org.apache.http.HttpStatus;
import org.junit.Assert;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
import static org.junit.Assert.assertEquals;
@ -85,7 +88,19 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestTestCredential() {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
@ -94,14 +109,26 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
.run(session -> {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, null);
testRequestTestCredential(session, clientScope, token, null, credentialIdentifier);
});
}
@Test
public void testRequestTestCredentialWithKeybinding() {
String cNonce = getCNonce();
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
@ -114,7 +141,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof);
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
}));
}
@ -122,7 +149,19 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestTestCredentialWithInvalidKeybinding() throws Throwable {
String cNonce = getCNonce();
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
@ -135,7 +174,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
}));
});
Assert.fail("Should have thrown an exception");
@ -147,7 +186,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testProofOfPossessionWithMissingAudience() throws Throwable {
try {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
@ -163,7 +215,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
})));
Assert.fail("Should have thrown an exception");
} catch (BadRequestException ex) {
@ -179,7 +231,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testProofOfPossessionWithIllegalSourceEndpoint() throws Throwable {
try {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
@ -194,7 +259,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
})));
Assert.fail("Should have thrown an exception");
} catch (BadRequestException ex) {
@ -210,7 +275,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testProofOfPossessionWithExpiredState() throws Throwable {
try {
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
@ -229,7 +307,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
} finally {
// make sure other tests are not affected by the changed realm-attribute
session.getContext().getRealm().removeAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS);
@ -250,16 +328,15 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope,
String token, Proofs proof)
String token, Proofs proof, String credentialIdentifier)
throws VerificationException, IOException {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credentialConfigurationId)
.setCredentialIdentifier(credentialIdentifier)
.setProofs(proof);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@ -337,21 +414,30 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
// 5. Get an access token for the pre-authorized code
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
.preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())
.endpoint(openidConfig.getTokenEndpoint())
.send();
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
String theToken = accessTokenResponse.getAccessToken();
assertNotNull("Access token should be present", theToken);
// Extract credential_identifier from authorization_details in token response
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should be present", credentialIdentifier);
final String vct = clientScope.getAttributes().get(CredentialScopeModel.VCT);
// 6. Get the credential
// 6. Get the credential using credential_identifier (required when authorization_details are present)
credentialsOffer.getCredentialConfigurationIds().stream()
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
.forEach(supportedCredential -> {
try {
requestCredential(theToken,
requestCredentialWithIdentifier(theToken,
credentialIssuer.getCredentialEndpoint(),
supportedCredential,
credentialIdentifier,
new TestCredentialResponseHandler(vct),
sdJwtTypeCredentialClientScope);
} catch (IOException e) {

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import jakarta.ws.rs.core.Response;
@ -26,10 +27,13 @@ import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
@ -40,6 +44,8 @@ import org.keycloak.util.JsonSerialization;
import org.apache.http.HttpStatus;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@ -59,7 +65,20 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
session.getContext().getRealm().setAttribute("oid4vci.time.round.unit", "DAY");
});
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
String scopeName = sdJwtTypeCredentialClientScope.getName();
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
testingClient.server(TEST_REALM_NAME).run(session -> {
@ -81,7 +100,7 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
.setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response response = issuerEndpoint.requestCredential(requestPayload);

View file

@ -19,15 +19,19 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AppAuthManager;
@ -36,6 +40,8 @@ import org.keycloak.util.JsonSerialization;
import org.apache.http.HttpStatus;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@ -57,7 +63,17 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
@ -66,7 +82,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(credConfigId);
.setCredentialIdentifier(credentialIdentifier);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response response = issuerEndpoint.requestCredential(requestPayload);