@@ -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
0 commit comments