Make authorizationDetails processing more generic and not tightly coupled to OID4VCI. Fixes

closes #44961

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2026-01-16 21:39:02 +01:00 committed by Marek Posolda
parent 17a2678438
commit 416a6017c2
34 changed files with 825 additions and 576 deletions

View file

@ -22,9 +22,11 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenCategory;
import org.keycloak.representations.idm.authorization.Permission;
@ -147,6 +149,9 @@ public class AccessToken extends IDToken {
@JsonProperty("scope")
protected String scope;
@JsonProperty(OAuth2Constants.AUTHORIZATION_DETAILS)
protected List<AuthorizationDetailsResponse> authorizationDetails;
@JsonIgnore
public Map<String, Access> getResourceAccess() {
return resourceAccess == null ? Collections.<String, Access>emptyMap() : resourceAccess;
@ -274,6 +279,14 @@ public class AccessToken extends IDToken {
this.scope = scope;
}
public List<AuthorizationDetailsResponse> getAuthorizationDetails() {
return authorizationDetails;
}
public void setAuthorizationDetails(List<AuthorizationDetailsResponse> authorizationDetails) {
this.authorizationDetails = authorizationDetails;
}
@Override
public TokenCategory getCategory() {
return TokenCategory.ACCESS;

View file

@ -18,8 +18,11 @@
package org.keycloak.representations;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -61,6 +64,9 @@ public class AccessTokenResponse {
@JsonProperty("scope")
protected String scope;
@JsonProperty(OAuth2Constants.AUTHORIZATION_DETAILS)
protected List<AuthorizationDetailsResponse> authorizationDetails;
@JsonProperty("error")
protected String error;
@ -78,6 +84,14 @@ public class AccessTokenResponse {
this.scope = scope;
}
public List<AuthorizationDetailsResponse> getAuthorizationDetails() {
return authorizationDetails;
}
public void setAuthorizationDetails(List<AuthorizationDetailsResponse> authorizationDetails) {
this.authorizationDetails = authorizationDetails;
}
public String getToken() {
return token;
}

View file

@ -30,7 +30,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
* The JSON representation of a Rich Authorization Request's "authorization_details" object.
*
* @author <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
* @see {@link <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar#section-2">Request parameter "authorization_details"</a>}
* @see {@link <a href="https://datatracker.ietf.org/doc/html/rfc9396#section-2">Request parameter "authorization_details"</a>}
*/
public class AuthorizationDetailsJSONRepresentation implements Serializable {
@ -156,4 +156,6 @@ public class AuthorizationDetailsJSONRepresentation implements Serializable {
public int hashCode() {
return Objects.hash(type, locations, actions, datatypes, identifier, privileges, customData);
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.representations;
import java.util.HashMap;
import java.util.Map;
/**
* Generic response object for authorization details processing.
* This class serves as a base for different types of authorization details responses
* from various RAR (Rich Authorization Requests) implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class AuthorizationDetailsResponse extends AuthorizationDetailsJSONRepresentation {
// Map of parsers for specific values of "type" claim of authorizationDetails
private static final Map<String, AuthorizationDetailsResponseParser<?>> PARSERS = new HashMap<>();
public static void registerParser(String type, AuthorizationDetailsResponseParser<?> parser) {
PARSERS.put(type, parser);
}
public <T extends AuthorizationDetailsResponse> T asSubtype(Class<T> clazz) {
AuthorizationDetailsResponseParser<T> parser = (AuthorizationDetailsResponseParser<T>) PARSERS.get(getType());
if (parser == null) {
throw new IllegalArgumentException("Unsupported to parse response of type '" + getType() + "' to the type '" + clazz +
"'. Please make sure that corresponding parser is registered.");
}
return parser.asSubtype(this);
}
/**
* Parser, which is able to create specific subtype of {@link AuthorizationDetailsResponse} in performant way
*/
public interface AuthorizationDetailsResponseParser<T extends AuthorizationDetailsResponse> {
T asSubtype(AuthorizationDetailsResponse response);
}
}

View file

@ -44,6 +44,7 @@ public class RefreshToken extends AccessToken {
this.nonce = token.nonce;
this.audience = new String[] { token.issuer };
this.scope = token.scope;
this.authorizationDetails = token.authorizationDetails;
}
/**
@ -54,14 +55,7 @@ public class RefreshToken extends AccessToken {
* always be included in the response
*/
public RefreshToken(AccessToken token, Confirmation confirmation) {
this();
this.issuer = token.issuer;
this.subject = token.subject;
this.issuedFor = token.issuedFor;
this.sessionId = token.sessionId;
this.nonce = token.nonce;
this.audience = new String[] { token.issuer };
this.scope = token.scope;
this(token);
this.confirmation = confirmation;
}

View file

@ -80,6 +80,8 @@
<include>org/keycloak/representations/adapters/config/**</include>
<include>org/keycloak/representations/adapters/action/**</include>
<include>org/keycloak/representations/AccessTokenResponse.class</include>
<include>org/keycloak/representations/AuthorizationDetailsJSONRepresentation.class</include>
<include>org/keycloak/representations/AuthorizationDetailsResponse.class</include>
<!--
<include>org/keycloak/representations/idm/ClientRepresentation.class</include>
<include>org/keycloak/representations/idm/RealmRepresentation.class</include>

View file

@ -104,6 +104,9 @@ public interface Errors {
String INVALID_DPOP_PROOF = "invalid_dpop_proof";
// https://datatracker.ietf.org/doc/html/rfc9396#name-authorization-error-respons
String INVALID_AUTHORIZATION_DETAILS = "invalid_authorization_details";
String NOT_LOGGED_IN = "not_logged_in";
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
String ILLEGAL_ORIGIN = "illegal_origin";

View file

@ -18,40 +18,52 @@ package org.keycloak.protocol.oidc.rar;
import java.util.List;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.UserSessionModel;
import org.keycloak.provider.Provider;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.AuthorizationDetailsResponse;
/**
* Provider interface for processing authorization_details parameter in OAuth2/OIDC authorization and token requests.
* This follows the RAR (Rich Authorization Requests) specification and allows different
* implementations to handle various types of authorization details.
* The authorization_details parameter can be used in both authorization requests and token requests
* as specified in the OpenID for Verifiable Credential Issuance specification.
* (as specified for example in the OpenID for Verifiable Credential Issuance specification).
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public interface AuthorizationDetailsProcessor extends Provider {
public interface AuthorizationDetailsProcessor<ADR extends AuthorizationDetailsResponse> extends Provider {
/**
* Checks if this processor should be regarded as supported in the running context.
*/
boolean isSupported();
/**
* @return supported type of authorization_details "type" claim, which this processor is able to process. This should usually correspond with the "providerId" of
* the {@link AuthorizationDetailsProcessorFactory}, which created this processor
*/
String getSupportedType();
/**
* @return supported Java type of {@link AuthorizationDetailsResponse} subclass, which this processor can create in the token response
*/
Class<ADR> getSupportedResponseJavaType();
/**
* Processes the authorization_details parameter and returns a response if this processor
* is able to handle the given authorization_details parameter.
*
* @param userSession the user session
* @param clientSessionCtx the client session context
* @param authorizationDetailsParameter the raw authorization_details parameter value
* @param authorizationDetailsMember the authorization_details member (usually one member from the list) sent in the "authorization_details" request parameter
* @return authorization details response if this processor can handle the parameter,
* null if the parameter is incompatible with this processor
*/
List<AuthorizationDetailsResponse> process(UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
String authorizationDetailsParameter);
ADR process(UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
AuthorizationDetailsJSONRepresentation authorizationDetailsMember) throws InvalidAuthorizationDetailsException;
/**
* Method is invoked in cases when authorization_details parameter is missing in the request. It allows processor to
@ -61,8 +73,8 @@ public interface AuthorizationDetailsProcessor extends Provider {
* @param clientSessionCtx the client session context
* @return authorization details response if this processor can handle current request in case that authorization_details parameter was not provided
*/
List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx);
List<ADR> handleMissingAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx) throws InvalidAuthorizationDetailsException;
/**
* Method is invoked when authorization_details was used in the authorization request but is missing from the token request.
@ -70,11 +82,26 @@ public interface AuthorizationDetailsProcessor extends Provider {
*
* @param userSession the user session
* @param clientSessionCtx the client session context
* @param storedAuthDetails the authorization_details that were stored during the authorization request
* @param storedAuthDetailsMember the parsed member (usually one member of the list) from the authorization_details parameter that were stored during the authorization request
* @return authorization details response if this processor can handle the stored authorization_details,
* null if the processor cannot handle the stored authorization_details
*/
List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
String storedAuthDetails) throws OAuthErrorException;
ADR processStoredAuthorizationDetails(UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
AuthorizationDetailsJSONRepresentation storedAuthDetailsMember) throws InvalidAuthorizationDetailsException;
/**
* @param authzDetailsResponse all the authorizationDetails. May contain also authorizationDetails entries, with different "type" than the type understandable by this processor
* @return sublist of the list provided by "authDetailsResponse" parameter, which will contain just the authorizationDetails of the corresponding type of this processor.
*/
default List<ADR> getSupportedAuthorizationDetails(List<AuthorizationDetailsResponse> authzDetailsResponse) {
if (authzDetailsResponse == null) {
return null;
}
return authzDetailsResponse.stream()
.filter(authDetailsResponse -> getSupportedType().equals(authDetailsResponse.getType()))
.map(authDetailsResponse -> authDetailsResponse.asSubtype(getSupportedResponseJavaType()))
.toList();
}
}

View file

@ -18,7 +18,6 @@ package org.keycloak.protocol.oidc.rar;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderFactory;
/**
@ -26,21 +25,16 @@ import org.keycloak.provider.ProviderFactory;
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public interface AuthorizationDetailsProcessorFactory extends ProviderFactory<AuthorizationDetailsProcessor> {
public interface AuthorizationDetailsProcessorFactory extends ProviderFactory<AuthorizationDetailsProcessor<?>> {
@Override
AuthorizationDetailsProcessor create(KeycloakSession session);
AuthorizationDetailsProcessor<?> create(KeycloakSession session);
@Override
default void init(Config.Scope config) {
// Default implementation does nothing
}
@Override
default void postInit(KeycloakSessionFactory factory) {
// Default implementation does nothing
}
@Override
default void close() {
// Default implementation does nothing

View file

@ -46,7 +46,7 @@ public class AuthorizationDetailsProcessorSpi implements Spi {
}
@Override
public Class<? extends ProviderFactory<AuthorizationDetailsProcessor>> getProviderFactoryClass() {
public Class<? extends ProviderFactory<AuthorizationDetailsProcessor<?>>> getProviderFactoryClass() {
return AuthorizationDetailsProcessorFactory.class;
}
}

View file

@ -1,47 +0,0 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.rar;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* Generic response object for authorization details processing.
* This class serves as a base for different types of authorization details responses
* from various RAR (Rich Authorization Requests) implementations.
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class AuthorizationDetailsResponse {
@JsonIgnore
private Map<String, Object> otherClaims = new HashMap<>();
@JsonAnyGetter
public Map<String, Object> getOtherClaims() {
return otherClaims;
}
@JsonAnySetter
public void setOtherClaims(String name, Object value) {
this.otherClaims.put(name, value);
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.oidc.rar;
public class InvalidAuthorizationDetailsException extends RuntimeException {
public InvalidAuthorizationDetailsException() {
}
public InvalidAuthorizationDetailsException(String message) {
super(message);
}
public InvalidAuthorizationDetailsException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -19,7 +19,7 @@ package org.keycloak.protocol.oid4vc.issuance;
import java.util.List;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.representations.AuthorizationDetailsResponse;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -29,32 +29,21 @@ import com.fasterxml.jackson.annotation.JsonProperty;
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResponse {
public class OID4VCAuthorizationDetailResponse extends AuthorizationDetailsResponse {
@JsonProperty("type")
private String type;
public static final String CREDENTIAL_CONFIGURATION_ID = "credential_configuration_id";
public static final String CREDENTIAL_IDENTIFIERS = "credential_identifiers";
public static final String CLAIMS = "claims";
@JsonProperty("credential_configuration_id")
@JsonProperty(CREDENTIAL_CONFIGURATION_ID)
private String credentialConfigurationId;
@JsonProperty("locations")
private List<String> locations;
@JsonProperty("credential_identifiers")
@JsonProperty(CREDENTIAL_IDENTIFIERS)
private List<String> credentialIdentifiers;
@JsonProperty("claims")
@JsonProperty(CLAIMS)
private List<ClaimsDescription> claims;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCredentialConfigurationId() {
return credentialConfigurationId;
}
@ -63,14 +52,6 @@ public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResp
this.credentialConfigurationId = credentialConfigurationId;
}
public List<String> getLocations() {
return locations;
}
public void setLocations(List<String> locations) {
this.locations = locations;
}
public List<String> getCredentialIdentifiers() {
return credentialIdentifiers;
}
@ -86,4 +67,15 @@ public class OID4VCAuthorizationDetailsResponse extends AuthorizationDetailsResp
public void setClaims(List<ClaimsDescription> claims) {
this.claims = claims;
}
@Override
public String toString() {
return "OID4VCAuthorizationDetailsResponse{" +
"type='" + getType() + '\'' +
", locations='" + getLocations() + '\'' +
", credentialConfigurationId='" + credentialConfigurationId + '\'' +
", credentialIdentifiers=" + credentialIdentifiers +
", claims=" + claims +
'}';
}
}

View file

@ -24,7 +24,6 @@ import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.util.Time;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
@ -33,25 +32,31 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.rar.InvalidAuthorizationDetailsException;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse.CLAIMS;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse.CREDENTIAL_CONFIGURATION_ID;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse.CREDENTIAL_IDENTIFIERS;
import static org.keycloak.protocol.oid4vc.model.ClaimsDescription.MANDATORY;
import static org.keycloak.protocol.oid4vc.model.ClaimsDescription.PATH;
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor {
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor<OID4VCAuthorizationDetailResponse> {
private static final Logger logger = Logger.getLogger(OID4VCAuthorizationDetailsProcessor.class);
private final KeycloakSession session;
@ -65,34 +70,32 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
@Override
public List<AuthorizationDetailsResponse> process(UserSessionModel userSession, ClientSessionContext clientSessionCtx, String authorizationDetailsParameter) {
if (authorizationDetailsParameter == null) {
return null; // authorization_details is optional
}
public String getSupportedType() {
return OPENID_CREDENTIAL;
}
List<AuthorizationDetail> authDetails = parseAuthorizationDetails(authorizationDetailsParameter);
@Override
public Class<OID4VCAuthorizationDetailResponse> getSupportedResponseJavaType() {
return OID4VCAuthorizationDetailResponse.class;
}
@Override
public OID4VCAuthorizationDetailResponse process(UserSessionModel userSession, ClientSessionContext clientSessionCtx, AuthorizationDetailsJSONRepresentation authzDetail) {
OID4VCAuthorizationDetail detail = convertRequestType(authzDetail);
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
List<AuthorizationDetailsResponse> authDetailsResponse = new ArrayList<>();
// Retrieve authorization servers and issuer identifier for locations check
List<String> authorizationServers = OID4VCIssuerWellKnownProvider.getAuthorizationServers(session);
String issuerIdentifier = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
for (AuthorizationDetail detail : authDetails) {
validateAuthorizationDetail(detail, supportedCredentials, authorizationServers, issuerIdentifier);
AuthorizationDetailsResponse responseDetail = buildAuthorizationDetailResponse(detail, userSession, clientSessionCtx);
authDetailsResponse.add(responseDetail);
}
if (authDetailsResponse.isEmpty()) {
throw getInvalidRequestException("no valid authorization details found");
}
validateAuthorizationDetail(detail, supportedCredentials, authorizationServers, issuerIdentifier);
OID4VCAuthorizationDetailResponse responseDetail = buildAuthorizationDetailResponse(detail, userSession, clientSessionCtx);
// For authorization code flow, create CredentialOfferState if credential identifiers are present
// This allows credential requests with credential_identifier to find the associated offer state
createOfferStateForAuthorizationCodeFlow(userSession, clientSessionCtx, authDetailsResponse);
createOfferStateForAuthorizationCodeFlow(userSession, clientSessionCtx, responseDetail);
return authDetailsResponse;
return responseDetail;
}
/**
@ -101,7 +104,7 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
* Processes all OID4VC authorization details to support multiple credential requests.
*/
private void createOfferStateForAuthorizationCodeFlow(UserSessionModel userSession, ClientSessionContext clientSessionCtx,
List<AuthorizationDetailsResponse> authDetailsResponse) {
OID4VCAuthorizationDetailResponse oid4vcDetail) {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
ClientModel client = clientSession != null ? clientSession.getClient() : null;
UserModel user = userSession != null ? userSession.getUser() : null;
@ -121,53 +124,39 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
// Process all OID4VC authorization details to create offer states for each credential
for (AuthorizationDetailsResponse authDetail : authDetailsResponse) {
if (authDetail instanceof OID4VCAuthorizationDetailsResponse oid4vcDetail) {
if (oid4vcDetail.getCredentialIdentifiers() != null && !oid4vcDetail.getCredentialIdentifiers().isEmpty()) {
for (String credentialId : oid4vcDetail.getCredentialIdentifiers()) {
// Check if offer state already exists
CredentialOfferStorage.CredentialOfferState existingState = offerStorage.findOfferStateByCredentialId(session, credentialId);
if (oid4vcDetail.getCredentialIdentifiers() != null && !oid4vcDetail.getCredentialIdentifiers().isEmpty()) {
for (String credentialId : oid4vcDetail.getCredentialIdentifiers()) {
// Check if offer state already exists
CredentialOfferStorage.CredentialOfferState existingState = offerStorage.findOfferStateByCredentialId(session, credentialId);
if (existingState == null) {
// Create a new offer state for authorization code flow
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of(oid4vcDetail.getCredentialConfigurationId()));
if (existingState == null) {
// Create a new offer state for authorization code flow
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of(oid4vcDetail.getCredentialConfigurationId()));
// Use a reasonable expiration time (e.g., 1 hour)
int expiration = Time.currentTime() + 3600;
CredentialOfferStorage.CredentialOfferState offerState = new CredentialOfferStorage.CredentialOfferState(
credOffer, client.getClientId(), user.getId(), expiration);
offerState.setAuthorizationDetails(oid4vcDetail);
// Use a reasonable expiration time (e.g., 1 hour)
int expiration = Time.currentTime() + 3600;
CredentialOfferStorage.CredentialOfferState offerState = new CredentialOfferStorage.CredentialOfferState(
credOffer, client.getClientId(), user.getId(), expiration);
offerState.setAuthorizationDetails(oid4vcDetail);
offerStorage.putOfferState(session, offerState);
logger.debugf("Created credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
client.getClientId(), offerState.getUserId(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
} else {
// Update existing offer state with new authorization details (e.g., if same credential identifier is reused)
existingState.setAuthorizationDetails(oid4vcDetail);
offerStorage.replaceOfferState(session, existingState);
logger.debugf("Updated existing credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
client.getClientId(), existingState.getUserId(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
}
}
offerStorage.putOfferState(session, offerState);
logger.debugf("Created credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
client.getClientId(), offerState.getUserId(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
} else {
// Update existing offer state with new authorization details (e.g., if same credential identifier is reused)
existingState.setAuthorizationDetails(oid4vcDetail);
offerStorage.replaceOfferState(session, existingState);
logger.debugf("Updated existing credential offer state for authorization code flow: [cid=%s, uid=%s, credConfigId=%s, credId=%s]",
client.getClientId(), existingState.getUserId(), oid4vcDetail.getCredentialConfigurationId(), credentialId);
}
}
}
}
private List<AuthorizationDetail> parseAuthorizationDetails(String authorizationDetailsParam) {
try {
return JsonSerialization.readValue(authorizationDetailsParam, new TypeReference<List<AuthorizationDetail>>() {
});
} catch (Exception e) {
logger.warnf(e, "Invalid authorization_details format: %s", authorizationDetailsParam);
throw getInvalidRequestException("format: " + authorizationDetailsParam);
}
}
private RuntimeException getInvalidRequestException(String errorDescription) {
return new RuntimeException("Invalid authorization_details: " + errorDescription);
private InvalidAuthorizationDetailsException getInvalidRequestException(String errorDescription) {
return new InvalidAuthorizationDetailsException("Invalid authorization_details: " + errorDescription);
}
/**
@ -178,7 +167,7 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
* @param authorizationServers list of authorization servers
* @param issuerIdentifier the issuer identifier
*/
private void validateAuthorizationDetail(AuthorizationDetail detail, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> authorizationServers, String issuerIdentifier) {
private void validateAuthorizationDetail(OID4VCAuthorizationDetail detail, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> authorizationServers, String issuerIdentifier) {
String type = detail.getType();
String credentialConfigurationId = detail.getCredentialConfigurationId();
@ -271,45 +260,26 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
}
private AuthorizationDetailsResponse buildAuthorizationDetailResponse(AuthorizationDetail detail, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
private OID4VCAuthorizationDetailResponse buildAuthorizationDetailResponse(OID4VCAuthorizationDetail detail, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
String credentialConfigurationId = detail.getCredentialConfigurationId();
// Try to reuse identifier from authorizationDetailsResponse in client session context
List<AuthorizationDetailsResponse> previousResponses = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
List<String> credentialIdentifiers = null;
if (previousResponses != null) {
for (AuthorizationDetailsResponse prev : previousResponses) {
if (prev instanceof OID4VCAuthorizationDetailsResponse) {
OID4VCAuthorizationDetailsResponse oid4vcResponse = (OID4VCAuthorizationDetailsResponse) prev;
credentialIdentifiers = oid4vcResponse.getCredentialIdentifiers();
break;
}
}
}
List<OID4VCAuthorizationDetailResponse> oid4vcPreviousResponses = getSupportedAuthorizationDetails(previousResponses);
List<String> credentialIdentifiers = oid4vcPreviousResponses != null && !oid4vcPreviousResponses.isEmpty()
? oid4vcPreviousResponses.get(0).getCredentialIdentifiers()
: null;
if (credentialIdentifiers == null) {
credentialIdentifiers = new ArrayList<>();
credentialIdentifiers.add(UUID.randomUUID().toString());
}
OID4VCAuthorizationDetailsResponse responseDetail = new OID4VCAuthorizationDetailsResponse();
OID4VCAuthorizationDetailResponse responseDetail = new OID4VCAuthorizationDetailResponse();
responseDetail.setType(OPENID_CREDENTIAL);
responseDetail.setCredentialConfigurationId(credentialConfigurationId);
responseDetail.setCredentialIdentifiers(credentialIdentifiers);
// Store claims in user session for later use during credential issuance
if (detail.getClaims() != null) {
// Store claims with a unique key based on credential configuration ID
String claimsKey = OID4VCIssuerEndpoint.AUTHORIZATION_DETAILS_CLAIMS_PREFIX + credentialConfigurationId;
try {
userSession.setNote(claimsKey, JsonSerialization.writeValueAsString(detail.getClaims()));
} catch (Exception e) {
logger.warnf(e, "Failed to store claims in user session for credential configuration %s", credentialConfigurationId);
}
// Include claims in response
responseDetail.setClaims(detail.getClaims());
}
responseDetail.setClaims(detail.getClaims());
return responseDetail;
}
@ -322,7 +292,7 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
* @param clientSession the client session that contains the credential offer information
* @return the authorization details response if generation was successful, null otherwise
*/
private List<AuthorizationDetailsResponse> generateAuthorizationDetailsFromCredentialOffer(AuthenticatedClientSessionModel clientSession) {
private List<OID4VCAuthorizationDetailResponse> generateAuthorizationDetailsFromCredentialOffer(AuthenticatedClientSessionModel clientSession) {
logger.debug("Processing authorization_details from credential offer");
// Get supported credentials
@ -341,7 +311,7 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
// Generate authorization_details for each credential configuration
List<AuthorizationDetailsResponse> authorizationDetailsList = new ArrayList<>();
List<OID4VCAuthorizationDetailResponse> authorizationDetailsList = new ArrayList<>();
for (String credentialConfigurationId : credentialConfigurationIds) {
SupportedCredentialConfiguration config = supportedCredentials.get(credentialConfigurationId);
@ -354,7 +324,7 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
logger.debugf("Generated credential identifier '%s' for configuration '%s'",
credentialIdentifier, credentialConfigurationId);
OID4VCAuthorizationDetailsResponse authDetail = new OID4VCAuthorizationDetailsResponse();
OID4VCAuthorizationDetailResponse authDetail = new OID4VCAuthorizationDetailResponse();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setCredentialIdentifiers(List.of(credentialIdentifier));
@ -393,13 +363,22 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
@Override
public List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
return generateAuthorizationDetailsFromCredentialOffer(clientSession);
public List<OID4VCAuthorizationDetailResponse> handleMissingAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
// Only generate authorization_details from credential offer if:
// 1. No authorization_details were processed yet, AND
// 2. There's a credential offer note in the client session (indicating this is a credential offer flow)
// This prevents generating authorization_details for regular SSO logins that don't request OID4VCI - Not 100% sure the check for CREDENTIAL_CONFIGURATION_IDS_NOTE is needed and sufficient
if (clientSessionCtx.getClientSession().getNote(OID4VCIssuerEndpoint.CREDENTIAL_CONFIGURATION_IDS_NOTE) != null) {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
return generateAuthorizationDetailsFromCredentialOffer(clientSession);
} else {
return null;
}
}
@Override
public List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx, String storedAuthDetails) throws OAuthErrorException {
public OID4VCAuthorizationDetailResponse processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx, AuthorizationDetailsJSONRepresentation storedAuthDetails)
throws InvalidAuthorizationDetailsException {
if (storedAuthDetails == null) {
return null;
}
@ -408,11 +387,10 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
try {
return process(userSession, clientSessionCtx, storedAuthDetails);
} catch (RuntimeException e) {
logger.warnf(e, "Error when processing stored authorization_details, cannot fulfill OID4VC requirement");
} catch (InvalidAuthorizationDetailsException e) {
// According to OID4VC spec, if authorization_details was used in authorization request,
// it is required to be returned in token response. If it cannot be processed, return invalid_request error
throw new OAuthErrorException(OAuthErrorException.INVALID_REQUEST, "authorization_details was used in authorization request but cannot be processed for token response: " + e.getMessage());
throw new InvalidAuthorizationDetailsException("authorization_details was used in authorization request but cannot be processed for token response: " + e.getMessage());
}
}
@ -420,4 +398,51 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
public void close() {
// No cleanup needed
}
public static class OID4VCAuthorizationDetailsParser implements AuthorizationDetailsResponse.AuthorizationDetailsResponseParser<OID4VCAuthorizationDetailResponse> {
@Override
public OID4VCAuthorizationDetailResponse asSubtype(AuthorizationDetailsResponse response) {
if (response instanceof OID4VCAuthorizationDetailResponse) {
return (OID4VCAuthorizationDetailResponse) response;
} else {
OID4VCAuthorizationDetailResponse detail = new OID4VCAuthorizationDetailResponse();
detail.setType(response.getType());
detail.setLocations(response.getLocations());
detail.setCredentialConfigurationId((String) response.getCustomData().get(CREDENTIAL_CONFIGURATION_ID));
detail.setClaims(parseClaims((List<Map>) response.getCustomData().get(CLAIMS)));
detail.setCredentialIdentifiers((List<String>) response.getCustomData().get(CREDENTIAL_IDENTIFIERS));
return detail;
}
}
}
private static OID4VCAuthorizationDetail convertRequestType(AuthorizationDetailsJSONRepresentation request) {
if (request instanceof OID4VCAuthorizationDetail) {
return (OID4VCAuthorizationDetail) request;
} else {
OID4VCAuthorizationDetail detail = new OID4VCAuthorizationDetail();
detail.setType(request.getType());
detail.setLocations(request.getLocations());
detail.setCredentialConfigurationId((String) request.getCustomData().get(CREDENTIAL_CONFIGURATION_ID));
detail.setClaims(parseClaims((List<Map>) request.getCustomData().get(CLAIMS)));
return detail;
}
}
private static List<ClaimsDescription> parseClaims(List<Map> genericClaims) {
if (genericClaims == null) {
return null;
}
return genericClaims.stream()
.map(claim -> {
List<Object> path = (List<Object>) claim.get(PATH);
Boolean mandatory = (Boolean) claim.get(MANDATORY);
return new ClaimsDescription(path, mandatory);
})
.toList();
}
}

View file

@ -16,12 +16,11 @@
*/
package org.keycloak.protocol.oid4vc.issuance;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory;
import org.keycloak.representations.AuthorizationDetailsResponse;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
@ -36,23 +35,13 @@ public class OID4VCAuthorizationDetailsProcessorFactory implements Authorization
public static final String PROVIDER_ID = OPENID_CREDENTIAL;
@Override
public AuthorizationDetailsProcessor create(KeycloakSession session) {
public OID4VCAuthorizationDetailsProcessor create(KeycloakSession session) {
return new OID4VCAuthorizationDetailsProcessor(session);
}
@Override
public void init(Config.Scope config) {
// No configuration needed
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// No post-initialization needed
}
@Override
public void close() {
// No cleanup needed
AuthorizationDetailsResponse.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
}
@Override

View file

@ -110,8 +110,9 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.services.CorsErrorResponseException;
@ -124,7 +125,6 @@ import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
@ -136,6 +136,7 @@ import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpPost;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
@ -161,12 +162,6 @@ public class OID4VCIssuerEndpoint {
*/
public static final String CREDENTIAL_CONFIGURATION_IDS_NOTE = "CREDENTIAL_CONFIGURATION_IDS";
/**
* Prefix for session note keys that store authorization details claims.
* This is used to store claims from authorization details for later use during credential issuance.
*/
public static final String AUTHORIZATION_DETAILS_CLAIMS_PREFIX = "AUTHORIZATION_DETAILS_CLAIMS_";
private Cors cors;
/**
@ -789,6 +784,7 @@ public class OID4VCIssuerEndpoint {
}
CredentialScopeModel requestedCredential;
OID4VCAuthorizationDetailResponse authDetails;
// When the CredentialRequest contains a credential identifier the caller must have gone through the
// CredentialOffer process or otherwise have set up a valid CredentialOfferState
@ -807,31 +803,13 @@ public class OID4VCIssuerEndpoint {
// Get the credential_configuration_id from AuthorizationDetails
// First check if offer state has authorization_details (for pre-authorized flows)
OID4VCAuthorizationDetailsResponse authDetails = offerState.getAuthorizationDetails();
authDetails = offerState.getAuthorizationDetails();
// Validate authorization_details: either in token or in offer state
// For pre-authorized flows, offer state is the source of truth
// For authorization code flows, token must contain authorization_details
AccessToken accessToken = authResult.token();
Object tokenAuthDetails = accessToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (tokenAuthDetails == null && authDetails == null) {
var errorMessage = "Access token does not contain authorization_details and offer state has no authorization_details. " +
"Only tokens issued with authorization_details can be used for credential requests with credential_identifier.";
LOGGER.debugf(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Use authorization_details from offer state if available, otherwise from token
// For pre-authorized flows, offer state is authoritative
if (authDetails == null) {
// Extract from token if offer state doesn't have it
// This should be rare but handle it for robustness
if (tokenAuthDetails instanceof List) {
List<AuthorizationDetailsResponse> tokenAuthDetailsList = (List<AuthorizationDetailsResponse>) tokenAuthDetails;
if (!tokenAuthDetailsList.isEmpty() && tokenAuthDetailsList.get(0) instanceof OID4VCAuthorizationDetailsResponse) {
authDetails = (OID4VCAuthorizationDetailsResponse) tokenAuthDetailsList.get(0);
}
}
authDetails = getAuthorizationDetailFromToken(authResult.token());
}
if (authDetails == null) {
@ -894,6 +872,8 @@ public class OID4VCIssuerEndpoint {
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";
@ -914,7 +894,7 @@ public class OID4VCIssuerEndpoint {
if (allProofs.isEmpty()) {
// Single issuance without proof
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
Object theCredential = getCredential(authResult, supportedCredential, authDetails, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential);
} else {
// Issue credentials for each proof
@ -927,7 +907,7 @@ public class OID4VCIssuerEndpoint {
proofForIteration.setProofByType(proofType, currentProof);
// Creating credential with keybinding to the current proof
credentialRequestVO.setProofs(proofForIteration);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
Object theCredential = getCredential(authResult, supportedCredential, authDetails, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential);
}
credentialRequestVO.setProofs(originalProofs);
@ -954,6 +934,13 @@ public class OID4VCIssuerEndpoint {
return response;
}
private OID4VCAuthorizationDetailResponse getAuthorizationDetailFromToken(AccessToken accessToken) {
List<AuthorizationDetailsResponse> tokenAuthDetails = accessToken.getAuthorizationDetails();
AuthorizationDetailsProcessor<OID4VCAuthorizationDetailResponse> oid4vcProcessor = session.getProvider(AuthorizationDetailsProcessor.class, OPENID_CREDENTIAL);
List<OID4VCAuthorizationDetailResponse> oid4vcResponses = oid4vcProcessor.getSupportedAuthorizationDetails(tokenAuthDetails);
return oid4vcResponses == null || oid4vcResponses.isEmpty() ? null : oid4vcResponses.get(0);
}
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata, EventBuilder eventBuilder) throws BadRequestException {
CredentialRequestEncryptionMetadata requestEncryptionMetadata = issuerMetadata.getCredentialRequestEncryption();
boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata)
@ -1428,12 +1415,14 @@ public class OID4VCIssuerEndpoint {
*
* @param authResult authResult containing the userSession to create the credential for
* @param credentialConfig the supported credential configuration
* @param authDetail Parsed OID4VC authorization_detail
* @param credentialRequestVO the credential request
* @param eventBuilder the event builder for logging events
* @return the signed credential
*/
private Object getCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig,
OID4VCAuthorizationDetailResponse authDetail,
CredentialRequest credentialRequestVO,
EventBuilder eventBuilder
) {
@ -1457,7 +1446,7 @@ public class OID4VCIssuerEndpoint {
.filter(Objects::nonNull)
.toList();
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel, eventBuilder);
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, authDetail, credentialRequestVO, credentialScopeModel, eventBuilder);
// Enforce key binding prior to signing if necessary
enforceKeyBindingIfProofProvided(vcIssuanceContext);
@ -1524,7 +1513,7 @@ public class OID4VCIssuerEndpoint {
// builds the unsigned credential by applying all protocol mappers.
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO,
AuthenticationManager.AuthResult authResult, OID4VCAuthorizationDetailResponse authDetail, CredentialRequest credentialRequestVO,
CredentialScopeModel credentialScopeModel, EventBuilder eventBuilder) {
// Compute issuance date and apply correlation-mitigation according to realm configuration
@ -1553,7 +1542,7 @@ public class OID4VCIssuerEndpoint {
// Validate that requested claims from authorization_details are present
String credentialConfigId = credentialConfig.getId();
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId, eventBuilder);
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.user(), authDetail, credentialConfigId, eventBuilder);
// Include all available claims
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
@ -1631,13 +1620,14 @@ public class OID4VCIssuerEndpoint {
*
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
* @param credentialConfig Credential configuration
* @param userSession the user session
* @param user the authenticated user
* @param authzDetail the parsed oid4vc authorization_detail
* @param scope the credential scope
* @param eventBuilder the event builder for logging error events
* @throws BadRequestException if mandatory requested claims are missing
*/
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig,
UserSessionModel userSession, String scope, EventBuilder eventBuilder) {
UserModel user, OID4VCAuthorizationDetailResponse authzDetail, String scope, EventBuilder eventBuilder) {
// Protocol mappers from configuration
Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims()
.stream()
@ -1647,7 +1637,7 @@ public class OID4VCIssuerEndpoint {
})
.collect(Collectors.toMap(ClaimsDescription::getPath, claimsDescription -> claimsDescription));
List<ClaimsDescription> claimsFromAuthzDetails = getClaimsFromAuthzDetails(scope, userSession);
List<ClaimsDescription> claimsFromAuthzDetails = getClaimsFromAuthzDetails(scope, user, authzDetail);
// Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory
for (ClaimsDescription claimDescription : claimsFromAuthzDetails) {
@ -1675,7 +1665,7 @@ public class OID4VCIssuerEndpoint {
String errorMessage = "Credential issuance failed: " + e.getMessage() +
". The requested claims are not available in the user profile.";
LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s"
, scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
, scope,user.getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
// Add error event details with information about which mandatory claim is missing
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(errorMessage);
@ -1683,27 +1673,15 @@ public class OID4VCIssuerEndpoint {
}
private List<ClaimsDescription> getClaimsFromAuthzDetails(String scope, UserSessionModel userSession) {
String username = userSession.getUser().getUsername();
String clientId = session.getContext().getClient().getClientId();
// Look for stored claims in user session notes
String claimsKey = AUTHORIZATION_DETAILS_CLAIMS_PREFIX + scope;
String storedClaimsJson = userSession.getNote(claimsKey);
if (storedClaimsJson != null && !storedClaimsJson.isEmpty()) {
try {
// Parse the stored claims from JSON
return JsonSerialization.readValue(storedClaimsJson,
new TypeReference<>() {
});
} catch (Exception e) {
LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId);
}
} else {
private List<ClaimsDescription> getClaimsFromAuthzDetails(String scope, UserModel user, OID4VCAuthorizationDetailResponse authzDetail) {
List<ClaimsDescription> storedClaims = authzDetail == null ? null : authzDetail.getClaims();
if (storedClaims == null || storedClaims.isEmpty()) {
String username = user.getUsername();
String clientId = session.getContext().getClient().getClientId();
LOGGER.debugf("No stored claims found for scope '%s', user '%s', client '%s'", scope, username, clientId);
return Collections.emptyList();
} else {
return storedClaims;
}
return Collections.emptyList();
}
}

View file

@ -22,7 +22,7 @@ import java.util.Optional;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
@ -41,7 +41,7 @@ public interface CredentialOfferStorage extends Provider {
private String userId;
private String nonce;
private int expiration;
private OID4VCAuthorizationDetailsResponse authorizationDetails;
private OID4VCAuthorizationDetailResponse authorizationDetails;
public CredentialOfferState(CredentialsOffer credOffer, String clientId, String userId, int expiration) {
this.credentialsOffer = credOffer;
@ -88,11 +88,11 @@ public interface CredentialOfferStorage extends Provider {
return expiration;
}
public OID4VCAuthorizationDetailsResponse getAuthorizationDetails() {
public OID4VCAuthorizationDetailResponse getAuthorizationDetails() {
return authorizationDetails;
}
public void setAuthorizationDetails(OID4VCAuthorizationDetailsResponse authorizationDetails) {
public void setAuthorizationDetails(OID4VCAuthorizationDetailResponse authorizationDetails) {
this.authorizationDetails = authorizationDetails;
}

View file

@ -29,10 +29,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
*/
public class ClaimsDescription {
@JsonProperty("path")
public static final String PATH = "path";
public static final String MANDATORY = "mandatory";
@JsonProperty(PATH)
private List<Object> path;
@JsonProperty("mandatory")
@JsonProperty(MANDATORY)
private Boolean mandatory;
public ClaimsDescription() {

View file

@ -16,13 +16,10 @@
*/
package org.keycloak.protocol.oid4vc.model;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
@ -30,41 +27,14 @@ import com.fasterxml.jackson.annotation.JsonProperty;
*
* @author <a href="mailto:Forkim.Akwichek@adorsys.com">Forkim Akwichek</a>
*/
public class AuthorizationDetail {
@JsonProperty("type")
private String type;
public class OID4VCAuthorizationDetail extends AuthorizationDetailsJSONRepresentation {
@JsonProperty("credential_configuration_id")
private String credentialConfigurationId;
@JsonProperty("locations")
private List<String> locations;
@JsonProperty("claims")
private List<ClaimsDescription> claims;
@JsonIgnore
private Map<String, Object> additionalFields = new HashMap<>();
@JsonAnyGetter
public Map<String, Object> getAdditionalFields() {
return additionalFields;
}
@JsonAnySetter
public void setAdditionalField(String name, Object value) {
this.additionalFields.put(name, value);
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCredentialConfigurationId() {
return credentialConfigurationId;
}
@ -73,14 +43,6 @@ public class AuthorizationDetail {
this.credentialConfigurationId = credentialConfigurationId;
}
public List<String> getLocations() {
return locations;
}
public void setLocations(List<String> locations) {
this.locations = locations;
}
public List<ClaimsDescription> getClaims() {
return claims;
}
@ -88,4 +50,14 @@ public class AuthorizationDetail {
public void setClaims(List<ClaimsDescription> claims) {
this.claims = claims;
}
@Override
public String toString() {
return "OID4VCAuthorizationDetailsResponse{" +
"type='" + getType() + '\'' +
", locations='" + getLocations() + '\'' +
", credentialConfigurationId='" + credentialConfigurationId + '\'' +
", claims=" + claims +
'}';
}
}

View file

@ -52,7 +52,6 @@ import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory;
import org.keycloak.protocol.oidc.representations.MTLSEndpointAliases;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.AcrUtils;
@ -331,12 +330,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
}
private List<String> getAuthorizationDetailsTypesSupported() {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.map(AuthorizationDetailsProcessorFactory.class::cast)
.map(factory -> Map.entry(factory.getId(), factory.create(session)))
.filter(entry -> entry.getValue().isSupported())
.map(Map.Entry::getKey)
return session.getAllProviders(AuthorizationDetailsProcessor.class).stream()
.filter(AuthorizationDetailsProcessor::isSupported)
.map(AuthorizationDetailsProcessor::getSupportedType)
.toList();
}

View file

@ -104,6 +104,7 @@ import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.LogoutToken;
@ -129,6 +130,7 @@ import org.keycloak.util.TokenUtil;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.ORGANIZATION;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser;
import static org.keycloak.representations.IDToken.NONCE;
@ -352,10 +354,11 @@ public class TokenManager {
AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session,
validation.userSession, validation.clientSessionCtx).offlineToken( TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())).accessToken(validation.newToken);
// Copy authorization_details from refresh token to new access token if present
Object authorizationDetails = refreshToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
// Copy authorization_details from refresh token to new access token and to acessTokenResponse (if present)
List<AuthorizationDetailsResponse> authorizationDetails = refreshToken.getAuthorizationDetails();
if (authorizationDetails != null) {
validation.newToken.setOtherClaims(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
validation.newToken.setAuthorizationDetails(authorizationDetails);
validation.clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetails);
}
if (clientConfig.isUseRefreshToken()) {
@ -368,11 +371,6 @@ public class TokenManager {
responseBuilder.getRefreshToken().setAuthorization(validation.newToken.getAuthorization());
}
// Ensure authorization_details are also in the new refresh token if present
if (authorizationDetails != null && clientConfig.isUseRefreshToken() && responseBuilder.getRefreshToken() != null) {
responseBuilder.getRefreshToken().setOtherClaims(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
}
String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
@ -1235,11 +1233,7 @@ public class TokenManager {
.map(ClientModel::getClientId)
.collect(Collectors.toSet()));
}
// Copy authorization_details from access token to refresh token if present
Object authorizationDetails = accessToken.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authorizationDetails != null) {
refreshToken.getOtherClaims().put(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
}
Boolean bindOnlyRefreshToken = session.getAttributeOrDefault(DPoPUtil.DPOP_BINDING_ONLY_REFRESH_TOKEN_SESSION_ATTRIBUTE, false);
if (bindOnlyRefreshToken) {
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
@ -1410,6 +1404,11 @@ public class TokenManager {
res.setScope(responseScope);
event.detail(Details.SCOPE, responseScope);
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
res.setAuthorizationDetails(authDetailsResponse);
}
response = res;
return response;
}

View file

@ -34,14 +34,12 @@ import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.TokenRequestContext;
@ -241,12 +239,8 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
}
}
// Only generate authorization_details from credential offer if:
// 1. No authorization_details were processed yet, AND
// 2. There's a credential offer note in the client session (indicating this is a credential offer flow)
// This prevents generating authorization_details for regular SSO logins that don't request OID4VCI
if ((authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty())
&& clientSession.getNote(OID4VCIssuerEndpoint.CREDENTIAL_CONFIGURATION_IDS_NOTE) != null) {
// Case when authorization_details response not generated
if ((authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty())) {
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), clientSessionCtx);
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
@ -260,24 +254,16 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
// Add authorization_details to the access token and refresh token if they were processed
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
s.getAccessToken().setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
s.getAccessToken().setAuthorizationDetails(authDetailsResponse);
// Also add to refresh token if one is generated
if (s.getRefreshToken() != null) {
s.getRefreshToken().setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
s.getRefreshToken().setAuthorizationDetails(authDetailsResponse);
}
}
return new TokenResponseContext(formParams, parseResult, clientSessionCtx, s);
});
}
@Override
protected void addCustomTokenResponseClaims(AccessTokenResponse res, ClientSessionContext clientSessionCtx) {
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
res.setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
}
}
@Override
public EventType getEventType() {
return EventType.CODE_TO_TOKEN;

View file

@ -50,12 +50,13 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorManager;
import org.keycloak.protocol.oidc.rar.InvalidAuthorizationDetailsException;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
@ -307,22 +308,13 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS);
if (authorizationDetailsParam != null) {
try {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
.map(authzDetailsProcessor -> authzDetailsProcessor.process(userSession, clientSessionCtx, authorizationDetailsParam))
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
} catch (RuntimeException e) {
if (e.getMessage() != null && e.getMessage().contains("Invalid authorization_details")) {
logger.warnf(e, "Error when processing authorization_details");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, "invalid_request", "Error when processing authorization_details", Response.Status.BAD_REQUEST);
} else {
throw e;
}
return new AuthorizationDetailsProcessorManager()
.processAuthorizationDetails(session, userSession, clientSessionCtx, authorizationDetailsParam);
} catch (InvalidAuthorizationDetailsException e) {
logger.warnf(e, "Error when processing authorization_details");
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_AUTHORIZATION_DETAILS);
throw new CorsErrorResponseException(cors, Errors.INVALID_AUTHORIZATION_DETAILS, e.getMessage(), Response.Status.BAD_REQUEST);
}
}
return null;
@ -338,18 +330,12 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
*/
protected List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
try {
var result = session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
.map(processor -> processor.handleMissingAuthorizationDetails(userSession, clientSessionCtx))
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
return result;
return new AuthorizationDetailsProcessorManager().handleMissingAuthorizationDetails(session, userSession, clientSessionCtx);
} catch (RuntimeException e) {
logger.warnf(e, "Error when handling missing authorization_details");
return null;
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_AUTHORIZATION_DETAILS);
throw new CorsErrorResponseException(cors, Errors.INVALID_AUTHORIZATION_DETAILS, e.getMessage(), Response.Status.BAD_REQUEST);
}
}
@ -368,24 +354,13 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
if (storedAuthDetails != null) {
logger.debugf("Found authorization_details in client session, processing it");
try {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
.map(processor -> {
try {
return processor.processStoredAuthorizationDetails(userSession, clientSessionCtx, storedAuthDetails);
} catch (OAuthErrorException e) {
// Wrap OAuthErrorException in CorsErrorResponseException for proper HTTP response
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
}
})
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
} catch (RuntimeException e) {
return new AuthorizationDetailsProcessorManager()
.processStoredAuthorizationDetails(session, userSession, clientSessionCtx, cors, storedAuthDetails);
} catch (InvalidAuthorizationDetailsException e) {
logger.warnf(e, "Error when processing stored authorization_details");
throw e;
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_AUTHORIZATION_DETAILS);
throw new CorsErrorResponseException(cors, Errors.INVALID_AUTHORIZATION_DETAILS, e.getMessage(), Response.Status.BAD_REQUEST);
}
}
return null;

View file

@ -32,21 +32,20 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
import static org.keycloak.services.util.DefaultClientSessionContext.fromClientSessionAndScopeParameter;
public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
@ -136,6 +135,9 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
// set the client as retrieved from the pre-authorized session
session.getContext().setClient(clientModel);
event.client(clientModel)
.user(userModel);
// Process authorization_details using provider discovery
List<AuthorizationDetailsResponse> authorizationDetailsResponses = processAuthorizationDetails(userSession, sessionContext);
LOGGER.debugf("Initial authorization_details processing result: %s", authorizationDetailsResponses);
@ -155,7 +157,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
}
// Add authorization_details to the OfferState and otherClaims
var authDetails = (OID4VCAuthorizationDetailsResponse) authorizationDetailsResponses.get(0);
var authDetails = (OID4VCAuthorizationDetailResponse) authorizationDetailsResponses.get(0);
offerState.setAuthorizationDetails(authDetails);
offerStorage.replaceOfferState(session, offerState);
@ -166,7 +168,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
userSession,
sessionContext);
accessToken.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses);
accessToken.setAuthorizationDetails(authorizationDetailsResponses);
AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(
clientSession.getRealm(),
@ -179,7 +181,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
AccessTokenResponse tokenResponse;
try {
tokenResponse = responseBuilder.build();
tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses);
tokenResponse.setAuthorizationDetails(authorizationDetailsResponses);
} catch (RuntimeException re) {
String errorMessage = "Cannot get encryption KEK";
if (errorMessage.equals(re.getMessage())) {

View file

@ -0,0 +1,105 @@
package org.keycloak.protocol.oidc.rar;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.services.cors.Cors;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
public class AuthorizationDetailsProcessorManager {
private static final Logger logger = Logger.getLogger(AuthorizationDetailsProcessorManager.class);
public List<AuthorizationDetailsResponse> processAuthorizationDetails(KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
String authorizationDetailsParam) throws InvalidAuthorizationDetailsException {
return processAuthzDetailsImpl(session, authorizationDetailsParam,
(processor, authzDetail) -> processor.process(userSession, clientSessionCtx, authzDetail));
}
public List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(KeycloakSession session, UserSessionModel userSession,
ClientSessionContext clientSessionCtx,
Cors cors,
String authorizationDetailsParam) throws InvalidAuthorizationDetailsException {
return processAuthzDetailsImpl(session, authorizationDetailsParam,
(processor, authzDetail) ->
processor.processStoredAuthorizationDetails(userSession, clientSessionCtx, authzDetail)
);
}
public List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
.map(processor -> processor.handleMissingAuthorizationDetails(userSession, clientSessionCtx))
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
}
private List<AuthorizationDetailsResponse> processAuthzDetailsImpl(KeycloakSession session, String authorizationDetailsParam,
BiFunction<AuthorizationDetailsProcessor<?>, AuthorizationDetailsJSONRepresentation, AuthorizationDetailsResponse> function) throws InvalidAuthorizationDetailsException {
if (authorizationDetailsParam == null) {
return null;
}
List<AuthorizationDetailsResponse> authzResponses = new ArrayList<>();
List<AuthorizationDetailsJSONRepresentation> authzDetails = parseAuthorizationDetails(authorizationDetailsParam);
Map<String, AuthorizationDetailsProcessor<?>> processors = getProcessors(session);
for (AuthorizationDetailsJSONRepresentation authzDetail : authzDetails) {
if (authzDetail.getType() == null) {
throw new InvalidAuthorizationDetailsException("Authorization_Details parameter provided without type: " + authorizationDetailsParam);
}
AuthorizationDetailsProcessor<?> processor = processors.get(authzDetail.getType());
if (processor == null) {
String errorDetails = String.format("Unsupported type '%s' of authorization_details parameter supplied in the request. Supported values: %s",
authzDetail.getType(), processors.keySet());
logger.warn(errorDetails);
throw new InvalidAuthorizationDetailsException(errorDetails);
}
function.apply(processor, authzDetail);
AuthorizationDetailsResponse response = function.apply(processor, authzDetail);
if (response != null) {
authzResponses.add(response);
} else {
logger.debugf("Null response returned by authorization processor " + processor + " for given authorization details");
}
}
return authzResponses;
}
private List<AuthorizationDetailsJSONRepresentation> parseAuthorizationDetails(String authorizationDetailsParam) {
try {
return JsonSerialization.readValue(authorizationDetailsParam, new TypeReference<List<AuthorizationDetailsJSONRepresentation>>() {
});
} catch (Exception e) {
logger.warnf(e, "Invalid authorization_details format: %s", authorizationDetailsParam);
throw new InvalidAuthorizationDetailsException("Invalid authorization_details: " + authorizationDetailsParam);
}
}
private Map<String, AuthorizationDetailsProcessor<?>> getProcessors(KeycloakSession session) {
return session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.collect(Collectors.toMap(ProviderFactory::getId, factory -> (AuthorizationDetailsProcessor<?>) session.getProvider(AuthorizationDetailsProcessor.class, factory.getId())));
}
}

View file

@ -16,12 +16,17 @@
*/
package org.keycloak.protocol.oid4vc.issuance;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.util.JsonSerialization;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS;
@ -46,11 +51,16 @@ import static org.junit.Assert.assertTrue;
*/
public class OID4VCAuthorizationDetailsProcessorTest {
@BeforeClass
public static void beforeClass() {
AuthorizationDetailsResponse.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
}
/**
* Creates a valid AuthorizationDetail for testing
*/
private AuthorizationDetail createValidAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
private OID4VCAuthorizationDetail createValidAuthorizationDetail() {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId("test-config-id");
authDetail.setLocations(List.of("https://test-issuer.com"));
@ -60,8 +70,8 @@ public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Creates a valid AuthorizationDetail with claims for testing
*/
private AuthorizationDetail createValidAuthorizationDetailWithClaims() {
AuthorizationDetail authDetail = createValidAuthorizationDetail();
private OID4VCAuthorizationDetail createValidAuthorizationDetailWithClaims() {
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim1 = new ClaimsDescription();
claim1.setPath(Arrays.asList("credentialSubject", "given_name"));
@ -78,8 +88,8 @@ public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Creates an invalid AuthorizationDetail with wrong type for testing
*/
private AuthorizationDetail createInvalidTypeAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
private OID4VCAuthorizationDetail createInvalidTypeAuthorizationDetail() {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType("invalid_type");
authDetail.setCredentialConfigurationId("test-config-id");
return authDetail;
@ -88,8 +98,8 @@ public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Creates an AuthorizationDetail with missing credential configuration ID for testing
*/
private AuthorizationDetail createMissingCredentialIdAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
private OID4VCAuthorizationDetail createMissingCredentialIdAuthorizationDetail() {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
return authDetail;
}
@ -118,7 +128,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Asserts that an AuthorizationDetail has valid structure
*/
private void assertValidAuthorizationDetail(AuthorizationDetail authDetail) {
private void assertValidAuthorizationDetail(OID4VCAuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Credential configuration ID should be set", "test-config-id", authDetail.getCredentialConfigurationId());
assertNotNull("Locations should not be null", authDetail.getLocations());
@ -126,10 +136,23 @@ public class OID4VCAuthorizationDetailsProcessorTest {
assertEquals("Location should match issuer", "https://test-issuer.com", authDetail.getLocations().get(0));
}
/**
* Asserts that an AuthorizationDetail has valid structure
*/
private void assertValidAuthorizationDetailResponse(OID4VCAuthorizationDetailResponse authDetail) {
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Credential configuration ID should be set", "test-config-id", authDetail.getCredentialConfigurationId());
assertNotNull("Locations should not be null", authDetail.getLocations());
assertEquals("Should have exactly one location", 1, authDetail.getLocations().size());
assertEquals("Location should match issuer", "https://test-issuer.com", authDetail.getLocations().get(0));
assertEquals(1, authDetail.getCredentialIdentifiers().size());
assertEquals("test-identifier-123", authDetail.getCredentialIdentifiers().get(0));
}
/**
* Asserts that an AuthorizationDetail has invalid type
*/
private void assertInvalidTypeAuthorizationDetail(AuthorizationDetail authDetail) {
private void assertInvalidTypeAuthorizationDetail(OID4VCAuthorizationDetail authDetail) {
assertNotEquals("Type should not be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Invalid type should be preserved", "invalid_type", authDetail.getType());
}
@ -137,7 +160,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
/**
* Asserts that an AuthorizationDetail has missing credential configuration ID
*/
private void assertMissingCredentialIdAuthorizationDetail(AuthorizationDetail authDetail) {
private void assertMissingCredentialIdAuthorizationDetail(OID4VCAuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertNull("Credential configuration ID should be null", authDetail.getCredentialConfigurationId());
}
@ -163,35 +186,35 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testAuthorizationDetailValidation() {
// Test the core validation logic that the processor uses
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
assertValidAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithInvalidType() {
// Test validation logic for invalid type
AuthorizationDetail authDetail = createInvalidTypeAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithMissingCredentialConfigurationId() {
// Test validation logic for missing credential configuration ID
AuthorizationDetail authDetail = createMissingCredentialIdAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(authDetail);
}
@Test
public void testAuthorizationDetailWithClaims() {
// Test the claims processing logic that the processor uses
AuthorizationDetail authDetail = createValidAuthorizationDetailWithClaims();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetailWithClaims();
assertValidClaims(authDetail.getClaims());
}
@Test
public void testAuthorizationDetailWithComplexClaims() {
// Test complex claims processing logic
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim1 = new ClaimsDescription();
claim1.setPath(Arrays.asList("credentialSubject", "address", "street"));
@ -220,7 +243,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testAuthorizationDetailWithNullClaims() {
// Test null claims handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setClaims(null);
assertNull("Claims should be null", authDetail.getClaims());
}
@ -228,7 +251,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testAuthorizationDetailWithEmptyClaims() {
// Test empty claims handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setClaims(List.of());
assertNotNull("Claims should not be null", authDetail.getClaims());
assertTrue("Claims should be empty", authDetail.getClaims().isEmpty());
@ -237,7 +260,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testAuthorizationDetailWithMultipleLocations() {
// Test multiple locations handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setLocations(Arrays.asList("https://issuer1.com", "https://issuer2.com"));
// Verify multiple locations structure
@ -250,7 +273,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testAuthorizationDetailWithNullLocations() {
// Test null locations handling
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setLocations(null);
assertNull("Locations should be null", authDetail.getLocations());
}
@ -309,17 +332,17 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testParseAuthorizationDetailsLogic() {
// Test valid authorization details structure that would be parsed
AuthorizationDetail authDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
authDetail.setClaims(List.of(claim));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
// Verify the structure that parseAuthorizationDetails() would process
assertNotNull("Authorization details list should not be null", authDetails);
assertEquals("Should have exactly one authorization detail", 1, authDetails.size());
AuthorizationDetail parsedDetail = authDetails.get(0);
OID4VCAuthorizationDetail parsedDetail = authDetails.get(0);
assertEquals("Type should be preserved", OPENID_CREDENTIAL, parsedDetail.getType());
assertEquals("Credential configuration ID should be preserved", "test-config-id", parsedDetail.getCredentialConfigurationId());
assertNotNull("Claims should be preserved", parsedDetail.getClaims());
@ -329,22 +352,22 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testValidateAuthorizationDetailLogic() {
// Test valid authorization detail that would pass validation
AuthorizationDetail validDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail validDetail = createValidAuthorizationDetail();
assertValidAuthorizationDetail(validDetail);
// Test invalid type that would fail validation
AuthorizationDetail invalidDetail = createInvalidTypeAuthorizationDetail();
OID4VCAuthorizationDetail invalidDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(invalidDetail);
// Test missing credential configuration ID that would fail validation
AuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
OID4VCAuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(missingIdDetail);
}
@Test
public void testValidateClaimsLogic() {
// Test valid claims that would pass validation
AuthorizationDetail authDetailWithClaims = createValidAuthorizationDetailWithClaims();
OID4VCAuthorizationDetail authDetailWithClaims = createValidAuthorizationDetailWithClaims();
List<ClaimsDescription> validClaims = authDetailWithClaims.getClaims();
assertValidClaims(validClaims);
@ -375,8 +398,8 @@ public class OID4VCAuthorizationDetailsProcessorTest {
List<ClaimsDescription> expectedClaims = List.of(claim);
// Test authorization detail that would be used to build response
AuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setAdditionalField(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers);
OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail();
authDetail.setCustomData(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers);
authDetail.setClaims(expectedClaims);
// Verify the data structure that buildAuthorizationDetailResponse() would process
@ -385,7 +408,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
assertEquals("Should have exactly one claim", 1, authDetail.getClaims().size());
@SuppressWarnings("unchecked")
List<String> actualCredentialIdentifiers = (List<String>) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS);
List<String> actualCredentialIdentifiers = (List<String>) authDetail.getCustomData().get(CREDENTIAL_IDENTIFIERS);
// Verify the response data that would be created
assertEquals("Response type should match", OPENID_CREDENTIAL, authDetail.getType());
@ -401,23 +424,23 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testProcessStoredAuthorizationDetailsLogic() {
// Test valid stored authorization details
AuthorizationDetail storedDetail = createValidAuthorizationDetail();
OID4VCAuthorizationDetail storedDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
storedDetail.setClaims(List.of(claim));
List<AuthorizationDetail> storedDetails = List.of(storedDetail);
List<OID4VCAuthorizationDetail> storedDetails = List.of(storedDetail);
// Verify the stored details structure that processStoredAuthorizationDetails() would process
assertNotNull("Stored details should not be null", storedDetails);
assertEquals("Should have exactly one stored detail", 1, storedDetails.size());
AuthorizationDetail processedDetail = storedDetails.get(0);
OID4VCAuthorizationDetail processedDetail = storedDetails.get(0);
assertValidAuthorizationDetail(processedDetail);
assertNotNull("Claims should be preserved", processedDetail.getClaims());
assertEquals("Should have exactly one claim", 1, processedDetail.getClaims().size());
// Test null stored details
List<AuthorizationDetail> nullStoredDetails = null;
List<OID4VCAuthorizationDetail> nullStoredDetails = null;
assertNull("Null stored details should be null", nullStoredDetails);
}
@ -450,11 +473,11 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testErrorHandlingLogic() {
// Test invalid type error handling
AuthorizationDetail invalidTypeDetail = createInvalidTypeAuthorizationDetail();
OID4VCAuthorizationDetail invalidTypeDetail = createInvalidTypeAuthorizationDetail();
assertInvalidTypeAuthorizationDetail(invalidTypeDetail);
// Test missing credential configuration ID error handling
AuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
OID4VCAuthorizationDetail missingIdDetail = createMissingCredentialIdAuthorizationDetail();
assertMissingCredentialIdAuthorizationDetail(missingIdDetail);
// Test invalid claims error handling
@ -469,4 +492,35 @@ public class OID4VCAuthorizationDetailsProcessorTest {
assertNotNull("Empty path claim should not be null", emptyPathClaim.getPath());
assertTrue("Empty path should be empty", emptyPathClaim.getPath().isEmpty());
}
@Test
public void testOID4VCAuthzDetailsTogetherWithGenericAuthzDetails() throws IOException {
List<String> expectedCredentialIdentifiers = List.of("test-identifier-123");
OID4VCAuthorizationDetail validDetail1 = createValidAuthorizationDetail();
validDetail1.setCustomData(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers);
OID4VCAuthorizationDetail validDetail2 = createValidAuthorizationDetailWithClaims();
validDetail2.setCustomData(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers);
OID4VCAuthorizationDetail invalidDetail1 = createInvalidTypeAuthorizationDetail();
List<AuthorizationDetailsResponse> responses = List.of(
convertToResponseType(validDetail1),
convertToResponseType(validDetail2),
convertToResponseType(invalidDetail1)
);
List<OID4VCAuthorizationDetailResponse> authzResponses = new OID4VCAuthorizationDetailsProcessor(null).getSupportedAuthorizationDetails(responses);
Assert.assertEquals(2, authzResponses.size());
assertValidAuthorizationDetailResponse(authzResponses.get(0));
Assert.assertNull(authzResponses.get(0).getClaims());
assertValidAuthorizationDetailResponse(authzResponses.get(1));
assertValidClaims(authzResponses.get(1).getClaims());
}
private AuthorizationDetailsResponse convertToResponseType(Object oid4vcDetails) throws IOException {
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(oid4vcDetails), AuthorizationDetailsResponse.class);
}
}

View file

@ -4,7 +4,7 @@ import java.io.IOException;
import java.util.List;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
@ -43,7 +43,7 @@ public class AccessTokenRequest extends AbstractHttpPostRequest<AccessTokenReque
return this;
}
public AccessTokenRequest authorizationDetails(List<AuthorizationDetail> authDetails) {
public AccessTokenRequest authorizationDetails(List<OID4VCAuthorizationDetail> authDetails) {
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, JsonSerialization.valueAsString(authDetails));
return this;
}

View file

@ -7,7 +7,7 @@ import java.util.List;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.util.JsonSerialization;
import org.apache.http.client.methods.CloseableHttpResponse;
@ -23,7 +23,7 @@ public class AccessTokenResponse extends AbstractHttpResponse {
private String refreshToken;
private String scope;
private String sessionState;
private List<AuthorizationDetail> authorizationDetails;
private List<AuthorizationDetailsResponse> authorizationDetails;
private Map<String, Object> otherClaims;
@ -68,7 +68,7 @@ public class AccessTokenResponse extends AbstractHttpResponse {
break;
case OAuth2Constants.AUTHORIZATION_DETAILS:
var valJson = JsonSerialization.valueAsString(entry.getValue());
var arr = JsonSerialization.valueFromString(valJson, AuthorizationDetail[].class);
var arr = JsonSerialization.valueFromString(valJson, AuthorizationDetailsResponse[].class);
authorizationDetails = Arrays.asList(arr);
break;
default:
@ -118,7 +118,17 @@ public class AccessTokenResponse extends AbstractHttpResponse {
return otherClaims;
}
public List<AuthorizationDetail> getAuthorizationDetails() {
public List<AuthorizationDetailsResponse> getAuthorizationDetails() {
return authorizationDetails;
}
public <ADR extends AuthorizationDetailsResponse> List<ADR> getAuthorizationDetails(Class<ADR> clazz) {
if (getAuthorizationDetails() == null) {
return null;
} else {
return getAuthorizationDetails().stream()
.map(authzResponse -> authzResponse.asSubtype(clazz))
.toList();
}
}
}

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.testsuite.oid4vc.issuance;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@ -30,12 +31,13 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
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.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
@ -64,7 +66,6 @@ import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
@ -143,14 +144,14 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
var ctx = newTestContext(false, null, appUsername);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetailResponse authDetail = new OID4VCAuthorizationDetailResponse();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer()));
// [TODO #44320] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails
// https://github.com/keycloak/keycloak/issues/44320
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, authDetail);
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, convertToAuthzDetail(authDetail));
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail);
verifyCredentialResponse(ctx, credResponse);
@ -272,7 +273,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
// 4. does not reflect anything from the credential offer
//
AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(ctx, credOffer);
List<AuthorizationDetail> authDetails = accessToken.getAuthorizationDetails();
List<OID4VCAuthorizationDetailResponse> authDetails = accessToken.getAuthorizationDetails(OID4VCAuthorizationDetailResponse.class);
if (authDetails == null)
throw new IllegalStateException("No authorization_details in token response");
if (authDetails.size() > 1)
@ -317,7 +318,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
}
}
private String getBearerToken(String clientId, String username, String scope, AuthorizationDetail... authDetail) {
private String getBearerToken(String clientId, String username, String scope, OID4VCAuthorizationDetail... authDetail) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
String authCode = getAuthorizationCode(oauth, client, username, scope);
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
@ -387,9 +388,8 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
return new AccessTokenResponse(accessTokenResponse);
}
private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, AuthorizationDetail authDetail) throws Exception {
@SuppressWarnings("unchecked")
List<String> credIdentifiers = (List<String>) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS);
private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, OID4VCAuthorizationDetailResponse authDetail) throws Exception {
List<String> credIdentifiers = authDetail.getCredentialIdentifiers();
var credentialRequest = new CredentialRequest();
if (credIdentifiers != null) {
if (credIdentifiers.size() > 1)
@ -481,4 +481,8 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
includeRoles.forEach(it -> assertTrue("Missing role: " + it, allRoles.contains(it)));
excludeRoles.forEach(it -> assertFalse("Invalid role: " + it, allRoles.contains(it)));
}
private OID4VCAuthorizationDetail convertToAuthzDetail(Object oid4vcDetails) throws IOException {
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(oid4vcDetails), OID4VCAuthorizationDetail.class);
}
}

View file

@ -38,13 +38,13 @@ import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
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.ErrorType;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientScopeRepresentation;
@ -55,7 +55,6 @@ import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
@ -157,7 +156,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
// ===== STEP 2: Second login - Regular SSO (should NOT return authorization_details) =====
// Second login WITHOUT OID4VCI scope and WITHOUT authorization_details.
oauth.client(client.getClientId());
oauth.client(client.getClientId(), "password");
oauth.scope(OAuth2Constants.SCOPE_OPENID);
oauth.openLoginForm();
@ -185,10 +184,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
}
// ===== STEP 3: Verify second token does NOT have authorization_details =====
Map<String, Object> secondTokenMap = JsonSerialization.readValue(JsonSerialization.writeValueAsString(secondTokenResponse), Map.class);
Object secondAuthDetailsObj = secondTokenMap.get(OAuth2Constants.AUTHORIZATION_DETAILS);
assertNull("Second token (regular SSO) should NOT have authorization_details", secondAuthDetailsObj);
assertNull("Second token (regular SSO) should NOT have authorization_details", secondTokenResponse.getAuthorizationDetails());
// ===== STEP 4: Verify second token cannot be used for credential requests =====
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
@ -238,6 +234,38 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier);
}
// Tests that when token is refreshed, the new access-token can be used as well for credential-request
@Test
public void testCompleteFlowWithClaimsValidationAuthorizationCode_refreshToken() throws Exception {
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
return credentialRequest;
};
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Perform authorization code flow to get authorization code
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
// Refresh token now
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponseRef = oauth.refreshRequest(tokenResponse.getRefreshToken()).send();
// TODO: Converting from one to the other... This is dummy and should be replaced once we start using "OAuthClient" in this test instead of hand-written HTTP requests...
AccessTokenResponse tokenResponse2 = new AccessTokenResponse();
tokenResponse2.setAuthorizationDetails(tokenResponseRef.getAuthorizationDetails());
tokenResponse2.setToken(tokenResponseRef.getAccessToken());
String credentialIdentifier = assertTokenResponse(tokenResponse2);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse2, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertSuccessfulCredentialResponse(credentialResponse);
}
}
// Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter
@Test
public void testCompleteFlow_mandatoryClaimsInAuthzDetailsParameter() throws Exception {
@ -292,6 +320,55 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
}
// Tests that Keycloak should use authorization_details from accessToken when processing mandatory claims
@Test
public void testCorrectAccessTokenUsed() throws Exception {
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
return credentialRequest;
};
Oid4vcTestContext ctx = prepareOid4vcTestContext();
// Update user to have missing "lastName"
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "john");
UserRepresentation userRep = user.toRepresentation();
// NOTE: Need to call both "setLastName" and set attributes to be able to set last name as null
userRep.setAttributes(Collections.emptyMap());
userRep.setLastName(null);
user.update(userRep);
try {
// Create token with authorization_details, which does not require "lastName" to be mandatory attribute
AccessTokenResponse tokenResponse = authzCodeFlow(ctx, Collections.emptyList(), false);
// Create another token with authorization_details, which require "lastName" to be mandatory attribute
AccessTokenResponse tokenResponseWithMandatoryLastName = authzCodeFlow(ctx, mandatoryLastNameClaimsSupplier(), true);
// Request with mandatory lastName will fail as user does not have "lastName"
String credentialIdentifier = assertTokenResponse(tokenResponseWithMandatoryLastName);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponseWithMandatoryLastName, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse_mandatoryClaimsMissing(credentialResponse);
}
// Request without mandatory lastName should work. Authorization_Details from accessToken will be used by Keycloak for processing this request
credentialIdentifier = assertTokenResponse(tokenResponse);
postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertSuccessfulCredentialResponse(credentialResponse);
}
} finally {
// Revert user changes and add lastName back
userRep.setLastName("Doe");
user.update(userRep);
}
}
// Test for the authorization_code flow with "mandatory" claim specified in the "authorization_details" parameter as well as
// mandatory claims in the protocol mappers configuration
@Test
@ -518,15 +595,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
// Successful authorization_code flow
private AccessTokenResponse authzCodeFlow(Oid4vcTestContext ctx) throws Exception {
// Perform authorization code flow to get authorization code
oauth.client(client.getClientId());
oauth.scope(getCredentialClientScope().getName()); // Add the credential scope
oauth.loginForm().doLogin("john", "password");
return authzCodeFlow(ctx, mandatoryLastNameClaimsSupplier(), false);
}
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
// Create authorization details with claims for token exchange
private List<ClaimsDescription> mandatoryLastNameClaimsSupplier() {
// Create authorization details with mandatory claims for "lastName" user attribute
ClaimsDescription claim = new ClaimsDescription();
// Construct claim path based on credential format
@ -538,14 +611,30 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
}
claim.setPath(claimPath);
claim.setMandatory(true);
return List.of(claim);
}
AuthorizationDetail authDetail = new AuthorizationDetail();
// Successful authorization_code flow
private AccessTokenResponse authzCodeFlow(Oid4vcTestContext ctx, List<ClaimsDescription> claimsForAuthorizationDetailsParameter, boolean expectUserAlreadyAuthenticated) throws Exception {
// Perform authorization code flow to get authorization code
oauth.client(client.getClientId(), "password");
oauth.scope(getCredentialClientScope().getName()); // Add the credential scope
if (expectUserAlreadyAuthenticated) {
oauth.openLoginForm();
} else {
oauth.loginForm().doLogin("john", "password");
}
String code = oauth.parseLoginResponse().getCode();
assertNotNull("Authorization code should not be null", code);
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(List.of(claim));
authDetail.setClaims(claimsForAuthorizationDetailsParameter);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Exchange authorization code for tokens with authorization_details
@ -570,11 +659,11 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
// Test successful token response. Returns "Credential identifier" of the VC credential
private String assertTokenResponse(AccessTokenResponse tokenResponse) throws Exception {
// Extract authorization_details from token response
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(tokenResponse);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
@ -664,23 +753,10 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
/**
* Parse authorization details from the token response.
*/
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, new TypeReference<>() {
});
Object authDetailsObj = responseMap.get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authDetailsObj == null) {
return Collections.emptyList();
}
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);
}
protected List<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(AccessTokenResponse tokenResponse) {
return tokenResponse.getAuthorizationDetails()
.stream()
.map(authzDetailsResponse -> authzDetailsResponse.asSubtype(OID4VCAuthorizationDetailResponse.class))
.toList();
}
}

View file

@ -29,12 +29,12 @@ import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
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.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.AccessTokenResponse;
@ -130,13 +130,13 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setClaims(List.of(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
@ -193,11 +193,11 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
}
// Step 4: Verify authorization_details is present in token response
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match", credentialConfigurationId, authDetailResponse.getCredentialConfigurationId());
@ -262,12 +262,12 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
// Step 1: Create PAR request with INVALID authorization_details
// Create authorization details with INVALID credential configuration ID
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId("INVALID_CONFIG_ID"); // This should cause failure
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
// Create PAR request
@ -378,7 +378,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
}
// Step 4: Verify NO authorization_details in token response (since none was in PAR request)
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(JsonSerialization.writeValueAsString(tokenResponse));
assertTrue("authorization_details should NOT be present in the response when not used in PAR request",
authDetailsResponse == null || authDetailsResponse.isEmpty());
}
@ -395,7 +395,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
/**
* Parse authorization details from the token response.
*/
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) {
protected List<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, Map.class);
@ -407,7 +407,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailsResponse>>() {
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
@ -34,18 +35,19 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
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.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.util.JsonSerialization;
@ -176,12 +178,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -195,10 +197,10 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
@ -234,13 +236,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -254,10 +256,10 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getClaims());
@ -289,13 +291,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(Arrays.asList("credentialSubject", "unsupportedClaim"));
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -308,10 +310,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
// Should fail because the claim is not supported by the credential configuration
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, unsupportedClaim]. This claim is not supported by the credential configuration.");
}
}
@ -325,13 +324,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(Arrays.asList("credentialSubject", "mandatoryClaim"));
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -343,11 +342,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
// Should fail because the mandatory claim is not supported
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, mandatoryClaim]. This claim is not supported by the credential configuration.");
}
}
@ -361,13 +356,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(Arrays.asList("credentialSubject", "address", "street"));
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -382,14 +377,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Should fail if the complex path is not supported
int statusCode = tokenResponse.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_BAD_REQUEST) {
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Unsupported claim: [credentialSubject, address, street]. This claim is not supported by the credential configuration.");
} else {
// If it succeeds, verify the response structure
assertEquals(HttpStatus.SC_OK, statusCode);
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
}
@ -401,12 +394,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
// Missing credential_configuration_id - should fail
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -418,13 +411,24 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: credential_configuration_id is required");
}
}
private void assertInvalidAuthzDetailsError(CloseableHttpResponse tokenResponse, String expectedErrorDescription) throws IOException {
events.expectCodeToToken(null, null)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.clearDetails()
.detail(Details.REASON, expectedErrorDescription)
.error(Errors.INVALID_AUTHORIZATION_DETAILS)
.assertEvent();
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(tokenResponse.getEntity().getContent(), OAuth2ErrorRepresentation.class);
assertEquals(Errors.INVALID_AUTHORIZATION_DETAILS, errorRep.getError());
assertEquals(expectedErrorDescription, errorRep.getErrorDescription());
}
@Test
public void testPreAuthorizedCodeWithInvalidClaims() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
@ -435,13 +439,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(null); // Invalid: null path
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -453,10 +457,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
assertInvalidAuthzDetailsError(tokenResponse, "Invalid authorization_details: Invalid claims description: path is required");
}
}
@ -465,7 +466,8 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Send empty authorization_details array - should fail
// Send empty authorization_details array - should work the same way like request without "authorization_details" parameter tested in testPreAuthorizedCodeWithCredentialOfferBasedAuthorizationDetails
// There is no processor available for empty authorization_details and hence considered as missing "authorization_details"
String authDetailsJson = "[]";
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -477,10 +479,10 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusLine().getStatusCode());
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
assertTrue("Error message should indicate authorization_details processing error",
responseBody.contains("Error when processing authorization_details"));
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
}
}
@ -504,7 +506,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals("Should have authorization_details for each credential configuration in the offer",
ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size());
@ -512,7 +514,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Verify each credential configuration from the offer has corresponding authorization_details
for (int i = 0; i < ctx.credentialsOffer.getCredentialConfigurationIds().size(); i++) {
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(i);
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(i);
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match the one from the offer",
@ -550,12 +552,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
OID4VCAuthorizationDetailResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals("Should have authorization_details for each credential configuration in the offer",
ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size());
@ -700,7 +702,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
// Verify that we have authorization_details for each credential configuration in the offer
@ -709,7 +711,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Verify each authorization detail
for (int i = 0; i < authDetailsResponse.size(); i++) {
OID4VCAuthorizationDetailsResponse authDetail = authDetailsResponse.get(i);
OID4VCAuthorizationDetailResponse authDetail = authDetailsResponse.get(i);
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
// Verify structure
@ -760,13 +762,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setPath(claimPath);
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
authDetail.setClaims(List.of(claim));
List<AuthorizationDetail> authDetails = List.of(authDetail);
List<OID4VCAuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.valueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
@ -779,11 +781,11 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
OID4VCAuthorizationDetailResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
List<OID4VCAuthorizationDetailsResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = parseAuthorizationDetails(responseBody);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
@ -915,7 +917,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
/**
* Parse authorization details from the token response.
*/
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) {
protected List<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(String responseBody) {
try {
// Parse the JSON response to extract authorization_details
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, Map.class);
@ -927,7 +929,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Convert to list of OID4VCAuthorizationDetailsResponse
return JsonSerialization.readValue(JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailsResponse>>() {
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse authorization_details from response", e);

View file

@ -72,7 +72,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
@ -762,14 +762,14 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
}
}
protected List<OID4VCAuthorizationDetailsResponse> parseAuthorizationDetails(String responseBody) throws IOException {
protected List<OID4VCAuthorizationDetailResponse> parseAuthorizationDetails(String responseBody) throws IOException {
Map<String, Object> responseMap = JsonSerialization.readValue(responseBody, new TypeReference<Map<String, Object>>() {
});
Object authDetailsObj = responseMap.get("authorization_details");
assertNotNull("authorization_details should be present in the response", authDetailsObj);
return JsonSerialization.readValue(
JsonSerialization.writeValueAsString(authDetailsObj),
new TypeReference<List<OID4VCAuthorizationDetailsResponse>>() {
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
}
);
}

View file

@ -67,23 +67,25 @@ import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AuthorizationDetailsResponse;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation;
@ -105,7 +107,9 @@ import org.apache.http.HttpStatus;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jboss.logging.Logger;
import org.junit.Assert;
import org.junit.BeforeClass;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest.TIME_PROVIDER;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getJtiGeneratedIdMapper;
@ -137,6 +141,11 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json";
@BeforeClass
public static void beforeClass() {
AuthorizationDetailsResponse.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
}
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
CredentialSubject credentialSubject = new CredentialSubject();
claims.forEach(credentialSubject::setClaims);
@ -468,7 +477,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return getBearerTokenCodeFlow(oauthClient, client, username, scope).getAccessToken();
}
protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, AuthorizationDetail... authDetail) {
protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, OID4VCAuthorizationDetail... authDetail) {
AccessTokenRequest accessTokenRequest = oauthClient.accessTokenRequest(authCode);
if (authDetail != null) {
accessTokenRequest.authorizationDetails(Arrays.asList(authDetail));