Skip to content

Commit 53c339c

Browse files
committed
reformated the multiple credential issuance flow using a credential endpoint
1 parent b7c55ba commit 53c339c

File tree

6 files changed

+661
-158
lines changed

6 files changed

+661
-158
lines changed

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java

Lines changed: 102 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -296,20 +296,30 @@ private void checkScope(CredentialRequest credentialRequestVO) {
296296
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
297297
// Authorization Code Flow
298298
RealmModel realm = session.getContext().getRealm();
299-
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
300-
301-
String scope = realm.getAttribute("vc." + credentialIdentifier + ".scope");
302-
303299
AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken();
304-
if (scope == null || Arrays.stream(accessToken.getScope().split(" "))
305-
.noneMatch(tokenScope -> tokenScope.equals(scope))) {
306-
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
307-
credentialIdentifier, scope, accessToken.getScope());
308-
throw new CorsErrorResponseException(cors,
309-
ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(),
310-
"Scope check failure",
311-
Response.Status.BAD_REQUEST);
312-
} else {
300+
301+
// Get all credential identifiers from the request
302+
List<String> credentialIdentifiers = credentialRequestVO.getCredentialSpecs() != null
303+
? credentialRequestVO.getCredentialSpecs().stream()
304+
.map(CredentialRequest.CredentialSpec::getCredentialIdentifier)
305+
.filter(Objects::nonNull)
306+
.collect(Collectors.toList())
307+
: List.of(credentialRequestVO.getCredentialIdentifier());
308+
309+
for (String credentialIdentifier : credentialIdentifiers) {
310+
if (credentialIdentifier == null) {
311+
continue; // Skip if no identifier is provided (e.g., format-based request)
312+
}
313+
String scope = realm.getAttribute("vc." + credentialIdentifier + ".scope");
314+
if (scope == null || Arrays.stream(accessToken.getScope().split(" "))
315+
.noneMatch(tokenScope -> tokenScope.equals(scope))) {
316+
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
317+
credentialIdentifier, scope, accessToken.getScope());
318+
throw new CorsErrorResponseException(cors,
319+
ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(),
320+
"Scope check failure for credential: " + credentialIdentifier,
321+
Response.Status.BAD_REQUEST);
322+
}
313323
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
314324
credentialIdentifier, scope, accessToken.getScope());
315325
}
@@ -337,84 +347,94 @@ public Response requestCredential(CredentialRequest credentialRequestVO) {
337347
checkScope(credentialRequestVO);
338348
}
339349

340-
// Both Format and identifier are optional.
341-
// If the credential_identifier is present, Format can't be present. But this implementation will
342-
// tolerate the presence of both, waiting for clarity in specifications.
343-
// This implementation will privilege the presence of the credential config identifier.
344-
String requestedCredentialId = credentialRequestVO.getCredentialIdentifier();
345-
String requestedFormat = credentialRequestVO.getFormat();
350+
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session);
351+
CredentialResponse responseVO = new CredentialResponse();
352+
353+
// Handle single or multiple credential specs
354+
List<CredentialRequest.CredentialSpec> specs = credentialRequestVO.getCredentialSpecs() != null
355+
? credentialRequestVO.getCredentialSpecs()
356+
: List.of(new CredentialRequest.CredentialSpec()
357+
.setFormat(credentialRequestVO.getFormat())
358+
.setCredentialIdentifier(credentialRequestVO.getCredentialIdentifier())
359+
.setVct(credentialRequestVO.getVct())
360+
.setCredentialDefinition(credentialRequestVO.getCredentialDefinition())
361+
.setProof(credentialRequestVO.getProof()));
362+
363+
// Process each credential specification
364+
List<CredentialResponse.CredentialEntry> credentials = specs.stream()
365+
.map(spec -> {
366+
// Resolve credential configuration
367+
SupportedCredentialConfiguration config = resolveCredentialConfiguration(spec, supportedCredentials);
368+
369+
// Single credential request for compatibility with existing getCredential method
370+
CredentialRequest singleRequest = new CredentialRequest()
371+
.setFormat(spec.getFormat())
372+
.setCredentialIdentifier(spec.getCredentialIdentifier())
373+
.setVct(spec.getVct())
374+
.setCredentialDefinition(spec.getCredentialDefinition())
375+
.setProof(spec.getProof());
376+
377+
// Generate credential
378+
Object credential = getCredential(authResult, config, singleRequest);
379+
380+
// Ensure format is supported
381+
if (!SUPPORTED_FORMATS.contains(spec.getFormat())) {
382+
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
383+
}
346384

347-
// Check if at least one of both is available.
385+
return new CredentialResponse.CredentialEntry()
386+
.setFormat(spec.getFormat())
387+
.setCredential(credential);
388+
})
389+
.collect(Collectors.toList());
390+
391+
responseVO.setCredentials(credentials);
392+
return Response.ok().entity(responseVO).build();
393+
}
394+
395+
private SupportedCredentialConfiguration resolveCredentialConfiguration(CredentialRequest.CredentialSpec spec, Map<String, SupportedCredentialConfiguration> supportedCredentials) {
396+
String requestedCredentialId = spec.getCredentialIdentifier();
397+
String requestedFormat = spec.getFormat();
398+
399+
// Check if at least one of credential_identifier or format is provided
348400
if (requestedCredentialId == null && requestedFormat == null) {
349401
LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified.");
350402
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT));
351403
}
352404

353-
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session);
354-
355-
// Resolve from identifier first
356-
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
405+
SupportedCredentialConfiguration config = null;
357406
if (requestedCredentialId != null) {
358-
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
359-
if (supportedCredentialConfiguration == null) {
407+
config = supportedCredentials.get(requestedCredentialId);
408+
if (config == null) {
360409
LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId);
361410
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
362411
}
363-
// Then for format. We know spec does not allow both parameter. But we are tolerant if you send both
364-
// Was found by id, check that the format matches.
365-
if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())) {
366-
LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat());
412+
// Check format compatibility if provided
413+
if (requestedFormat != null && !requestedFormat.equals(config.getFormat())) {
414+
LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.",
415+
requestedCredentialId, requestedFormat, config.getFormat());
367416
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
368417
}
369418
}
370419

371-
if (supportedCredentialConfiguration == null && requestedFormat != null) {
420+
if (config == null && requestedFormat != null) {
372421
// Search by format
373-
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
374-
if (supportedCredentialConfiguration == null) {
375-
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
422+
config = getSupportedCredentialConfiguration(spec, supportedCredentials, requestedFormat);
423+
if (config == null) {
424+
LOGGER.debugf("Credential with requested format %s not supported.", requestedFormat);
376425
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
377426
}
378427
}
379428

380-
CredentialResponse responseVO = new CredentialResponse();
381-
382-
// Handle multiple credentials if proofs are provided
383-
List<Proof> proofs = credentialRequestVO.getProofs() != null ? credentialRequestVO.getProofs() : List.of();
384-
if (proofs.isEmpty()) {
385-
// Single credential request without proof
386-
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
387-
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
388-
responseVO.setCredential(List.of(theCredential));
389-
} else {
390-
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
391-
}
392-
} else {
393-
// Multiple credential request with proofs
394-
SupportedCredentialConfiguration finalSupportedCredentialConfiguration = supportedCredentialConfiguration;
395-
List<Object> credentials = proofs.stream()
396-
.map(proof -> {
397-
CredentialRequest singleProofRequest = new CredentialRequest()
398-
.setFormat(credentialRequestVO.getFormat())
399-
.setCredentialIdentifier(credentialRequestVO.getCredentialIdentifier())
400-
.setVct(credentialRequestVO.getVct())
401-
.setCredentialDefinition(credentialRequestVO.getCredentialDefinition())
402-
.setProof(proof);
403-
return getCredential(authResult, finalSupportedCredentialConfiguration, singleProofRequest);
404-
})
405-
.collect(Collectors.toList());
406-
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
407-
responseVO.setCredential(credentials);
408-
} else {
409-
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
410-
}
429+
if (config == null) {
430+
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
411431
}
412432

413-
return Response.ok().entity(responseVO).build();
433+
return config;
414434
}
415435

416-
private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
417-
// 1. Format resolver
436+
private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest.CredentialSpec spec, Map<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
437+
// Filter by format
418438
List<SupportedCredentialConfiguration> configs = supportedCredentials.values().stream()
419439
.filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat))
420440
.collect(Collectors.toList());
@@ -425,14 +445,14 @@ private SupportedCredentialConfiguration getSupportedCredentialConfiguration(Cre
425445
case SD_JWT_VC:
426446
// Resolve from vct for sd-jwt
427447
matchingConfigs = configs.stream()
428-
.filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct()))
448+
.filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), spec.getVct()))
429449
.collect(Collectors.toList());
430450
break;
431451
case JWT_VC:
432452
case LDP_VC:
433-
// Will detach this when each format provides logic on how to resolve from definition.
453+
// Resolve from credential_definition
434454
matchingConfigs = configs.stream()
435-
.filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition()))
455+
.filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), spec.getCredentialDefinition()))
436456
.collect(Collectors.toList());
437457
break;
438458
default:
@@ -587,6 +607,17 @@ private Response getErrorResponse(ErrorType errorType) {
587607
.build();
588608
}
589609

610+
private Response getErrorResponse(ErrorType errorType, String errorDescription) {
611+
var errorResponse = new ErrorResponse();
612+
errorResponse.setError(errorType);
613+
errorResponse.setErrorDescription(errorDescription);
614+
return Response
615+
.status(Response.Status.BAD_REQUEST)
616+
.entity(errorResponse)
617+
.type(MediaType.APPLICATION_JSON)
618+
.build();
619+
}
620+
590621
private ClientModel getClient(String clientId) {
591622
return session.clients().getClientByClientId(session.getContext().getRealm(), clientId);
592623
}

services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
@JsonInclude(JsonInclude.Include.NON_NULL)
3434
public class CredentialRequest {
3535

36+
@JsonProperty("credential_specs")
37+
private List<CredentialSpec> credentialSpecs;
38+
39+
// Backward compatibility fields for single credential requests
3640
private String format;
3741

3842
@JsonProperty("credential_identifier")
@@ -54,6 +58,81 @@ public class CredentialRequest {
5458
@JsonProperty("credential_definition")
5559
private CredentialDefinition credentialDefinition;
5660

61+
// New class to represent a single credential specification
62+
public static class CredentialSpec {
63+
private String format;
64+
65+
@JsonProperty("credential_identifier")
66+
private String credentialIdentifier;
67+
68+
private String vct;
69+
70+
@JsonProperty("credential_definition")
71+
private CredentialDefinition credentialDefinition;
72+
73+
@JsonProperty("proof")
74+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "proof_type")
75+
@JsonSubTypes({
76+
@JsonSubTypes.Type(value = JwtProof.class, name = ProofType.JWT),
77+
@JsonSubTypes.Type(value = LdpVpProof.class, name = ProofType.LD_PROOF)
78+
})
79+
private Proof proof;
80+
81+
public String getFormat() {
82+
return format;
83+
}
84+
85+
public CredentialSpec setFormat(String format) {
86+
this.format = format;
87+
return this;
88+
}
89+
90+
public String getCredentialIdentifier() {
91+
return credentialIdentifier;
92+
}
93+
94+
public CredentialSpec setCredentialIdentifier(String credentialIdentifier) {
95+
this.credentialIdentifier = credentialIdentifier;
96+
return this;
97+
}
98+
99+
public String getVct() {
100+
return vct;
101+
}
102+
103+
public CredentialSpec setVct(String vct) {
104+
this.vct = vct;
105+
return this;
106+
}
107+
108+
public CredentialDefinition getCredentialDefinition() {
109+
return credentialDefinition;
110+
}
111+
112+
public CredentialSpec setCredentialDefinition(CredentialDefinition credentialDefinition) {
113+
this.credentialDefinition = credentialDefinition;
114+
return this;
115+
}
116+
117+
public Proof getProof() {
118+
return proof;
119+
}
120+
121+
public CredentialSpec setProof(Proof proof) {
122+
this.proof = proof;
123+
return this;
124+
}
125+
}
126+
127+
public List<CredentialSpec> getCredentialSpecs() {
128+
return credentialSpecs;
129+
}
130+
131+
public CredentialRequest setCredentialSpecs(List<CredentialSpec> credentialSpecs) {
132+
this.credentialSpecs = credentialSpecs;
133+
return this;
134+
}
135+
57136
public String getFormat() {
58137
return format;
59138
}
@@ -72,21 +151,18 @@ public CredentialRequest setCredentialIdentifier(String credentialIdentifier) {
72151
return this;
73152
}
74153

75-
public List<Proof> getProofs() {
76-
return proofs;
77-
}
78-
79-
public CredentialRequest setProofs(List<Proof> proofs) {
80-
this.proofs = proofs;
81-
return this;
82-
}
83-
84-
// Backward compatibility for single proof
154+
// Backward compatibility for single proof, ensuring only one proof is used for single credential requests
85155
public Proof getProof() {
156+
if (proofs != null && proofs.size() > 1) {
157+
throw new IllegalStateException("Multiple proofs are not supported for single credential requests in backward compatibility mode");
158+
}
86159
return proofs != null && !proofs.isEmpty() ? proofs.get(0) : null;
87160
}
88161

89162
public CredentialRequest setProof(Proof proof) {
163+
if (proof != null && this.proofs != null && !this.proofs.isEmpty()) {
164+
throw new IllegalStateException("Cannot set single proof when proofs list is already set");
165+
}
90166
this.proofs = proof != null ? List.of(proof) : null;
91167
return this;
92168
}

0 commit comments

Comments
 (0)