Skip to content

Commit 5cfa0bc

Browse files
committed
Refuse access tokens at UserInfo and the credential endpoint as RFC 6750 section 3 has it
- Both protected resources answered every token problem with access_denied, and UserInfo's 401 carried no WWW-Authenticate challenge. A token which arrived and fails a check is now invalid_token (401) with the challenge ; a request which carried no token gets 401, the bare challenge and no body (RFC 6750 section 3.1). A failure of the OP's own while it checks the token is server_error (500), on the split the introspection endpoint already makes; a claim the library can not read after the checks no longer escapes as an uncaught exception. - An Authorization header under another scheme counts as no token instead of being read as one and refused as invalid, and the Bearer scheme name is matched case-insensitively (RFC 9110 section 11.1). - The challenge and the body-less response belong to OidcServerException, so both error renderers carry them (forExceptionJson now returns a Response); the credential endpoint's own invalid_token answers carry the same challenge. The upgrade guide says what changes for UserInfo.
1 parent 163e64f commit 5cfa0bc

10 files changed

Lines changed: 860 additions & 84 deletions

File tree

‎docs/6-oidc-upgrade.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,21 @@ answers any unexpected failure while processing a request in the token error
702702
format (`{"error": "server_error", ...}`, HTTP 500, with the cause in the OP
703703
log rather than in the response) instead of with SimpleSAMLphp's HTML error
704704
page.
705+
- The UserInfo endpoint now refuses an access token as RFC 6750 section 3 has
706+
it (OpenID Connect Core 1.0 section 5.3.3). A token which is expired, revoked,
707+
malformed or otherwise not accepted is answered with `invalid_token` instead of
708+
`access_denied`, still with HTTP 401, and the response now carries the
709+
`WWW-Authenticate: Bearer error="invalid_token"` challenge it lacked. A request
710+
which carries no access token is answered with HTTP 401, the bare
711+
`WWW-Authenticate: Bearer` challenge and no body, instead of an `access_denied`
712+
JSON error. An `Authorization` header under a scheme other than `Bearer`
713+
(`Basic`, say) now counts as no token, where it used to be read as one and
714+
refused as invalid, and the `Bearer` scheme name is now matched
715+
case-insensitively. A failure of the OP while it checks the token, such as a
716+
database which does not answer, is answered with `server_error` (HTTP 500)
717+
instead of a 401. A client which acts on the 401 status is unaffected; one
718+
which compares the `error` member with `access_denied` finds `invalid_token` for
719+
a refused token, and no body at all for a request which carried none.
705720
- The token introspection endpoint now tells an access token from a refresh
706721
token by the token itself, and no longer by `token_type_hint`. Previously a
707722
hint naming the other type answered `active: false` for a valid token, and an

‎src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,9 @@ public function __construct(
103103
*
104104
* Every 401 this endpoint answers carries a Bearer challenge, whichever check made it a 401: HTTP
105105
* requires a challenge of any 401 (RFC 9110 section 15.5.2), and RFC 6750 section 3 has a protected
106-
* resource send one when the access token is missing or does not authorize the request. The challenge
107-
* names the scheme only; the error code is in the JSON body.
106+
* resource send one when the access token is missing or does not authorize the request. A refused token
107+
* is named in it (`error="invalid_token"`), and a request which carried none gets the scheme alone and no
108+
* body (RFC 6750 section 3.1); both come with the refusal. A 401 from anywhere else gets the scheme alone.
108109
*
109110
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
110111
* @throws \ReflectionException
@@ -119,13 +120,28 @@ public function credential(Request $request): Response
119120
}
120121

121122
if ($response->getStatusCode() === 401 && !$response->headers->has('WWW-Authenticate')) {
122-
$response->headers->set('WWW-Authenticate', 'Bearer');
123+
$response->headers->set('WWW-Authenticate', OidcServerException::CHALLENGE_BEARER);
123124
}
124125

125126
return $response;
126127
}
127128

128129

130+
/**
131+
* Refuses an access token which passed the resource server but can not buy a credential, as the resource
132+
* server refuses one (RFC 6750 section 3.1): `invalid_token`, with the error named in the challenge too.
133+
*/
134+
protected function invalidTokenResponse(string $description): Response
135+
{
136+
return $this->routes->newJsonErrorResponse(
137+
'invalid_token',
138+
$description,
139+
401,
140+
['WWW-Authenticate' => OidcServerException::CHALLENGE_INVALID_TOKEN],
141+
);
142+
}
143+
144+
129145
/**
130146
* @throws \League\OAuth2\Server\Exception\OAuthServerException
131147
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
@@ -157,20 +173,12 @@ protected function issueCredential(Request $request): Response
157173

158174
if (! $accessToken instanceof AccessTokenEntity) {
159175
$this->loggerService->error('Access token not found in repository.');
160-
return $this->routes->newJsonErrorResponse(
161-
'invalid_token',
162-
'Access token not found.',
163-
401,
164-
);
176+
return $this->invalidTokenResponse('Access token not found.');
165177
}
166178

167179
if ($accessToken->isRevoked()) {
168180
$this->loggerService->error('Access token is revoked.', ['accessTokenId' => $accessToken->getIdentifier()]);
169-
return $this->routes->newJsonErrorResponse(
170-
'invalid_token',
171-
'Access token is revoked.',
172-
401,
173-
);
181+
return $this->invalidTokenResponse('Access token is revoked.');
174182
}
175183

176184
if (
@@ -181,11 +189,7 @@ protected function issueCredential(Request $request): Response
181189
'Access token is not intended for Verifiable Credential Issuance.',
182190
['flowType' => $flowType?->value, 'accessTokenId' => $accessToken->getIdentifier()],
183191
);
184-
return $this->routes->newJsonErrorResponse(
185-
'invalid_token',
186-
'Access token is not intended for verifiable credential issuance.',
187-
401,
188-
);
192+
return $this->invalidTokenResponse('Access token is not intended for verifiable credential issuance.');
189193
}
190194

191195
// A token which can not buy a credential is refused as RFC 6750 section 3 has it (OpenID4VCI 1.0 section
@@ -201,11 +205,7 @@ protected function issueCredential(Request $request): Response
201205
'to a client which is not registered.',
202206
['accessTokenState' => $accessToken->getState()],
203207
);
204-
return $this->routes->newJsonErrorResponse(
205-
'invalid_token',
206-
'Issuer state missing in access token.',
207-
401,
208-
);
208+
return $this->invalidTokenResponse('Issuer state missing in access token.');
209209
}
210210

211211
// An issuer state on the token is not checked again here. The token endpoint spent it when it exchanged

‎src/Server/Exceptions/OidcServerException.php‎

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@
1616

1717
class OidcServerException extends OAuthServerException
1818
{
19+
/**
20+
* The challenge a protected resource sends with its refusal of the access token a request carried (RFC 6750
21+
* section 3).
22+
*/
23+
public const string CHALLENGE_INVALID_TOKEN = 'Bearer error="invalid_token"';
24+
25+
/**
26+
* The challenge a protected resource sends to a request which carried no access token: the scheme alone, with
27+
* no error code (RFC 6750 section 3.1).
28+
*/
29+
public const string CHALLENGE_BEARER = 'Bearer';
30+
31+
/**
32+
* The error type of a refusal for want of an access token. RFC 6750 defines no code for it, so it is never
33+
* sent; it names the refusal in the log.
34+
*/
35+
public const string ERROR_TYPE_MISSING_TOKEN = 'missing_token';
36+
37+
1938
/**
2039
* @var null|string
2140
*/
@@ -26,6 +45,16 @@ class OidcServerException extends OAuthServerException
2645
*/
2746
protected ?ResponseModeInterface $responseMode = null;
2847

48+
/**
49+
* The WWW-Authenticate challenge the error response carries, or null for none.
50+
*/
51+
protected ?string $wwwAuthenticate = null;
52+
53+
/**
54+
* Whether the error response has a body.
55+
*/
56+
protected bool $hasBody = true;
57+
2958

3059
private static function create(
3160
string $message,
@@ -204,6 +233,60 @@ public static function accessDenied(
204233
}
205234

206235

236+
/**
237+
* A protected resource refusing the access token a request carried: one which is expired, revoked, malformed
238+
* or invalid for another reason (RFC 6750 section 3.1). The challenge names the error too, so that a client
239+
* which reads only the header learns that its token was refused.
240+
*
241+
* @param string|null $hint
242+
* @param \Throwable|null $previous
243+
* @return static
244+
*/
245+
public static function invalidToken(?string $hint = null, ?Throwable $previous = null): static
246+
{
247+
$e = self::create(
248+
'The access token is expired, revoked, malformed, or invalid for other reasons.',
249+
16,
250+
'invalid_token',
251+
401,
252+
$hint,
253+
null,
254+
$previous,
255+
);
256+
257+
$e->wwwAuthenticate = self::CHALLENGE_INVALID_TOKEN;
258+
259+
return $e;
260+
}
261+
262+
263+
/**
264+
* A protected resource refusing a request which carried no access token. RFC 6750 section 3.1 answers it with
265+
* the bare challenge and no error code: nothing the client sent was found wanting, and a client told that its
266+
* token was refused, when it sent none, may throw away a working one. So the response has no body, and the
267+
* error type and the message are for the log only.
268+
*
269+
* @param string|null $hint
270+
* @return static
271+
*/
272+
public static function missingToken(?string $hint = null): static
273+
{
274+
$e = self::create(
275+
'The request carried no access token.',
276+
17,
277+
self::ERROR_TYPE_MISSING_TOKEN,
278+
401,
279+
$hint,
280+
);
281+
282+
$e->wwwAuthenticate = self::CHALLENGE_BEARER;
283+
$e->hasBody = false;
284+
$e->setPayload([]);
285+
286+
return $e;
287+
}
288+
289+
207290
/**
208291
* The authenticated client is not authorized to use this authorization grant type or response type
209292
* (RFC 6749 sections 4.1.2.1 / 5.2).
@@ -480,6 +563,46 @@ public function setPayload(array $payload): void
480563
}
481564

482565

566+
/**
567+
* The WWW-Authenticate challenge the error response carries, or null for none.
568+
*/
569+
public function getWwwAuthenticate(): ?string
570+
{
571+
return $this->wwwAuthenticate;
572+
}
573+
574+
575+
/**
576+
* Whether the error response has a body. Only a refusal for want of an access token has none.
577+
*/
578+
public function hasBody(): bool
579+
{
580+
return $this->hasBody;
581+
}
582+
583+
584+
/**
585+
* The headers of the error response: League's, the challenge when there is one, and no JSON content type for
586+
* a response which has no body.
587+
*
588+
* @return array<string, string>
589+
*/
590+
public function getHttpHeaders(): array
591+
{
592+
$headers = parent::getHttpHeaders();
593+
594+
if ($this->wwwAuthenticate !== null) {
595+
$headers['WWW-Authenticate'] = $this->wwwAuthenticate;
596+
}
597+
598+
if (!$this->hasBody) {
599+
unset($headers['Content-type']);
600+
}
601+
602+
return $headers;
603+
}
604+
605+
483606
/**
484607
* @param string|null $redirectUri Set to string, or unset it with null
485608
*/
@@ -562,9 +685,11 @@ public function generateHttpResponse(
562685
$response = $response->withHeader($header, $content);
563686
}
564687

565-
$responseBody = json_encode($payload, $jsonOptions) ?: 'JSON encoding of payload failed';
688+
if ($this->hasBody) {
689+
$responseBody = json_encode($payload, $jsonOptions) ?: 'JSON encoding of payload failed';
566690

567-
$response->getBody()->write($responseBody);
691+
$response->getBody()->write($responseBody);
692+
}
568693

569694
return $response->withStatus($this->getHttpStatusCode());
570695
}

0 commit comments

Comments
 (0)