Skip to content

Commit 25e8651

Browse files
committed
Align the Credential Endpoint's requests and errors with OpenID4VCI 1.0
Closes gaps G12, G14, G17 and G2 of the issuer compliance matrix. - A Credential Request names its credential by credential_identifier or credential_configuration_id only, each a non-empty string. The pre-final resolution from format with a credential type or vct is removed, with its ModuleConfig lookup; a request naming neither gets invalid_credential_request instead of the pre-final unsupported_credential_type. The two members are read by name, so one sent as null next to the other is refused. - A token which does not grant the credential asked for gets its 403 insufficient_scope with a WWW-Authenticate challenge naming the error under the scheme the token came under (RFC 6750 section 3). A token naming no user, or a user who no longer exists, is refused as invalid_token (401, with its challenge) before the request is read, instead of 400 invalid_request. - Key proofs: the kid, jwk, x5c, key_attestation and trust_chain header parameters and the iss claim are refused when written as null rather than read as absent. A key_attestation, and each statement of a trust_chain (a JSON array, as sent), must be a JWT in compact serialization (a JSON object for its claims, no b64) whose alg is advertised in proof_signing_alg_values_supported (Appendix F.1); what they attest is not evaluated (HAIP, H-G4). - error_description keeps only the characters OpenID4VCI 1.0 section 8.3.1.2 and RFC 6749 section 5.2 allow: Routes::newJsonErrorResponse() drops the rest, and the endpoint's descriptions no longer quote names in double quotes or echo the requested configuration id. The proof validator tests build real proofs with the openid library for the type, issue time, null members and attached JWTs, and both endpoint test classes check every description their refusals produce.
1 parent 2d87444 commit 25e8651

11 files changed

Lines changed: 982 additions & 362 deletions

File tree

‎conformance-tests/openid4vci-1_0-compliance.md‎

Lines changed: 11 additions & 19 deletions
Large diffs are not rendered by default.

‎docs/1-oidc.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ Currently implemented OpenID4VCI features:
164164
- Credential Issuer Metadata | `.well-known/openid-credential-issuer` -
165165
Advertises supported credentials, algorithms, and endpoints.
166166
- Credential Endpoint | `credential-issuer/credential` - Handles credential
167-
requests with proof of possession.
167+
requests with proof of possession. A request names its credential by
168+
`credential_identifier` or `credential_configuration_id`, as OpenID4VCI 1.0
169+
has it; the pre-final `format` with a credential type or `vct` is not read.
168170
- Nonce Endpoint | `credential-issuer/nonce` - Provides nonces (`c_nonce`).
169171
- Credential Offer (API) | `api/vci/credential-offer` - Allows triggering
170172
credential offers via administrative API.
@@ -219,6 +221,10 @@ Currently implemented OpenID4VCI features:
219221
`c_nonce` is a JWS of a type of its own (`c-nonce+jwt`), so nothing else this
220222
issuer signs with the same key, a credential or a Status List Token, passes as
221223
one.
224+
- A key proof's `key_attestation` and `trust_chain` headers are accepted when
225+
signed with an algorithm advertised for key proofs
226+
(`proof_signing_alg_values_supported`). What they attest is not evaluated,
227+
so neither changes the key a credential is bound to.
222228
- Holder binding is stated in a `cnf` claim, in every credential format.
223229
- Each credential configuration decides for itself whether a key proof is
224230
required and which identifier rules apply to it. See

‎src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php‎

Lines changed: 66 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ public function __construct(
109109
* section 7.2 recommends (OidcServerException::buildResourceChallenges()). A refused token is named in the
110110
* challenge of the scheme it was presented under (`error="invalid_token"`), and a request which carried none
111111
* gets both challenges with no error and no body (RFC 6750 section 3.1); these come with the refusal. A 401
112-
* from anywhere else gets both challenges with no error.
112+
* from anywhere else gets both challenges with no error. The 403 for a token which does not grant the
113+
* credential asked for carries its challenge too, `error="insufficient_scope"` (tokenErrorResponse()).
113114
*
114115
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
115116
* @throws \ReflectionException
@@ -145,14 +146,30 @@ public function credential(Request $request): Response
145146
*/
146147
protected function invalidTokenResponse(string $description, AccessTokenTypesEnum $scheme): Response
147148
{
149+
return $this->tokenErrorResponse('invalid_token', $description, 401, $scheme);
150+
}
151+
152+
153+
/**
154+
* A refusal for the access token as RFC 6750 section 3 has a protected resource answer one: the error, its
155+
* status, and the `WWW-Authenticate` challenge it MUST carry when the token "does not contain an access token
156+
* that enables access to the protected resource", naming the error under the scheme the token was presented
157+
* under (OidcServerException::buildResourceChallenges()).
158+
*/
159+
protected function tokenErrorResponse(
160+
string $error,
161+
string $description,
162+
int $httpCode,
163+
AccessTokenTypesEnum $scheme,
164+
): Response {
148165
return $this->routes->newJsonErrorResponse(
149-
'invalid_token',
166+
$error,
150167
$description,
151-
401,
168+
$httpCode,
152169
[
153170
'WWW-Authenticate' => OidcServerException::buildResourceChallenges(
154171
$scheme,
155-
'invalid_token',
172+
$error,
156173
$this->moduleConfig->getDpopSigningAlgorithms(),
157174
),
158175
],
@@ -242,9 +259,35 @@ protected function issueCredential(Request $request): Response
242259
// the code carrying it for this token (AuthCodeGrant), so the token can be used for as many credential
243260
// requests as its lifetime allows, like one which followed no offer (OpenID4VCI 1.0 section 14.3).
244261

262+
// The user a credential would be about, checked with the rest of the token and before anything the
263+
// request asks for: a token naming no user, or one who no longer exists, buys no credential whatever is
264+
// asked with it, which no change to the request would mend.
265+
$userId = $accessToken->getUserIdentifier();
266+
if (!is_string($userId)) {
267+
$this->loggerService->error(
268+
'CredentialIssuerCredentialController::credential: Access token names no user.',
269+
['accessTokenId' => $accessToken->getIdentifier()],
270+
);
271+
return $this->invalidTokenResponse('The access token names no user.', $scheme);
272+
}
273+
$userEntity = $this->userRepository->getUserEntityByIdentifier($userId);
274+
if ($userEntity === null) {
275+
$this->loggerService->error('User entity not found.', ['userId' => $userId]);
276+
return $this->invalidTokenResponse(
277+
'The user the access token was issued for no longer exists.',
278+
$scheme,
279+
);
280+
}
281+
282+
// A request names its credential by `credential_identifier` when the token response returned authorization
283+
// details, by `credential_configuration_id` otherwise, and never by both (OpenID4VCI 1.0 section 8.2).
284+
// Which members it carries is read by name rather than by value, so a member sent as null still counts as
285+
// sent: next to the other one the request names its credential both ways, and alone it names nothing.
286+
$hasCredentialIdentifier = array_key_exists(ClaimsEnum::CredentialIdentifier->value, $requestData);
287+
245288
if (
246-
isset($requestData[ClaimsEnum::CredentialConfigurationId->value]) &&
247-
isset($requestData[ClaimsEnum::CredentialIdentifier->value])
289+
$hasCredentialIdentifier &&
290+
array_key_exists(ClaimsEnum::CredentialConfigurationId->value, $requestData)
248291
) {
249292
$this->loggerService->error(
250293
'CredentialIssuerCredentialController::credential: Credential configuration ID ' .
@@ -259,9 +302,6 @@ protected function issueCredential(Request $request): Response
259302
);
260303
}
261304

262-
// Resolve the requested credential identifier.
263-
$resolvedCredentialIdentifier = null;
264-
265305
/** @psalm-suppress MixedAssignment */
266306
$credentialIdentifier = $requestData[ClaimsEnum::CredentialIdentifier->value] ?? null;
267307

@@ -271,14 +311,14 @@ protected function issueCredential(Request $request): Response
271311
// section 8.2), so one sent for a flow which used no `authorization_details`, and so was issued none,
272312
// is unknown rather than ignored.
273313
$authorizationDetails = $accessToken->getAuthorizationDetails();
274-
if ($authorizationDetails !== null || $credentialIdentifier !== null) {
275-
if (!is_string($credentialIdentifier)) {
314+
if ($authorizationDetails !== null || $hasCredentialIdentifier) {
315+
if (!is_string($credentialIdentifier) || $credentialIdentifier === '') {
276316
$this->loggerService->error(
277317
'CredentialIssuerCredentialController::credential: Credential identifier missing in request.',
278318
);
279319
return $this->routes->newJsonErrorResponse(
280320
'invalid_credential_request',
281-
'Can not resolve credential identifier.',
321+
'The credential_identifier parameter is missing, or is not a non-empty string.',
282322
400,
283323
);
284324
}
@@ -333,118 +373,29 @@ protected function issueCredential(Request $request): Response
333373
['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier],
334374
);
335375
} else {
336-
$this->loggerService->debug(
337-
'No authorization details in the access token and no credential identifier in the request. ' .
338-
'Skipping resolution from "credential_identifier".',
339-
);
340-
}
341-
342-
if (!is_string($resolvedCredentialIdentifier)) {
343-
$this->loggerService->debug('Resolving credential identifier from "credential_configuration_id".');
344-
376+
// The one other way a request names its credential. What came before OpenID4VCI 1.0, a `format`
377+
// with a credential type or a `vct`, is not read: a request naming neither identifier is malformed.
345378
/** @psalm-suppress MixedAssignment */
346379
$credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null;
347380

348-
if (is_string($credentialConfigurationId)) {
349-
/** @psalm-suppress MixedAssignment */
350-
$resolvedCredentialIdentifier = $credentialConfigurationId;
351-
352-
$this->loggerService->debug(
353-
'Resolved credential identifier from "credential_configuration_id" parameter.',
354-
['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier],
381+
if (!is_string($credentialConfigurationId) || $credentialConfigurationId === '') {
382+
$this->loggerService->error(
383+
'CredentialIssuerCredentialController::credential: The request names its credential by ' .
384+
'neither credential_identifier nor credential_configuration_id.',
355385
);
356-
} else {
357-
$this->loggerService->warning('Credential identifier not provided in request parameters.');
358-
}
359-
}
360-
361-
if (!is_string($resolvedCredentialIdentifier)) {
362-
$this->loggerService->warning(
363-
'CredentialIssuerCredentialController::credential: No credential identifier found in request. ' .
364-
'Falling back to resolution from format and credential type.',
365-
);
366-
367-
$requestedCredentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null;
368-
369-
if (!is_string($requestedCredentialFormatId)) {
370-
$this->loggerService->error('Credential format missing in request (fallback resolution failed).');
371386
return $this->routes->newJsonErrorResponse(
372387
'invalid_credential_request',
373-
'Can not resolve credential format.',
388+
'The request must name the credential it asks for in the credential_configuration_id ' .
389+
'parameter.',
374390
400,
375391
);
376392
}
377393

378-
if (
379-
!in_array($requestedCredentialFormatId, [
380-
CredentialFormatIdentifiersEnum::JwtVcJson->value,
381-
CredentialFormatIdentifiersEnum::DcSdJwt->value,
382-
CredentialFormatIdentifiersEnum::VcSdJwt->value,
383-
])
384-
) {
385-
$this->loggerService->error(
386-
'CredentialIssuerCredentialController::credential: Unsupported credential format.',
387-
['requestedCredentialFormatId' => $requestedCredentialFormatId],
388-
);
389-
return $this->routes->newJsonErrorResponse(
390-
'unsupported_credential_type',
391-
sprintf('Credential format ID "%s" is not supported.', $requestedCredentialFormatId),
392-
400,
393-
);
394-
}
394+
$resolvedCredentialIdentifier = $credentialConfigurationId;
395395

396396
$this->loggerService->debug(
397-
'Resolved requested credential format.',
398-
['format' => $requestedCredentialFormatId],
399-
);
400-
401-
$fallbackCredentialConfigurationId = null;
402-
403-
// TODO mivanci Update this to newest draft.
404-
// Check per draft 14 (Sphereon wallet case).
405-
/** @psalm-suppress MixedAssignment */
406-
if (
407-
$requestedCredentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value &&
408-
is_array(
409-
$credentialDefinitionType =
410-
$requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null,
411-
)
412-
) {
413-
$this->loggerService->debug(
414-
'Resolving configuration from credential definition types.',
415-
['types' => $credentialDefinitionType],
416-
);
417-
$fallbackCredentialConfigurationId =
418-
$this->moduleConfig->getVciCredentialConfigurationIdForCredentialDefinitionType(
419-
$credentialDefinitionType,
420-
);
421-
} elseif (
422-
in_array($requestedCredentialFormatId, self::SD_JWT_FORMAT_IDS, true) &&
423-
is_string($vct = $requestData[ClaimsEnum::Vct->value] ?? null)
424-
) {
425-
$this->loggerService->debug(
426-
'Resolving configuration from VCT parameter.',
427-
['vct' => $vct],
428-
);
429-
$fallbackCredentialConfigurationId = $vct;
430-
}
431-
432-
if (!is_string($fallbackCredentialConfigurationId)) {
433-
$this->loggerService->error('Fallback resolution failed to find a valid credential configuration.');
434-
} else {
435-
$this->loggerService->debug(
436-
'Resolved credential identifier via fallback mechanism.',
437-
['resolvedCredentialIdentifier' => $fallbackCredentialConfigurationId],
438-
);
439-
440-
$resolvedCredentialIdentifier = $fallbackCredentialConfigurationId;
441-
}
442-
}
443-
if (!is_string($resolvedCredentialIdentifier)) {
444-
return $this->routes->newJsonErrorResponse(
445-
'invalid_credential_request',
446-
'Can not resolve credential configuration ID.',
447-
400,
397+
'Resolved credential identifier from "credential_configuration_id" parameter.',
398+
['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier],
448399
);
449400
}
450401

@@ -458,7 +409,7 @@ protected function issueCredential(Request $request): Response
458409
if (!is_array($resolvedCredentialConfiguration)) {
459410
return $this->routes->newJsonErrorResponse(
460411
'unknown_credential_configuration',
461-
sprintf('Credential ID "%s" is not supported.', $resolvedCredentialIdentifier),
412+
'The credential configuration the request names is not supported.',
462413
400,
463414
);
464415
}
@@ -477,10 +428,11 @@ protected function issueCredential(Request $request): Response
477428
'accessTokenId' => $accessToken->getIdentifier(),
478429
],
479430
);
480-
return $this->routes->newJsonErrorResponse(
431+
return $this->tokenErrorResponse(
481432
'insufficient_scope',
482433
'The access token does not grant the requested credential.',
483434
403,
435+
$scheme,
484436
);
485437
}
486438

@@ -496,15 +448,6 @@ protected function issueCredential(Request $request): Response
496448
);
497449
}
498450

499-
$userId = $accessToken->getUserIdentifier();
500-
if (!is_string($userId)) {
501-
throw OidcServerException::invalidRequest('User identifier not available in Access Token.');
502-
}
503-
$userEntity = $this->userRepository->getUserEntityByIdentifier($userId);
504-
if ($userEntity === null) {
505-
$this->loggerService->error('User entity not found.', ['userId' => $userId]);
506-
throw OidcServerException::invalidRequest('User not found.');
507-
}
508451
$this->loggerService->info('Issuing credential for user.', ['userId' => $userId]);
509452

510453
// Every key proof is validated before anything at all is issued. Validating and issuing in one

‎src/Exceptions/CredentialRequestException.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* choice with the check that made it, rather than flattening every refusal to one code at the boundary.
1414
*
1515
* The message is returned to the wallet as `error_description`, so it says what is wrong with the
16-
* request without repeating back anything the request itself supplied.
16+
* request without repeating back anything the request itself supplied, in the characters OpenID4VCI 1.0
17+
* section 8.3.1.2 allows there: printable ASCII without the double quote and the backslash. Parameter names
18+
* are written bare for that reason.
1719
*/
1820
class CredentialRequestException extends OidcException
1921
{

‎src/ModuleConfig.php‎

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,33 +3096,6 @@ public function getVciScopes(): array
30963096
}
30973097

30983098

3099-
public function getVciCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string
3100-
{
3101-
foreach (
3102-
$this->getVciCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration
3103-
) {
3104-
if (!is_array($credentialConfiguration)) {
3105-
continue;
3106-
}
3107-
3108-
$credentialDefinition = $credentialConfiguration[ClaimsEnum::CredentialDefinition->value] ?? null;
3109-
3110-
if (!is_array($credentialDefinition)) {
3111-
continue;
3112-
}
3113-
3114-
/** @psalm-suppress MixedAssignment */
3115-
$configuredType = $credentialDefinition[ClaimsEnum::Type->value] ?? null;
3116-
3117-
if ($configuredType === $credentialDefinitionType) {
3118-
return (string)$credentialConfigurationId;
3119-
}
3120-
}
3121-
3122-
return null;
3123-
}
3124-
3125-
31263099
/**
31273100
* Extract and parse the claims path definition from the credential configuration supported.
31283101
* Returns an array of valid paths for the claims.

‎src/Utils/Routes.php‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,25 @@ public function newJsonResponse(
6565
}
6666

6767

68+
/**
69+
* An error response of the OAuth 2.0 shape, `error` and `error_description`.
70+
*
71+
* The description keeps only the characters RFC 6749 section 5.2 allows in one, %x20-21 / %x23-5B / %x5D-7E:
72+
* printable ASCII without the double quote and the backslash. OpenID4VCI 1.0 section 8.3.1.2 has the same
73+
* rule for Credential Request errors. Several callers pass an exception message, so what arrives here is not
74+
* fixed where it is written, and the rest are dropped rather than escaped, since no escape is allowed either.
75+
*/
6876
public function newJsonErrorResponse(
6977
string $error,
7078
string $description,
7179
int $httpCode = 500,
7280
array $headers = [],
7381
): JsonResponse {
7482
return $this->newJsonResponse(
75-
['error' => $error, 'error_description' => $description],
83+
[
84+
'error' => $error,
85+
'error_description' => (string)preg_replace('/[^\x20\x21\x23-\x5B\x5D-\x7E]/', '', $description),
86+
],
7687
$httpCode,
7788
$headers,
7889
);

0 commit comments

Comments
 (0)