mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
[OID4VCI] CredentialEndpoint can be invoked with incorrect access token (#45816)
closes #44670 closes #44580 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
parent
9462f0f00b
commit
3adcca44a7
20 changed files with 1030 additions and 258 deletions
|
|
@ -38,6 +38,7 @@ import org.keycloak.models.KeycloakSession;
|
|||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
|
||||
/**
|
||||
|
|
@ -70,6 +71,22 @@ public interface OAuth2GrantType extends Provider {
|
|||
*/
|
||||
Response process(Context context);
|
||||
|
||||
/**
|
||||
* Check if the token issued from this grant type is allowed for the current request.
|
||||
* This allows grant types to restrict token usage to specific endpoints or contexts.
|
||||
* The default implementation returns {@code true}, meaning tokens are allowed at all endpoints.
|
||||
* Grant types that need to restrict token usage (e.g., pre-authorized code tokens that should
|
||||
* only be accepted at the credential endpoint) should override this method to implement
|
||||
* specific endpoint restrictions.
|
||||
*
|
||||
* @param session the Keycloak session
|
||||
* @param token the access token
|
||||
* @return true if the token is allowed for the current request, false otherwise
|
||||
*/
|
||||
default boolean isTokenAllowed(KeycloakSession session, AccessToken token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class Context {
|
||||
protected KeycloakSession session;
|
||||
protected RealmModel realm;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.keycloak.protocol.oid4vc.issuance;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
|
||||
|
|
@ -53,4 +54,18 @@ public class OID4VCAuthorizationDetailResponse extends OID4VCAuthorizationDetail
|
|||
", claims=" + getClaims() +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
if (!super.equals(o)) return false;
|
||||
OID4VCAuthorizationDetailResponse that = (OID4VCAuthorizationDetailResponse) o;
|
||||
return Objects.equals(credentialIdentifiers, that.credentialIdentifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(super.hashCode(), credentialIdentifiers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -767,20 +767,19 @@ public class OID4VCIssuerEndpoint {
|
|||
// checkClientEnabled call after authentication
|
||||
checkClientEnabled();
|
||||
|
||||
// Both credential_configuration_id and credential_identifier are optional.
|
||||
// If the credential_configuration_id is present, credential_identifier can't be present.
|
||||
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
|
||||
// This implementation will privilege the presence of credential_identifier.
|
||||
// Per OID4VCI specification, credential_identifier is required when authorization_details are present.
|
||||
// Since both pre-authorized and authorization code flows always include credential_identifiers
|
||||
// in authorization_details, we always require credential_identifier in the credential request.
|
||||
|
||||
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
|
||||
String credentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
|
||||
|
||||
// Check if at least one of both is available.
|
||||
if (credentialIdentifier == null && credentialConfigurationId == null) {
|
||||
String errorMessage = "Missing both credential_configuration_id and credential_identifier. At least one must be specified.";
|
||||
// credential_identifier is required
|
||||
if (credentialIdentifier == null) {
|
||||
String errorMessage = "Missing credential_identifier in credential request. " +
|
||||
"Per OID4VCI specification, credential_identifier must be used when authorization_details are present.";
|
||||
LOGGER.debugf(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
CredentialScopeModel requestedCredential;
|
||||
|
|
@ -789,97 +788,100 @@ public class OID4VCIssuerEndpoint {
|
|||
// When the CredentialRequest contains a credential identifier the caller must have gone through the
|
||||
// CredentialOffer process or otherwise have set up a valid CredentialOfferState
|
||||
|
||||
if (credentialIdentifier != null) {
|
||||
AccessToken accessToken = authResult.token();
|
||||
|
||||
// First check if the credential identifier exists
|
||||
// This allows proper error reporting for unknown identifiers
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
|
||||
if (offerState == null) {
|
||||
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
|
||||
}
|
||||
|
||||
// Get the credential_configuration_id from AuthorizationDetails
|
||||
// First check if offer state has authorization_details (for pre-authorized flows)
|
||||
authDetails = offerState.getAuthorizationDetails();
|
||||
|
||||
// Validate authorization_details: either in token or in offer state
|
||||
// For pre-authorized flows, offer state is the source of truth
|
||||
// For authorization code flows, token must contain authorization_details
|
||||
if (authDetails == null) {
|
||||
authDetails = getAuthorizationDetailFromToken(authResult.token());
|
||||
}
|
||||
|
||||
if (authDetails == null) {
|
||||
var errorMessage = "No authorization_details found in offer state or token";
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
String credConfigId = authDetails.getCredentialConfigurationId();
|
||||
if (credConfigId == null) {
|
||||
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
// Find the credential configuration in the Issuer's metadata
|
||||
//
|
||||
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
|
||||
if (credConfig == null) {
|
||||
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the user login session
|
||||
//
|
||||
if (!userModel.getId().equals(offerState.getUserId())) {
|
||||
var errorMessage = "Unexpected login user: " + userModel.getUsername();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the login client
|
||||
//
|
||||
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
|
||||
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Find the configured scope in the login client
|
||||
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
|
||||
if (clientScope == null) {
|
||||
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
requestedCredential = new CredentialScopeModel(clientScope);
|
||||
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
|
||||
|
||||
} else if (credentialConfigurationId != null) {
|
||||
// Use credential_configuration_id for direct lookup
|
||||
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
|
||||
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
});
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
|
||||
|
||||
authDetails = getAuthorizationDetailFromToken(authResult.token()); // Get authorization_details always from token for this case
|
||||
} else {
|
||||
// Neither provided - this should not happen due to earlier validation
|
||||
String errorMessage = "Missing both credential_configuration_id and credential_identifier";
|
||||
// First check if the credential identifier exists
|
||||
// This allows proper error reporting for unknown identifiers
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
|
||||
if (offerState == null) {
|
||||
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
|
||||
}
|
||||
|
||||
// Get the credential_configuration_id from the offer state authorization details
|
||||
authDetails = offerState.getAuthorizationDetails();
|
||||
|
||||
// Validate authorization_details: either in token or in offer state
|
||||
// For pre-authorized flows, offer state is the source of truth
|
||||
// For authorization code flows, token must contain authorization_details
|
||||
if (authDetails == null) {
|
||||
authDetails = getAuthorizationDetailFromToken(accessToken);
|
||||
}
|
||||
|
||||
if (authDetails == null) {
|
||||
var errorMessage = "No authorization_details found in offer state or token";
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Validate that authorization_details from the token matches the offer state
|
||||
// This ensures the correct access token is being used for the credential request
|
||||
OID4VCAuthorizationDetailResponse tokenAuthDetails = getAuthorizationDetailFromToken(accessToken);
|
||||
if (tokenAuthDetails != null && !tokenAuthDetails.equals(authDetails)) {
|
||||
var errorMessage = "Authorization details in access token do not match the credential offer state. " +
|
||||
"The access token may not be the one issued for this credential offer.";
|
||||
LOGGER.debugf(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_TOKEN);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Validate that the credential_identifier in the request matches one in the authorization_details
|
||||
List<String> credentialIdentifiers = authDetails.getCredentialIdentifiers();
|
||||
if (credentialIdentifiers == null || !credentialIdentifiers.contains(credentialIdentifier)) {
|
||||
var errorMessage = "Credential identifier '" + credentialIdentifier + "' not found in authorization_details. " +
|
||||
"The credential_identifier must match one from the authorization_details in the token.";
|
||||
LOGGER.debugf(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
|
||||
}
|
||||
|
||||
String credConfigId = authDetails.getCredentialConfigurationId();
|
||||
if (credConfigId == null) {
|
||||
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
// Find the credential configuration in the Issuer's metadata
|
||||
//
|
||||
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
|
||||
if (credConfig == null) {
|
||||
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the user login session
|
||||
//
|
||||
if (!userModel.getId().equals(offerState.getUserId())) {
|
||||
var errorMessage = "Unexpected login user: " + userModel.getUsername();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the login client
|
||||
//
|
||||
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
|
||||
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Find the configured scope in the login client
|
||||
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
|
||||
if (clientScope == null) {
|
||||
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
requestedCredential = new CredentialScopeModel(clientScope);
|
||||
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
|
||||
|
||||
checkScope(requestedCredential);
|
||||
|
||||
SupportedCredentialConfiguration supportedCredential =
|
||||
|
|
@ -1496,18 +1498,21 @@ public class OID4VCIssuerEndpoint {
|
|||
return new CredentialScopeModel(clientScopeModel);
|
||||
}
|
||||
|
||||
private Response getErrorResponse(ErrorType errorType) {
|
||||
return getErrorResponse(errorType, null);
|
||||
}
|
||||
|
||||
private Response getErrorResponse(ErrorType errorType, String errorDescription) {
|
||||
private Response.ResponseBuilder getErrorResponseBuilder(ErrorType errorType, String errorDescription) {
|
||||
var errorResponse = new ErrorResponse();
|
||||
errorResponse.setError(errorType).setErrorDescription(errorDescription);
|
||||
return Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.entity(errorResponse)
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.build();
|
||||
.type(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
private Response getErrorResponse(ErrorType errorType) {
|
||||
return getErrorResponseBuilder(errorType, null).build();
|
||||
}
|
||||
|
||||
private Response getErrorResponse(ErrorType errorType, String errorDescription) {
|
||||
return getErrorResponseBuilder(errorType, errorDescription).build();
|
||||
}
|
||||
|
||||
// builds the unsigned credential by applying all protocol mappers.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
|
|
@ -68,4 +69,17 @@ public class ClaimsDescription {
|
|||
public boolean isMandatory() {
|
||||
return mandatory != null ? mandatory : false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ClaimsDescription that = (ClaimsDescription) o;
|
||||
return Objects.equals(path, that.path) && Objects.equals(mandatory, that.mandatory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(path, mandatory);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
|
||||
|
||||
|
|
@ -64,4 +65,19 @@ public class OID4VCAuthorizationDetail extends AuthorizationDetailsJSONRepresent
|
|||
", claims=" + claims +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
if (!super.equals(o)) return false;
|
||||
OID4VCAuthorizationDetail that = (OID4VCAuthorizationDetail) o;
|
||||
return Objects.equals(credentialConfigurationId, that.credentialConfigurationId)
|
||||
&& Objects.equals(claims, that.claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(super.hashCode(), credentialConfigurationId, claims);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1232,7 +1232,6 @@ public class TokenManager {
|
|||
.map(ClientModel::getClientId)
|
||||
.collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
Boolean bindOnlyRefreshToken = session.getAttributeOrDefault(DPoPUtil.DPOP_BINDING_ONLY_REFRESH_TOKEN_SESSION_ATTRIBUTE, false);
|
||||
if (bindOnlyRefreshToken) {
|
||||
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
|
|||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
|
||||
|
|
@ -138,12 +140,24 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
|
|||
event.client(clientModel)
|
||||
.user(userModel);
|
||||
|
||||
// Check if authorization_details parameter was explicitly provided
|
||||
String authorizationDetailsParam = formParams.getFirst(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
|
||||
// Validate empty authorization_details - if parameter is provided but empty, reject it
|
||||
if (authorizationDetailsParam != null && (authorizationDetailsParam.trim().isEmpty() || "[]".equals(authorizationDetailsParam.trim()))) {
|
||||
var errorMessage = "Invalid authorization_details: parameter cannot be empty";
|
||||
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
|
||||
errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Process authorization_details using provider discovery
|
||||
List<AuthorizationDetailsJSONRepresentation> authorizationDetailsResponses = processAuthorizationDetails(userSession, sessionContext);
|
||||
LOGGER.debugf("Initial authorization_details processing result: %s", authorizationDetailsResponses);
|
||||
|
||||
// If no authorization_details were processed from the request, try to generate them from credential offer
|
||||
if (authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) {
|
||||
// (only if authorization_details parameter was not explicitly provided)
|
||||
if ((authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) && authorizationDetailsParam == null) {
|
||||
authorizationDetailsResponses = handleMissingAuthorizationDetails(userSession, sessionContext);
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +184,10 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
|
|||
|
||||
accessToken.setAuthorizationDetails(authorizationDetailsResponses);
|
||||
|
||||
// Set audience to credential endpoint for pre-authorized tokens
|
||||
String credentialEndpoint = OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
|
||||
accessToken.audience(credentialEndpoint);
|
||||
|
||||
AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(
|
||||
clientSession.getRealm(),
|
||||
clientSession.getClient(),
|
||||
|
|
@ -199,4 +217,26 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
|
|||
public EventType getEventType() {
|
||||
return EventType.CODE_TO_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict pre-authorized tokens to the VC credential endpoint.
|
||||
*/
|
||||
@Override
|
||||
public boolean isTokenAllowed(KeycloakSession session, AccessToken token) {
|
||||
// Check if the request path ends with the credential endpoint path
|
||||
boolean isCredentialEndpoint = Optional.ofNullable(session.getContext().getUri())
|
||||
.map(uri -> uri.getPath())
|
||||
.map(path -> path.endsWith("/" + OID4VCIssuerEndpoint.CREDENTIAL_PATH))
|
||||
.orElse(false);
|
||||
|
||||
if (!isCredentialEndpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token has exactly one audience and it matches the credential endpoint
|
||||
// Being strict about audience prevents potential security issues with multi-audience tokens
|
||||
String expectedAudience = OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
|
||||
String[] audiences = token.getAudience();
|
||||
return audiences != null && audiences.length == 1 && expectedAudience.equals(audiences[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import org.keycloak.models.RealmModel;
|
|||
import org.keycloak.services.util.DPoPUtil;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
|
@ -129,6 +131,8 @@ public class AppAuthManager extends AuthenticationManager {
|
|||
}
|
||||
|
||||
public static class BearerTokenAuthenticator {
|
||||
private static final Logger logger = Logger.getLogger(BearerTokenAuthenticator.class);
|
||||
|
||||
private KeycloakSession session;
|
||||
private RealmModel realm;
|
||||
private UriInfo uriInfo;
|
||||
|
|
@ -193,7 +197,10 @@ public class AppAuthManager extends AuthenticationManager {
|
|||
// audience can be null
|
||||
|
||||
return verifyIdentityToken(session, realm, uriInfo, connection, true, true, audience, false, tokenString, headers,
|
||||
verifier -> DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString)));
|
||||
verifier -> {
|
||||
DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString));
|
||||
verifier.withChecks(GrantTypeEndpointRestrictionValidator.check(session));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
package org.keycloak.services.managers;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
|
||||
import org.keycloak.protocol.oidc.encode.DefaultTokenContextEncoderProvider;
|
||||
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
|
||||
import org.keycloak.protocol.oidc.grants.OAuth2GrantType;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.services.ErrorResponseException;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Validates that tokens are only used on endpoints allowed by their grant type.
|
||||
* This ensures Pre-Authorized Code tokens are restricted to the credential endpoint,
|
||||
* and other grant types only access their intended endpoints.
|
||||
*/
|
||||
public class GrantTypeEndpointRestrictionValidator implements TokenVerifier.Predicate<AccessToken> {
|
||||
private static final Logger logger = Logger.getLogger(GrantTypeEndpointRestrictionValidator.class);
|
||||
|
||||
private final KeycloakSession session;
|
||||
|
||||
private GrantTypeEndpointRestrictionValidator(KeycloakSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TokenVerifier.Predicate for grant type endpoint restriction validation.
|
||||
* Can be used with TokenVerifier.withChecks() for inline verification.
|
||||
*
|
||||
* @param session The Keycloak session
|
||||
* @return A predicate that validates grant type restrictions
|
||||
*/
|
||||
public static TokenVerifier.Predicate<AccessToken> check(KeycloakSession session) {
|
||||
return new GrantTypeEndpointRestrictionValidator(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(AccessToken token) throws VerificationException {
|
||||
validate(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the token is allowed for the current endpoint based on its grant type.
|
||||
*
|
||||
* @param token The access token to validate
|
||||
* @throws VerificationException if token validation fails
|
||||
* @throws ErrorResponseException if server configuration is broken
|
||||
*/
|
||||
private void validate(AccessToken token) throws VerificationException {
|
||||
try {
|
||||
// Get the grant type from the token
|
||||
String grantType = recoverGrantType(token);
|
||||
|
||||
// If no specific grant type, allow the token for backward compatibility
|
||||
if (grantType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the grant type provider to verify endpoint restrictions
|
||||
OAuth2GrantType grantTypeProvider = session.getProvider(OAuth2GrantType.class, grantType);
|
||||
if (grantTypeProvider == null) {
|
||||
// This is a server configuration error - grant type provider not registered
|
||||
logger.errorf("Grant type restriction provider not available for: %s - server misconfiguration", grantType);
|
||||
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR,
|
||||
"Internal error: grant type restriction provider not available", Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (!grantTypeProvider.isTokenAllowed(session, token)) {
|
||||
throw new VerificationException("Token is not allowed for this endpoint. Grant type: " + grantType);
|
||||
}
|
||||
} catch (ErrorResponseException | VerificationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.errorf(e, "Error checking grant type restriction");
|
||||
throw new VerificationException("Error verifying grant type restrictions: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the grant type from the token context.
|
||||
* Handles legacy tokens and various token formats gracefully.
|
||||
*
|
||||
* @param token The access token to extract grant type from
|
||||
* @return The grant type, or null if no specific grant type is assigned
|
||||
* @throws VerificationException if token context is invalid
|
||||
* @throws ErrorResponseException if server configuration is broken
|
||||
*/
|
||||
private String recoverGrantType(AccessToken token) throws VerificationException {
|
||||
TokenContextEncoderProvider encoder = session.getProvider(TokenContextEncoderProvider.class);
|
||||
if (encoder == null) {
|
||||
logger.error("Token context encoder provider not available - server misconfiguration");
|
||||
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR,
|
||||
"Internal error: token context encoder not available", Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
AccessTokenContext tokenContext;
|
||||
try {
|
||||
tokenContext = encoder.getTokenContextFromTokenId(token.getId());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Token ID format is invalid or unknown - treat as legacy token
|
||||
logger.debugf("Cannot decode token context from token ID, treating as legacy token: %s", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tokenContext == null) {
|
||||
throw new VerificationException("Invalid token context");
|
||||
}
|
||||
|
||||
String grantType = tokenContext.getGrantType();
|
||||
if (grantType == null || grantType.isEmpty() || DefaultTokenContextEncoderProvider.UNKNOWN.equals(grantType)) {
|
||||
// Standard Keycloak token without specific grant-type context.
|
||||
// We allow these to maintain backward compatibility with standard OIDC flows.
|
||||
return null;
|
||||
}
|
||||
|
||||
return grantType;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import java.util.Arrays;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
|
|
@ -49,6 +50,7 @@ import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
|
|||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.apache.directory.api.util.Strings;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.junit.Test;
|
||||
|
|
@ -128,7 +130,6 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
@Test
|
||||
public void testCredentialWithoutOffer() throws Exception {
|
||||
|
||||
var ctx = newTestContext(false, null, appUsername);
|
||||
|
||||
OID4VCAuthorizationDetailResponse authDetail = new OID4VCAuthorizationDetailResponse();
|
||||
|
|
@ -140,7 +141,31 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
|||
// https://github.com/keycloak/keycloak/issues/44320
|
||||
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, convertToAuthzDetail(authDetail));
|
||||
|
||||
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail);
|
||||
// Extract credential_identifier from the access token's authorization_details
|
||||
JsonWebToken tokenDecoded = new JWSInput(accessToken).readJsonContent(JsonWebToken.class);
|
||||
Object tokenAuthDetails = tokenDecoded.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
assertNotNull("authorization_details not found in access token", tokenAuthDetails);
|
||||
|
||||
// When authorization_details are sent in token request, they are returned in token response with credential_identifiers
|
||||
// The credential request MUST use credential_identifier (not credential_configuration_id)
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = JsonSerialization.readValue(
|
||||
JsonSerialization.writeValueAsString(tokenAuthDetails),
|
||||
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
|
||||
}
|
||||
);
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
|
||||
OID4VCAuthorizationDetailResponse authDetailResponse = authDetailsResponse.get(0);
|
||||
List<String> credentialIdentifiers = authDetailResponse.getCredentialIdentifiers();
|
||||
assertNotNull("credential_identifiers should be present", credentialIdentifiers);
|
||||
assertFalse("credential_identifiers should not be empty", credentialIdentifiers.isEmpty());
|
||||
String credentialIdentifier = credentialIdentifiers.get(0);
|
||||
|
||||
var credentialRequest = new CredentialRequest();
|
||||
credentialRequest.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
CredentialResponse credResponse = sendCredentialRequest(ctx, accessToken, credentialRequest);
|
||||
verifyCredentialResponse(ctx, credResponse);
|
||||
}
|
||||
|
||||
|
|
@ -283,11 +308,12 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
|||
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
|
||||
String scope = credConfig.getScope();
|
||||
|
||||
String accessToken = getBearerToken(clientId, userId, scope);
|
||||
AccessTokenResponse tokenResponse = getBearerTokenResponse(clientId, userId, scope);
|
||||
String accessToken = tokenResponse.getAccessToken();
|
||||
|
||||
// Get the credential and verify
|
||||
//
|
||||
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, credOffer);
|
||||
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, tokenResponse, credOffer);
|
||||
verifyCredentialResponse(ctx, credResponse);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -299,13 +325,59 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
// Private ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private String getBearerToken(String clientId, String username, String scope) {
|
||||
private AccessTokenResponse getBearerTokenResponse(String clientId, String username, String scope) {
|
||||
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
|
||||
if (client.isDirectAccessGrantsEnabled()) {
|
||||
return getBearerTokenDirectAccess(oauth, client, username, scope).getAccessToken();
|
||||
} else {
|
||||
return getBearerTokenCodeFlow(oauth, client, username, scope).getAccessToken();
|
||||
|
||||
// For credential scopes, we need to request authorization_details to get credential_identifier
|
||||
if (scope != null && scope.equals(credScopeName)) {
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(getCredentialIssuerMetadata().getCredentialIssuer()));
|
||||
|
||||
// Set the redirect URI from the client's configuration
|
||||
if (client.getRedirectUris() != null && !client.getRedirectUris().isEmpty()) {
|
||||
oauth.redirectUri(client.getRedirectUris().get(0));
|
||||
}
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, username, scope);
|
||||
return getBearerToken(oauth, authCode, authDetail);
|
||||
}
|
||||
|
||||
// For non-credential scopes, use the appropriate flow based on client configuration
|
||||
if (client.isDirectAccessGrantsEnabled()) {
|
||||
return getBearerTokenDirectAccess(oauth, client, username, scope);
|
||||
} else {
|
||||
return getBearerTokenCodeFlow(oauth, client, username, scope);
|
||||
}
|
||||
}
|
||||
|
||||
private List<OID4VCAuthorizationDetailResponse> extractAuthorizationDetails(AccessTokenResponse tokenResponse) {
|
||||
// First check if already populated in token response
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
|
||||
return authDetailsResponse;
|
||||
}
|
||||
|
||||
// Otherwise, extract from JWT access token
|
||||
try {
|
||||
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
|
||||
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (authDetails != null) {
|
||||
return JsonSerialization.readValue(
|
||||
JsonSerialization.writeValueAsString(authDetails),
|
||||
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore - authorization_details not present or couldn't be parsed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getBearerToken(String clientId, String username, String scope) {
|
||||
return getBearerTokenResponse(clientId, username, scope).getAccessToken();
|
||||
}
|
||||
|
||||
private String getBearerToken(String clientId, String username, String scope, OID4VCAuthorizationDetail... authDetail) {
|
||||
|
|
@ -402,12 +474,29 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
|||
return sendCredentialRequest(ctx, accessToken, credentialRequest);
|
||||
}
|
||||
|
||||
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, CredentialsOffer credOffer) throws Exception {
|
||||
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, AccessTokenResponse tokenResponse, CredentialsOffer credOffer) throws Exception {
|
||||
List<String> credConfigIds = credOffer.getCredentialConfigurationIds();
|
||||
if (credConfigIds.size() > 1)
|
||||
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
|
||||
var credentialRequest = new CredentialRequest();
|
||||
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
|
||||
|
||||
// Extract authorization_details (from token response or JWT)
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = extractAuthorizationDetails(tokenResponse);
|
||||
|
||||
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
|
||||
// If authorization_details are present, credential_identifier is required
|
||||
if (authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
|
||||
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
credentialRequest.setCredentialIdentifier(credentialIdentifier);
|
||||
} else {
|
||||
throw new IllegalStateException("authorization_details present but no credential_identifier found");
|
||||
}
|
||||
} else {
|
||||
// No authorization_details, use credential_configuration_id
|
||||
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
|
||||
}
|
||||
|
||||
return sendCredentialRequest(ctx, accessToken, credentialRequest);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.keycloak.jose.jws.JWSBuilder;
|
|||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
|
||||
|
|
@ -24,19 +25,25 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidato
|
|||
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidatorFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.Proofs;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
|
@ -138,7 +145,20 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
|
|||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String configIdFromScope = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String credConfigId = configIdFromScope != null ? configIdFromScope : scopeName;
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
String cNonce = getCNonce();
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
|
|
@ -160,8 +180,8 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
|
|||
Proofs proofs = new Proofs().setAttestation(List.of(attestationJwt));
|
||||
|
||||
CredentialRequest request = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setProofs(proofs);
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setProofs(proofs);
|
||||
|
||||
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
|
@ -47,8 +48,14 @@ import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
|
|||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.http.HttpHeaders;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
|
@ -58,6 +65,7 @@ import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
|||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Base class for authorization code flow tests with authorization details and claims validation.
|
||||
|
|
@ -182,15 +190,38 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
|
|||
}
|
||||
|
||||
// Test for the whole authorization_code flow with the credentialRequest using credential_configuration_id
|
||||
// Note: When authorization_details are present in the token, credential_identifier must be used instead
|
||||
// This test verifies that using credential_configuration_id fails when authorization_details are present
|
||||
@Test
|
||||
public void testCompleteFlowWithClaimsValidationAuthorizationCode_credentialRequestWithConfigurationId() throws Exception {
|
||||
BiFunction<String, String, CredentialRequest> credRequestSupplier = (credentialConfigurationId, credentialIdentifier) -> {
|
||||
CredentialRequest credentialRequest = new CredentialRequest();
|
||||
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
|
||||
return credentialRequest;
|
||||
};
|
||||
Oid4vcTestContext ctx = prepareOid4vcTestContext();
|
||||
|
||||
testCompleteFlowWithClaimsValidationAuthorizationCode(credRequestSupplier);
|
||||
// Perform authorization code flow to get authorization code (includes authorization_details)
|
||||
AccessTokenResponse tokenResponse = authzCodeFlow(ctx);
|
||||
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
|
||||
// Clear events before credential request
|
||||
events.clear();
|
||||
|
||||
// Request the credential using credential_configuration_id (should fail when authorization_details are present)
|
||||
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
|
||||
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getAccessToken());
|
||||
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest();
|
||||
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
|
||||
|
||||
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
|
||||
|
||||
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
|
||||
assertEquals("Using credential_configuration_id with token that has authorization_details should fail",
|
||||
HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
|
||||
|
||||
String errorBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
assertTrue("Error should indicate that credential_identifier must be used. Actual error: " + errorBody,
|
||||
errorBody.contains("credential_identifier") || errorBody.contains("authorization_details"));
|
||||
}
|
||||
}
|
||||
|
||||
// Test for the whole authorization_code flow with the credentialRequest using credential_identifier
|
||||
|
|
|
|||
|
|
@ -184,11 +184,12 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
|
|||
}
|
||||
|
||||
// Step 5: Request the actual credential using the identifier
|
||||
// When authorization_details are present in the token, credential_identifier must be used
|
||||
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc()
|
||||
.credentialRequest()
|
||||
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
|
||||
.bearerToken(tokenResponse.getAccessToken())
|
||||
.credentialConfigurationId(credentialConfigurationId)
|
||||
.credentialIdentifier(credentialIdentifier)
|
||||
.send();
|
||||
|
||||
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
|||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.util.ClientManager;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.testsuite.util.oauth.OAuthClient;
|
||||
import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
|
||||
|
|
@ -47,7 +49,11 @@ import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
|
|||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import org.apache.http.HttpHeaders;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
|
|
@ -70,6 +76,12 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Before
|
||||
public void enableDirectAccessGrants() {
|
||||
// Enable direct access grants for test-app client to allow password grant
|
||||
ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true);
|
||||
}
|
||||
|
||||
protected static class Oid4vcTestContext {
|
||||
CredentialsOffer credentialsOffer;
|
||||
CredentialIssuer credentialIssuer;
|
||||
|
|
@ -379,7 +391,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
assertEquals("invalid_authorization_details", tokenResponse.getError());
|
||||
assertTrue("Error description should indicate missing credential_configuration_id. Actual: " + tokenResponse.getErrorDescription(),
|
||||
tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details")
|
||||
&& tokenResponse.getErrorDescription().contains("credential_configuration_id is required"));
|
||||
&& tokenResponse.getErrorDescription().contains("credential_configuration_id is required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -411,7 +423,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
assertEquals("invalid_authorization_details", tokenResponse.getError());
|
||||
assertTrue("Error description should indicate invalid claims path. Actual: " + tokenResponse.getErrorDescription(),
|
||||
tokenResponse.getErrorDescription() != null && tokenResponse.getErrorDescription().contains("Invalid authorization_details")
|
||||
&& tokenResponse.getErrorDescription().contains("path is required"));
|
||||
&& tokenResponse.getErrorDescription().contains("path is required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -429,7 +441,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
.send();
|
||||
|
||||
assertEquals(HttpStatus.SC_BAD_REQUEST, tokenResponse.getStatusCode());
|
||||
assertEquals("invalid_authorization_details", tokenResponse.getError());
|
||||
assertEquals("invalid_request", tokenResponse.getError());
|
||||
assertNotNull("Error description should be present", tokenResponse.getErrorDescription());
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +538,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
|
||||
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest()
|
||||
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
|
||||
.bearerToken(token)
|
||||
.bearerToken(tokenResponse.getAccessToken())
|
||||
.credentialIdentifier(credentialIdentifier)
|
||||
.send();
|
||||
|
||||
|
|
@ -558,48 +570,90 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
|
|||
// Verify the credential structure based on format
|
||||
verifyCredentialStructure(credentialObj);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreAuthorizedCodeTokenEndpointRestriction() throws Exception {
|
||||
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
|
||||
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
|
||||
PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode();
|
||||
|
||||
// Step 3: Request a credential using the credentialConfigurationId
|
||||
//
|
||||
{
|
||||
// Clear events before credential request
|
||||
events.clear();
|
||||
// Step 1: Get pre-authorized code token
|
||||
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
|
||||
.preAuthorizedCodeGrantRequest(preAuthorizedCode.getPreAuthorizedCode())
|
||||
.endpoint(ctx.openidConfig.getTokenEndpoint())
|
||||
.send();
|
||||
|
||||
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest()
|
||||
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
|
||||
.bearerToken(token)
|
||||
.credentialConfigurationId(credentialConfigurationId)
|
||||
.send();
|
||||
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
|
||||
String preAuthorizedToken = accessTokenResponse.getAccessToken();
|
||||
assertNotNull("Access token should be present", preAuthorizedToken);
|
||||
|
||||
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
|
||||
// Verify CREDENTIAL_REQUEST event was fired
|
||||
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST)
|
||||
.client(client.getClientId())
|
||||
.user(AssertEvents.isUUID())
|
||||
.session(AssertEvents.isSessionId())
|
||||
.detail(Details.USERNAME, "john")
|
||||
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
|
||||
.assertEvent();
|
||||
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusCode());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("Credential identifier should be present", credentialIdentifier);
|
||||
|
||||
// Parse the credential response
|
||||
CredentialResponse parsedResponse = credentialResponse.getCredentialResponse();
|
||||
assertNotNull("Credential response should not be null", parsedResponse);
|
||||
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
|
||||
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
|
||||
// Step 2: Verify token works at credential endpoint (should succeed)
|
||||
Oid4vcCredentialResponse credentialResponse = oauth.oid4vc()
|
||||
.credentialRequest()
|
||||
.endpoint(ctx.credentialIssuer.getCredentialEndpoint())
|
||||
.bearerToken(preAuthorizedToken)
|
||||
.credentialIdentifier(credentialIdentifier)
|
||||
.send();
|
||||
|
||||
// Step 3: Verify that the issued credential structure is valid
|
||||
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
|
||||
assertNotNull("Credential wrapper should not be null", credentialWrapper);
|
||||
assertEquals("Pre-authorized code token should work at credential endpoint",
|
||||
HttpStatus.SC_OK, credentialResponse.getStatusCode());
|
||||
|
||||
// The credential is stored as Object, so we need to cast it
|
||||
Object credentialObj = credentialWrapper.getCredential();
|
||||
assertNotNull("Credential object should not be null", credentialObj);
|
||||
// Step 3: Verify token is rejected at Account REST API endpoint (uses BearerTokenAuthenticator)
|
||||
// Account endpoint uses BearerTokenAuthenticator which enforces the restriction
|
||||
// Use versioned path to get REST API (not HTML UI)
|
||||
String accountEndpoint = OAuthClient.AUTH_SERVER_ROOT + "/realms/" + oauth.getRealm() + "/account/v1";
|
||||
|
||||
// Verify the credential structure based on format
|
||||
verifyCredentialStructure(credentialObj);
|
||||
HttpGet getAccount = new HttpGet(accountEndpoint);
|
||||
getAccount.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + preAuthorizedToken);
|
||||
getAccount.addHeader(HttpHeaders.ACCEPT, "application/json");
|
||||
|
||||
try (CloseableHttpResponse accountResponse = httpClient.execute(getAccount)) {
|
||||
assertEquals("Pre-authorized code token should be rejected at account endpoint",
|
||||
HttpStatus.SC_UNAUTHORIZED, accountResponse.getStatusLine().getStatusCode());
|
||||
}
|
||||
|
||||
// Step 4: Verify token is rejected at Admin REST API endpoint (uses BearerTokenAuthenticator)
|
||||
// Admin endpoint uses BearerTokenAuthenticator which enforces the restriction
|
||||
String adminEndpoint = oauth.AUTH_SERVER_ROOT + "/admin/realms/" + oauth.getRealm();
|
||||
HttpGet getAdmin = new HttpGet(adminEndpoint);
|
||||
getAdmin.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + preAuthorizedToken);
|
||||
|
||||
try (CloseableHttpResponse adminResponse = httpClient.execute(getAdmin)) {
|
||||
assertEquals("Pre-authorized code token should be rejected at admin endpoint",
|
||||
HttpStatus.SC_UNAUTHORIZED, adminResponse.getStatusLine().getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStandardTokenAllowedAtEndpoint() throws Exception {
|
||||
// Verify that a standard OIDC token (e.g. from password grant)
|
||||
// which has an "UNKNOWN" grant type context, is ALLOWED (backward compatibility).
|
||||
// This ensures the fail-closed logic doesn't accidentally block standard Keycloak flows.
|
||||
|
||||
// 1. Get standard token
|
||||
oauth.realm("test");
|
||||
oauth.client("test-app", "password");
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password");
|
||||
String accessToken = response.getAccessToken();
|
||||
|
||||
// 2. Use at Account API (which would be restricted if it were a pre-authorized token)
|
||||
String accountEndpoint = OAuthClient.AUTH_SERVER_ROOT + "/realms/test/account";
|
||||
HttpGet getAccount = new HttpGet(accountEndpoint);
|
||||
getAccount.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
|
||||
getAccount.addHeader(HttpHeaders.ACCEPT, "application/json");
|
||||
|
||||
try (CloseableHttpResponse accountResponse = httpClient.execute(getAccount)) {
|
||||
// Should be 200 OK because standard tokens are allowed
|
||||
assertEquals("Standard token should be allowed at account endpoint",
|
||||
HttpStatus.SC_OK, accountResponse.getStatusLine().getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,15 +39,19 @@ import org.keycloak.jose.jwk.JWKParser;
|
|||
import org.keycloak.models.KeyManager;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.utils.MediaType;
|
||||
|
||||
|
|
@ -55,11 +59,13 @@ import org.apache.http.HttpStatus;
|
|||
import org.jboss.logging.Logger;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
|
||||
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_ENCRYPTION_PARAMETERS;
|
||||
import static org.keycloak.utils.MediaType.APPLICATION_JWT;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
|
@ -77,7 +83,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
public void testRequestCredentialWithEncryption() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
|
|
@ -95,7 +115,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setCredentialResponseEncryption(
|
||||
new CredentialResponseEncryption()
|
||||
.setEnc(A256GCM)
|
||||
|
|
@ -172,7 +192,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
public void testEncryptedCredentialRequest() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
try {
|
||||
// Enable request encryption requirement
|
||||
|
|
@ -197,7 +231,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setCredentialResponseEncryption(
|
||||
new CredentialResponseEncryption()
|
||||
.setEnc(A256GCM)
|
||||
|
|
@ -238,7 +272,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
public void testEncryptedCredentialRequestWithCompression() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
try {
|
||||
// Enable request encryption and compression
|
||||
|
|
@ -266,7 +314,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
|
||||
// Create credential request with response encryption parameters
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setCredentialResponseEncryption(
|
||||
new CredentialResponseEncryption()
|
||||
.setEnc(A256GCM)
|
||||
|
|
@ -341,10 +389,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
@Test
|
||||
public void testCredentialIssuanceWithEncryption() throws Exception {
|
||||
// Integration test for the full credential issuance flow with encryption
|
||||
String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
|
||||
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
|
||||
(testClientId, testScope) -> {
|
||||
String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
return getBearerToken(oauth.clientId(testClientId).openid(false).scope(scopeName));
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
oauth.clientId(testClientId).openid(false).scope(scopeName);
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
return tokenResponse.getAccessToken();
|
||||
},
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
|
|
@ -479,7 +538,21 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
public void testRequestCredentialWithInvalidJWK() throws Throwable {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
|
|
@ -488,7 +561,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
|
|||
// Invalid JWK (missing modulus but WITH alg parameter)
|
||||
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk();
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setCredentialResponseEncryption(
|
||||
new CredentialResponseEncryption()
|
||||
.setEnc("A256GCM")
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import jakarta.ws.rs.client.WebTarget;
|
|||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.admin.client.resource.ClientResource;
|
||||
|
|
@ -65,6 +66,7 @@ import org.keycloak.jose.jwe.JWEException;
|
|||
import org.keycloak.jose.jwe.JWEHeader;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.jose.jwk.RSAPublicJWK;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
|
@ -111,7 +113,13 @@ import org.keycloak.userprofile.config.UPConfigUtils;
|
|||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.http.HttpHeaders;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.jboss.logging.Logger;
|
||||
|
|
@ -513,6 +521,27 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
|||
// 2. Using the code to get accesstoken
|
||||
String token = f.apply(testClientId, testScope);
|
||||
|
||||
// Extract credential_identifier from the token (client-side parsing)
|
||||
String credentialIdentifier = null;
|
||||
try {
|
||||
JsonWebToken jwt = new JWSInput(token).readJsonContent(JsonWebToken.class);
|
||||
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (authDetails != null) {
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = JsonSerialization.readValue(
|
||||
JsonSerialization.writeValueAsString(authDetails),
|
||||
new TypeReference<List<OID4VCAuthorizationDetailResponse>>() {
|
||||
}
|
||||
);
|
||||
if (!authDetailsResponse.isEmpty() &&
|
||||
authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
|
||||
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
|
||||
credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to extract credential_identifier from token", e);
|
||||
}
|
||||
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
|
||||
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class),
|
||||
|
|
@ -528,9 +557,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
|||
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
|
||||
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setCredentialConfigurationId(oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
// Use credential_identifier if available, otherwise use configuration_id for error testing
|
||||
if (credentialIdentifier != null) {
|
||||
request.setCredentialIdentifier(credentialIdentifier);
|
||||
} else {
|
||||
request.setCredentialConfigurationId(testCredentialConfigurationId);
|
||||
}
|
||||
|
||||
assertEquals(testFormat,
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
|
|
@ -620,6 +652,32 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
|||
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
|
||||
}
|
||||
|
||||
protected void requestCredentialWithIdentifier(String token,
|
||||
String credentialEndpoint,
|
||||
String credentialIdentifier,
|
||||
CredentialResponseHandler responseHandler,
|
||||
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request),
|
||||
ContentType.APPLICATION_JSON);
|
||||
|
||||
HttpPost postCredential = new HttpPost(credentialEndpoint);
|
||||
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
postCredential.setEntity(stringEntity);
|
||||
|
||||
CredentialResponse credentialResponse;
|
||||
try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) {
|
||||
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
|
||||
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
|
||||
}
|
||||
|
||||
// Use response handler to customize checks based on formats.
|
||||
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
|
||||
}
|
||||
|
||||
public CredentialIssuer getCredentialIssuerMetadata() {
|
||||
final String endpoint = getRealmMetadataPath(TEST_REALM_NAME);
|
||||
CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc()
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import org.keycloak.common.VerificationException;
|
|||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
|
||||
|
|
@ -52,6 +53,7 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
|||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.JwtProof;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||
import org.keycloak.protocol.oid4vc.model.Proofs;
|
||||
|
|
@ -72,6 +74,7 @@ import org.apache.http.HttpStatus;
|
|||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
|
@ -301,7 +304,18 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credentialConfigurationId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
try {
|
||||
withCausePropagation(() -> {
|
||||
|
|
@ -314,7 +328,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
|
||||
|
||||
CredentialRequest credentialRequest =
|
||||
new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId);
|
||||
new CredentialRequest().setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
issuerEndpoint.requestCredential(requestPayload);
|
||||
|
|
@ -349,13 +363,27 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
public void testRequestCredential() {
|
||||
String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
|
||||
|
|
@ -399,10 +427,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
try {
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
issuerEndpoint.requestCredential(requestPayload);
|
||||
Assert.fail("Expected BadRequestException due to unknown credential identifier");
|
||||
Assert.fail("Expected BadRequestException due to missing credential identifier or configuration id");
|
||||
} catch (BadRequestException e) {
|
||||
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
|
||||
assertEquals(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID, error.getError());
|
||||
assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST, error.getError());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -464,19 +492,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
// 5. Get an access token for the pre-authorized code
|
||||
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
|
||||
.preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())
|
||||
.endpoint(openidConfig.getTokenEndpoint())
|
||||
.send();
|
||||
|
||||
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
|
||||
String theToken = accessTokenResponse.getAccessToken();
|
||||
assertNotNull("Access token should be present", theToken);
|
||||
|
||||
// 6. Get the credential
|
||||
// Extract credential_identifier from authorization_details in token response
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("Credential identifier should be present", credentialIdentifier);
|
||||
|
||||
// 6. Get the credential using credential_identifier (required when authorization_details are present)
|
||||
credentialsOffer.getCredentialConfigurationIds().stream()
|
||||
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
|
||||
.forEach(supportedCredential -> {
|
||||
try {
|
||||
requestCredential(theToken,
|
||||
requestCredentialWithIdentifier(theToken,
|
||||
credentialIssuer.getCredentialEndpoint(),
|
||||
supportedCredential,
|
||||
credentialIdentifier,
|
||||
new CredentialResponseHandler(),
|
||||
jwtTypeCredentialClientScope);
|
||||
} catch (IOException e) {
|
||||
|
|
@ -488,46 +525,46 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() {
|
||||
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
|
||||
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
};
|
||||
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception {
|
||||
String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
assertEquals("Credential configuration id should match",
|
||||
jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID),
|
||||
credentialRequest.getCredentialConfigurationId());
|
||||
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
|
||||
(testClientId, testScope) -> {
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
|
||||
},
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
|
||||
try (Response response = credentialTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
|
||||
.post(Entity.json(credentialRequest))) {
|
||||
if (response.getStatus() != 200) {
|
||||
String errorBody = response.readEntity(String.class);
|
||||
System.out.println("Error Response: " + errorBody);
|
||||
}
|
||||
assertEquals(200, response.getStatus());
|
||||
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
|
||||
CredentialResponse.class);
|
||||
try (Response response = credentialTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
|
||||
.post(Entity.json(credentialRequest))) {
|
||||
assertEquals(200, response.getStatus());
|
||||
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
|
||||
CredentialResponse.class);
|
||||
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
|
||||
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
|
||||
.get("vc"),
|
||||
VerifiableCredential.class);
|
||||
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
|
||||
assertEquals(TEST_DID, credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
} catch (VerificationException | IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
|
||||
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
|
||||
.get("vc"),
|
||||
VerifiableCredential.class);
|
||||
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
|
||||
assertEquals(TEST_DID, credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
} catch (VerificationException | IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -581,8 +618,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
assertEquals(400, response.getStatus());
|
||||
String errorJson = response.readEntity(String.class);
|
||||
assertNotNull("Error response should not be null", errorJson);
|
||||
assertTrue("Error response should mention UNKNOWN_CREDENTIAL_CONFIGURATION or scope",
|
||||
errorJson.contains("UNKNOWN_CREDENTIAL_CONFIGURATION") || errorJson.contains("scope"));
|
||||
assertTrue("Error response should mention INVALID_CREDENTIAL_REQUEST or scope",
|
||||
errorJson.contains("INVALID_CREDENTIAL_REQUEST") || errorJson.contains("scope"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -596,7 +633,19 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
public void testRequestMultipleCredentialsWithProofs() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
String cNonce = getCNonce();
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
|
|
@ -611,7 +660,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
|
||||
CredentialRequest request = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setProofs(proofs);
|
||||
|
||||
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
|
@ -837,7 +886,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
// Create a credential request with a non-existent configuration ID
|
||||
// This will test the unknown_credential_configuration error
|
||||
// This will test the invalid_credential_request error when no authorization_details present
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId("unknown-configuration-id");
|
||||
|
||||
|
|
@ -848,7 +897,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
Assert.fail("Expected BadRequestException due to unknown credential configuration");
|
||||
} catch (BadRequestException e) {
|
||||
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
|
||||
assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, error.getError());
|
||||
assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST, error.getError());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -859,7 +908,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
*/
|
||||
@Test
|
||||
public void testRequestCredentialWhenNoCredentialBuilderForFormat() {
|
||||
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("credential_identifier should be present", credentialIdentifier);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
|
||||
|
|
@ -867,9 +931,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
// Prepare endpoint with no credential builders to simulate missing builder for the configured format
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
|
||||
|
||||
// Use the known configuration id for the JWT VC test scope
|
||||
// Use the credential identifier from the token
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(jwtTypeCredentialConfigurationIdName);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
|
||||
|
|
@ -889,10 +953,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
*/
|
||||
@Test
|
||||
public void testProofToProofsConversion() throws Exception {
|
||||
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credentialConfigurationId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
|
|
@ -900,7 +976,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
// Test 1: Create a request with single proof field - should be converted to proofs array
|
||||
CredentialRequest requestWithProof = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credentialConfigurationId);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
// Create a single proof object
|
||||
JwtProof singleProof = new JwtProof()
|
||||
|
|
@ -922,7 +998,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
// Test 2: Create a request with both proof and proofs fields - should fail validation
|
||||
CredentialRequest requestWithBoth = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credentialConfigurationId);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
requestWithBoth.setProof(singleProof);
|
||||
|
||||
|
|
@ -965,7 +1041,18 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
// Extract serializable data before lambda
|
||||
final String scopeName = optionalScope.getName();
|
||||
final String configId = optionalScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(configId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
|
||||
|
|
@ -973,7 +1060,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(configId);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import org.keycloak.models.ClientScopeModel;
|
|||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
|
||||
|
|
@ -48,6 +49,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
|||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.Proofs;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
|
|
@ -67,6 +69,7 @@ import org.apache.http.HttpStatus;
|
|||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
|
@ -85,7 +88,19 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
@Test
|
||||
public void testRequestTestCredential() {
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
|
|
@ -94,14 +109,26 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
.run(session -> {
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, null);
|
||||
testRequestTestCredential(session, clientScope, token, null, credentialIdentifier);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestTestCredentialWithKeybinding() {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
|
|
@ -114,7 +141,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof);
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
|
||||
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
|
||||
}));
|
||||
}
|
||||
|
|
@ -122,7 +149,19 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
@Test
|
||||
public void testRequestTestCredentialWithInvalidKeybinding() throws Throwable {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
|
|
@ -135,7 +174,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
|
||||
}));
|
||||
});
|
||||
Assert.fail("Should have thrown an exception");
|
||||
|
|
@ -147,7 +186,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
@Test
|
||||
public void testProofOfPossessionWithMissingAudience() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
|
|
@ -163,7 +215,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
|
|
@ -179,7 +231,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
@Test
|
||||
public void testProofOfPossessionWithIllegalSourceEndpoint() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
|
|
@ -194,7 +259,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
|
|
@ -210,7 +275,20 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
@Test
|
||||
public void testProofOfPossessionWithExpiredState() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
|
|
@ -229,7 +307,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
testRequestTestCredential(session, clientScope, token, proof, credentialIdentifier);
|
||||
} finally {
|
||||
// make sure other tests are not affected by the changed realm-attribute
|
||||
session.getContext().getRealm().removeAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS);
|
||||
|
|
@ -250,16 +328,15 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
|
||||
private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope,
|
||||
|
||||
String token, Proofs proof)
|
||||
String token, Proofs proof, String credentialIdentifier)
|
||||
throws VerificationException, IOException {
|
||||
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credentialConfigurationId)
|
||||
.setCredentialIdentifier(credentialIdentifier)
|
||||
.setProofs(proof);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
|
|
@ -337,21 +414,30 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
|||
// 5. Get an access token for the pre-authorized code
|
||||
AccessTokenResponse accessTokenResponse = oauth.oid4vc()
|
||||
.preAuthorizedCodeGrantRequest(credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())
|
||||
.endpoint(openidConfig.getTokenEndpoint())
|
||||
.send();
|
||||
|
||||
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
|
||||
String theToken = accessTokenResponse.getAccessToken();
|
||||
assertNotNull("Access token should be present", theToken);
|
||||
|
||||
// Extract credential_identifier from authorization_details in token response
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = accessTokenResponse.getOid4vcAuthorizationDetails();
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
assertNotNull("Credential identifier should be present", credentialIdentifier);
|
||||
|
||||
final String vct = clientScope.getAttributes().get(CredentialScopeModel.VCT);
|
||||
|
||||
// 6. Get the credential
|
||||
// 6. Get the credential using credential_identifier (required when authorization_details are present)
|
||||
credentialsOffer.getCredentialConfigurationIds().stream()
|
||||
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
|
||||
.forEach(supportedCredential -> {
|
||||
try {
|
||||
requestCredential(theToken,
|
||||
requestCredentialWithIdentifier(theToken,
|
||||
credentialIssuer.getCredentialEndpoint(),
|
||||
supportedCredential,
|
||||
credentialIdentifier,
|
||||
new TestCredentialResponseHandler(vct),
|
||||
sdJwtTypeCredentialClientScope);
|
||||
} catch (IOException e) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
|
@ -26,10 +27,13 @@ import org.keycloak.TokenVerifier;
|
|||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.constants.OID4VCIConstants;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
|
|
@ -40,6 +44,8 @@ import org.keycloak.util.JsonSerialization;
|
|||
import org.apache.http.HttpStatus;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
|
|
@ -59,7 +65,20 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
|
|||
session.getContext().getRealm().setAttribute("oid4vci.time.round.unit", "DAY");
|
||||
});
|
||||
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
|
|
@ -81,7 +100,7 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
|
|||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
Response response = issuerEndpoint.requestCredential(requestPayload);
|
||||
|
|
|
|||
|
|
@ -19,15 +19,19 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailResponse;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
|
|
@ -36,6 +40,8 @@ import org.keycloak.util.JsonSerialization;
|
|||
import org.apache.http.HttpStatus;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
|
|
@ -57,7 +63,17 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
|
|||
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
|
||||
org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
|
||||
String token = tokenResponse.getAccessToken();
|
||||
List<OID4VCAuthorizationDetailResponse> authDetailsResponse = tokenResponse.getOid4vcAuthorizationDetails();
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
try {
|
||||
|
|
@ -66,7 +82,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
|
|||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(credConfigId);
|
||||
.setCredentialIdentifier(credentialIdentifier);
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
Response response = issuerEndpoint.requestCredential(requestPayload);
|
||||
|
|
|
|||
Loading…
Reference in a new issue