Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ private void checkClientEnabled(EventBuilder eventBuilder) {
}

/**
* the OpenId4VCI nonce-endpoint
* The OID4VCI nonce endpoint
*
* @return a short-lived c_nonce value that must be presented in key-bound proofs at the credential endpoint.
* @see <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-16.html#name-nonce-endpoint">Nonce endpoint</a>
Expand Down Expand Up @@ -404,47 +404,52 @@ public Response createCredentialOfferPreflight() {
* Creates an authorization code grant offer that is bound to the calling user.
*/
public Response createCredentialOffer(String credConfigId) {
return createCredentialOffer(credConfigId, false, null);
return createCredentialOffer(credConfigId, false, null, null, null, OfferResponseType.URI, 0, 0);
}

/**
* Creates a Credential Offer that is bound to a specific user.
*/
public Response createCredentialOffer(String credConfigId, boolean preAuthorized, String targetUser) {
return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, OfferResponseType.URI, 0, 0);
return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, null, OfferResponseType.URI, 0, 0);
}

/**
* Creates a Credential Offer that can be pre-authorized and/or bound to a specific target user.
* Creates a Credential Offer that can be pre-authorized and/or bound to a specific target user and client.
* <p>
* Credential Offer Validity Matrix for the supported request parameters "pre_authorized", "targetUser" combinations.
* Credential Offer Validity Matrix for the supported request parameters.
* </p>
* +----------+----------+---------+--------------------------------------------+
* | Pre-Auth | Username | Valid | Notes |
* +----------+----------+---------+--------------------------------------------+
* | no | no | yes | Anonymous offer; works for any login user. |
* | no | yes | yes | Offer restricted to a specific user. |
* +----------+----------+---------+--------------------------------------------+
* | yes | no | yes | Self issued pre-auth offer. |
* | yes | yes | yes | Offer restricted to a specific user. |
* +----------+----------+---------+--------------------------------------------+
* +----------+----------+-----------------+---------+------------------------------------------------------+
* | Pre-Auth | Username | Client | Valid | Notes |
* +----------+----------+-----------------+---------+------------------------------------------------------+
* | no | no | no | yes | Anonymous offer; any logged-in user may redeem. |
* | no | no | explicit | yes | Anonymous offer; bound to a specific client. |
* | no | yes | no | yes | Offer restricted to a specific user. |
* | no | yes | explicit | yes | Offer restricted to a specific user and client. |
* +----------+----------+-----------------+---------+------------------------------------------------------+
* | yes | no | explicit/unique | yes | Defaults to the user from the current login session |
* | yes | yes | explicit | yes | Pre-auth for a specific target user and client. |
* | yes | yes | omitted unique | yes | Discovered from credential_configuration_id. |
* | yes | yes | omitted none | no | Discovery fails: no OID4VCI client has that scope. |
* | yes | yes | omitted multi | no | Discovery fails: multiple clients match. |
* +----------+----------+-----------------+---------+------------------------------------------------------+
* </p>
* <b>Pre-Authorized Offer</b>
* <b>Pre-Authorized Code Grant Offer</b>
* <ul>
* <li>A pre-authorized offer is authorized for the clientId from the current login session</li>
* <li>If targetUser is null or empty, it defaults to the user from the current login session</li>
* <li>If targetUser is equal to the current login, the generated offer is "self issued"</li>
* <li>To create an offer for another user, the issuing user must hold the {@code credential_offer_create} role</li>
* <li>A targetClient can explicitly be given or be discovered; the offer is scoped to that client</li>
* <li>A pre-authorized offer can optionally have an associated tx_code</li>
* <li>An offer can optionally have a predefined expiry date</li>
* </ul>
*
* <b>Non Pre-Authorized Offer</b>
* <b>Authorization Code Grant Offer</b>
* <ul>
* <li>If targetUser is null or empty, the generated offer is "anonymous"</li>
* <li>If targetUser is equal to the current login, the offer is "self issued"</li>
* <li>If targetUser is none of the above, the offer is "targeted"</li>
* <li>For a targeted offer, the issuing user must hold the {@code credential_offer_create} role</li>
* <li>To create an offer for another user, the issuing user must hold the {@code credential_offer_create} role</li>
* <li>A targetClient may be given, in which case the offer is scoped to that client</li>
* <li>An offer can optionally have a predefined expiry date</li>
* </ul>
*
Expand All @@ -468,6 +473,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized
* @param credentialConfigurationId A valid credential configuration id
* @param preAuthorized A flag whether the offer should be pre-authorized
* @param targetUser The username that the offer is authorized for
* @param targetClient The client_id that the offer is scoped to (optional)
* @param expiresAt The date/time when the offer expires (in Unix timestamp seconds)
* @param responseType The response type, which can be 'uri', 'qr' or 'uri+qr'
* @param width The width of the QR code image
Expand All @@ -480,6 +486,7 @@ public Response createCredentialOffer(
@QueryParam("credential_configuration_id") String credentialConfigurationId,
@QueryParam("pre_authorized") @DefaultValue("false") Boolean preAuthorized,
@QueryParam("target_user") String targetUser,
@QueryParam("target_client") String targetClient,
@QueryParam("expire") Integer expiresAt,
@QueryParam("type") @DefaultValue("uri") OfferResponseType responseType,
@QueryParam("width") @DefaultValue("200") int width,
Expand Down Expand Up @@ -540,7 +547,6 @@ public Response createCredentialOffer(

// Create the CredentialsOffer
//
String targetClientId = clientModel.getClientId();
String grantType = preAuthorized ? PRE_AUTH_GRANT_TYPE : AUTH_CODE_GRANT_TYPE;
List<String> credentialConfigurationIds = List.of(credentialConfigurationId);

Expand All @@ -549,7 +555,7 @@ public Response createCredentialOffer(

CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class);
offerState = offerProvider.createCredentialOffer(loginUserModel, grantType,
credentialConfigurationIds, targetClientId, targetUser, expiresAt);
credentialConfigurationIds, targetUser, targetClient, expiresAt);

} catch (CredentialOfferException ex) {
eventBuilder.detail(Details.REASON, ex.getMessage()).error(ex.getErrorType());
Expand All @@ -563,8 +569,9 @@ public Response createCredentialOffer(
offerStorage.putOfferState(offerState);

String targetUserId = offerState.getTargetUserId();
String targetClientId = offerState.getTargetClientId();
LOGGER.debugf("Stored credential offer state: [grant=%s, ids=%s, cid=%s, uid=%s, nonce=%s]",
grantType, credentialConfigurationIds, offerState.getTargetClientId(), targetUserId, offerState.getNonce());
grantType, credentialConfigurationIds, targetClientId, targetUserId, offerState.getNonce());

// Add event details
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED, String.valueOf(preAuthorized))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ CredentialOfferState createCredentialOffer(
UserModel user,
String grantType,
List<String> credentialConfigurationIds,
String targetUsername,
String targetClientId,
Comment thread
tdiesler marked this conversation as resolved.
String targetUserId,
Integer expireAt
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.keycloak.common.Profile;
import org.keycloak.events.Errors;
Expand All @@ -27,6 +30,7 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oid4vc.issuance.CredentialOfferException;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
Expand All @@ -38,6 +42,7 @@
import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant;
import org.keycloak.protocol.oid4vc.utils.OID4VCUtil;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.util.Strings;

import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY;
Expand All @@ -63,8 +68,7 @@ public CredentialOfferState createCredentialOffer(
UserModel user,
String grantType,
List<String> credentialConfigurationIds,
String targetClientId,
String targetUsername,
String targetUsername, String targetClientId,
Integer expireAt) {

// Checks whether `--feature=oid4vc_vci_preauth_code` is enabled
Expand All @@ -83,16 +87,30 @@ public CredentialOfferState createCredentialOffer(

RealmModel realmModel = this.session.getContext().getRealm();

Map<String, CredentialScopeModel> credScopeModels = new LinkedHashMap<>();
for (String cid : credentialConfigurationIds) {
CredentialScopeModel credScopeModel = Optional.ofNullable(findCredentialScopeModelByConfigurationId(
realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), cid))
.orElseThrow(() -> new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + cid));
credScopeModels.put(cid, credScopeModel);
}

// Validate the target user
//
UserModel targetUser = Optional.ofNullable(targetUsername)
.map(tu -> validateTargetUser(session, realmModel, user, tu))
.orElse(null);
String targetUserId = targetUser == null ? null : targetUser.getId();
String targetUserId = targetUser != null ? targetUser.getId() : null;

// Discovery: find the unique OID4VCI-enabled client that has this scope assigned
if (preAuthorized && targetClientId == null) {
targetClientId = discoverTargetClient(realmModel, credScopeModels);
}
Comment thread
tdiesler marked this conversation as resolved.

// Validate the target client
if (targetClientId != null) {
validateTargetClient(realmModel, targetClientId);
List<String> credScopeNames = credScopeModels.values().stream().map(CredentialScopeModel::getName).toList();
validateTargetClient(realmModel, targetClientId, credScopeNames);
}

// Create the CredentialsOffer
Expand All @@ -105,16 +123,11 @@ public CredentialOfferState createCredentialOffer(
//
CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt, credOffersId -> {
List<OID4VCAuthorizationDetail> authDetails = new ArrayList<>();
for (String credConfigId : credentialConfigurationIds) {
CredentialScopeModel credScope = findCredentialScopeModelByConfigurationId(
realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), credConfigId);
if (credScope == null) {
throw new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + credConfigId);
}
for (CredentialScopeModel credScope : credScopeModels.values()) {
String credConfigId = credScope.getCredentialConfigurationId();
if (targetUser != null && !OID4VCUtil.hasVerifiableCredential(session, targetUser, credScope)) {
throw new CredentialOfferException(Errors.INVALID_REQUEST, "User '" + targetUser.getUsername() + "' does not have verifiable credential '" + credConfigId + "'.");
}

OID4VCAuthorizationDetailsProcessor authDetailsProcessor = new OID4VCAuthorizationDetailsProcessor(session);
authDetails.add(authDetailsProcessor.generateResponseAuthorizationDetails(credScope, credOffersId));
}
Expand Down Expand Up @@ -163,18 +176,53 @@ private UserModel validateTargetUser(KeycloakSession session, RealmModel realmMo
return targetUserModel;
}

private void validateTargetClient(RealmModel realm, String clientId) {
ClientModel client = session.clients().getClientByClientId(realm, clientId);
if (client == null) {
/**
* Discovers the unique OID4VCI-enabled client that has the given credential scope.
* Fails fast if zero or more than one client match.
*/
private String discoverTargetClient(RealmModel realm, Map<String, CredentialScopeModel> credScopeModels) {

List<ClientModel> candidateClients = session.clients().getClientsStream(realm)
.filter(ClientModel::isEnabled)
.filter(c -> Boolean.parseBoolean(c.getAttributes().get(OID4VCI_ENABLED_ATTRIBUTE_KEY)))
.toList();

Set<String> credConfigIds = credScopeModels.keySet();
List<String> credScopeNames = credScopeModels.values().stream().map(CredentialScopeModel::getName).toList();
List<ClientModel> matches = candidateClients.stream()
.filter(c -> c.getClientScopes(false).keySet().containsAll(credScopeNames))
.toList();

if (matches.isEmpty()) {
throw new CredentialOfferException(Errors.INVALID_REQUEST,
"No OID4VCI client found for credential configuration ids: " + credConfigIds);
}
if (matches.size() > 1) {
throw new CredentialOfferException(Errors.INVALID_REQUEST,
"Multiple OID4VCI clients for credential configuration ids: " + credConfigIds);
}
return matches.get(0).getClientId();
}

private void validateTargetClient(RealmModel realm, String clientId, List<String> credentialScopeNames) {
ClientModel clientModel = session.clients().getClientByClientId(realm, clientId);
if (clientModel == null) {
throw new CredentialOfferException(Errors.CLIENT_NOT_FOUND, "Client '" + clientId + "' not found");
}
ClientRepresentation client = ModelToRepresentation.toRepresentation(clientModel, session);
if (!client.isEnabled()) {
throw new CredentialOfferException(Errors.CLIENT_DISABLED, "Client '" + clientId + "' disabled");
}
boolean oid4vciEnabled = Boolean.parseBoolean(client.getAttributes().get(OID4VCI_ENABLED_ATTRIBUTE_KEY));
if (!oid4vciEnabled) {
throw new CredentialOfferException(Errors.INVALID_CLIENT, "Client '" + clientId + "' is not enabled for OID4VCI features.");
}
List<String> clientScopes = client.getOptionalClientScopes();
for (String scope : credentialScopeNames) {
if (!clientScopes.contains(scope)) {
throw new CredentialOfferException(Errors.INVALID_CLIENT, "Client '" + clientId + "' does not support '" + scope + "'");
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,14 @@ private CredentialOfferState createCredentialsOffer(KeycloakSession session, Rea
String credentialConfigurationId = actionConfig.getCredentialConfigurationId();
event = event.clone().detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);

String clientId = actionConfig.getClientId();
CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class);
CredentialOfferState offerState = offerProvider.createCredentialOffer(user, grantType,
List.of(credentialConfigurationId), clientId, user.getUsername(), expiresAt);
List.of(credentialConfigurationId), user.getUsername(), actionConfig.getClientId(), expiresAt);

CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
offerStorage.putOfferState(offerState);

String clientId = offerState.getTargetClientId();
logger.debugf("Stored credential offer state: [credentialConfigId=%s, clientId=%s, username=%s, nonce=%s]",
credentialConfigurationId, clientId, user.getUsername(), offerState.getNonce());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,32 @@ public class CredentialScopeUtils {
// Hide ctor
private CredentialScopeUtils() {}

public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier<Stream<ClientScopeModel>> supplier, String credConfigId) {
public static List<CredentialScopeModel> findCredentialScopeModelsByConfigurationId(RealmModel realmModel, Supplier<Stream<ClientScopeModel>> supplier, String credConfigId) {
if (Strings.isEmpty(credConfigId)) {
return null;
return List.of();
}
List<CredentialScopeModel> credScopes = supplier.get()
List<CredentialScopeModel> scopesModels = supplier.get()
.filter(it -> it.getProtocol().equals(OID4VC_PROTOCOL))
.map(CredentialScopeModel::new)
.filter(it -> credConfigId.equals(it.getCredentialConfigurationId()))
.toList();
return scopesModels;
}

public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier<Stream<ClientScopeModel>> supplier, String credConfigId) {
List<CredentialScopeModel> credScopes = findCredentialScopeModelsByConfigurationId(realmModel, supplier, credConfigId);
if (credScopes.size() > 1) {
List<String> clientScopeNames = credScopes.stream().map(ClientScopeModel::getName).toList();
log.warnf("Multiple client scopes found for credential configuration '%s' in realm '%s': %s",
credConfigId, realmModel.getName(), clientScopeNames);
return null;
} else if (credScopes.isEmpty()) {
log.warnf("No client scopes found for credential configuration '%s' in realm '%s'",
}
if (credScopes.isEmpty()) {
log.debugf("No client scopes found for credential configuration '%s' in realm '%s'",
credConfigId, realmModel.getName());
return null;
} else {
return credScopes.get(0);
}
return credScopes.get(0);
}

public static CredentialScopeModel findCredentialScopeModelByName(RealmModel realmModel, Supplier<Stream<ClientScopeModel>> supplier, String scope) {
Expand Down
Loading
Loading