mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
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:
parent
17a2678438
commit
416a6017c2
34 changed files with 825 additions and 576 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>>() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue