Skip to content

Commit 8eb5d44

Browse files
committed
Tell a client only about the tokens that were issued to it by default
1 parent 20d2905 commit 8eb5d44

11 files changed

Lines changed: 645 additions & 24 deletions

File tree

config/module_oidc.php.dist

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,6 +1890,31 @@ $config = [
18901890
*/
18911891
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => false,
18921892

1893+
/**
1894+
* (optional) Client IDs allowed to introspect tokens issued to any client,
1895+
* and not only to themselves. Default is an empty list.
1896+
*
1897+
* A client which authenticates at the introspection endpoint may always ask
1898+
* about tokens issued to itself, which tells it nothing it did not already
1899+
* hold. Asking about another client's token is a different matter, since
1900+
* the answer carries that token's subject, scopes and lifetime, so it is
1901+
* refused unless the client is named here. A refused request is answered as
1902+
* if the token was not active, rather than with an error, so that the
1903+
* endpoint can not be used to find out which tokens exist.
1904+
*
1905+
* This is deliberately a deployment decision instead of a client property.
1906+
* Were it registered client metadata, a client registering itself through
1907+
* Dynamic Client Registration could ask for the ability to read every other
1908+
* party's tokens.
1909+
*
1910+
* Callers authorized with an API token holding an introspection scope, and
1911+
* logged in SimpleSAMLphp administrators, may introspect any token and are
1912+
* not affected by this option.
1913+
*/
1914+
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS => [
1915+
// 'resource-server-client-id',
1916+
],
1917+
18931918
/**
18941919
* List of API tokens which can be used to access API endpoints based on
18951920
* given scopes. The format is: ['token' => [ApiScopesEnum]]

docs/6-oidc-upgrade.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,13 @@ setting allowed time tolerance for timestamp validation in artifacts like JWSs.
263263
multiple Federation-related signing algorithms and key pairs.
264264
- `ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED` -
265265
optional, enables the OAuth2 token introspection endpoint as per RFC7662.
266+
- `ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS` -
267+
optional, client IDs allowed to introspect tokens issued to any client (default
268+
`[]`). A client which authenticates at the introspection endpoint is otherwise
269+
told only about tokens issued to itself, and answered `active: false` for
270+
anyone else's. Name a resource server here if it introspects tokens issued to
271+
other clients. API tokens with an introspection scope and logged in
272+
administrators are unaffected.
266273
- `ModuleConfig::OPTION_PAR_REQUEST_URI_TTL` - optional, lifetime of a PAR
267274
`request_uri` (default `PT10M`).
268275
- `ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS` - optional,

docs/8-api.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,31 @@ authentication methods (Basic, Post, Private Key JWT, Bearer).
336336
* Or, if the request is authorized using an API Bearer Token with
337337
the appropriate scope.
338338

339+
Authenticating is not on its own permission to introspect any given token.
340+
A client which authenticates as itself is told about tokens issued to it, and
341+
answered with `active: false` for tokens issued to anyone else. It already
342+
holds its own tokens, so it learns nothing new about them, while another
343+
client's token would answer with that token's subject, scopes and lifetime.
344+
345+
A deployment which runs a resource server as a client of its own names it in
346+
`config/module_oidc.php`, and that client may then introspect any token:
347+
348+
```php
349+
use SimpleSAML\Module\oidc\ModuleConfig;
350+
351+
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS => [
352+
'resource-server-client-id',
353+
],
354+
```
355+
356+
This is configuration rather than client metadata on purpose: a client
357+
registering itself through Dynamic Client Registration must not be able to ask
358+
for the ability to read every other party's tokens.
359+
360+
Requests authorized with an API Bearer Token holding an introspection scope,
361+
and those made by a logged in SimpleSAMLphp administrator, may introspect any
362+
token and are unaffected by that option.
363+
339364
#### Request
340365

341366
The request is sent with `application/x-www-form-urlencoded` encoding with the
@@ -368,7 +393,9 @@ authorized the token.
368393
* __jti__ (string, optional): Identifier for the token.
369394

370395
If the token is not active, only the `active` field with a value of
371-
`false` is returned.
396+
`false` is returned. The same answer is given when the caller is not entitled
397+
to be told about the token, so an inactive answer does not distinguish a token
398+
which does not exist from one the caller may not see.
372399

373400
#### Sample 1
374401

src/Admin/ConfigOverview/ProtocolOverviewBuilder.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,32 @@ protected function buildApiSection(): Section
809809
ConfigOverviewValueTypeEnum::Text,
810810
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED,
811811
),
812+
$this->guardRow(
813+
Translate::noop('Token Introspection Resource Servers'),
814+
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS,
815+
function (): Row {
816+
$resourceServers = $this->moduleConfig
817+
->getApiOAuth2TokenIntrospectionResourceServerClientIds();
818+
819+
return new Row(
820+
Translate::noop('Token Introspection Resource Servers'),
821+
$resourceServers,
822+
ConfigOverviewValueTypeEnum::StringList,
823+
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS,
824+
$resourceServers === [] ?
825+
Translate::noop(
826+
'None, so a client authenticating at the introspection endpoint is only ' .
827+
'told about tokens issued to itself. API tokens and administrators are ' .
828+
'unaffected.',
829+
) :
830+
Translate::noop(
831+
'These clients may introspect tokens issued to any client, and not only ' .
832+
'their own, so each one can read every other client\'s token subject and ' .
833+
'scopes.',
834+
),
835+
);
836+
},
837+
),
812838
$this->buildSecretCountRow(
813839
Translate::noop('API Tokens'),
814840
$apiTokenCount,

src/Controllers/OAuth2/TokenIntrospectionController.php

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver;
1717
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
1818
use SimpleSAML\Module\oidc\Utils\Routes;
19+
use SimpleSAML\Module\oidc\ValueAbstracts\IntrospectionAuthorization;
1920
use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod;
2021
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
2122
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
@@ -53,7 +54,7 @@ public function __construct(
5354
public function __invoke(Request $request): Response
5455
{
5556
try {
56-
$this->ensureAuthenticatedClient($request);
57+
$introspectionAuthorization = $this->resolveIntrospectionAuthorization($request);
5758
} catch (AuthorizationException $e) {
5859
$this->loggerService->error(
5960
'TokenIntrospectionController::invoke: AuthorizationException: ' . $e->getMessage(),
@@ -89,21 +90,55 @@ public function __invoke(Request $request): Response
8990

9091
$payload = null;
9192
if (is_null($tokenTypeHintParam)) {
92-
$payload = $this->resolveAccessTokenPayload($tokenParam) ??
93-
$this->resolveRefreshTokenPayload($tokenParam);
93+
$payload = $this->resolveAccessTokenPayload($tokenParam, $introspectionAuthorization) ??
94+
$this->resolveRefreshTokenPayload($tokenParam, $introspectionAuthorization);
9495
} elseif ($tokenTypeHintParam === 'access_token') {
95-
$payload = $this->resolveAccessTokenPayload($tokenParam);
96+
$payload = $this->resolveAccessTokenPayload($tokenParam, $introspectionAuthorization);
9697
} elseif ($tokenTypeHintParam === 'refresh_token') {
97-
$payload = $this->resolveRefreshTokenPayload($tokenParam);
98+
$payload = $this->resolveRefreshTokenPayload($tokenParam, $introspectionAuthorization);
9899
}
99100

100101
$payload = $payload ?? ['active' => false];
101102

102103
return $this->routes->newJsonResponse($payload);
103104
}
104105

105-
protected function resolveAccessTokenPayload(string $tokenParam): ?array
106-
{
106+
/**
107+
* Whether the caller is to be told about a token issued to the given client, logging any refusal.
108+
*
109+
* Asked with the owner as the token itself gives it, before the payload is assembled: the payload has
110+
* its empty values dropped, so reading the owner back out of it would lose a client identifier which
111+
* PHP considers falsy, and refuse that client its own tokens.
112+
*/
113+
protected function isTokenIntrospectableBy(
114+
IntrospectionAuthorization $introspectionAuthorization,
115+
mixed $tokenClientId,
116+
): bool {
117+
$clientId = (is_string($tokenClientId) && $tokenClientId !== '') ? $tokenClientId : null;
118+
119+
if ($introspectionAuthorization->mayIntrospectTokenOf($clientId)) {
120+
return true;
121+
}
122+
123+
$this->loggerService->warning(
124+
sprintf(
125+
'Client %s asked about a token which was not issued to it. Answering as if the token ' .
126+
'was not active.',
127+
(string)$introspectionAuthorization->getClientId(),
128+
),
129+
);
130+
131+
// Deliberately the same answer an expired, revoked or made up token gets. Saying that the token
132+
// exists but is none of the caller's business would turn the endpoint into an oracle it could ask
133+
// about tokens it has come into possession of, which is what RFC 7662 section 2.2 has in mind when
134+
// it has an unauthorized request answered as an inactive token.
135+
return false;
136+
}
137+
138+
protected function resolveAccessTokenPayload(
139+
string $tokenParam,
140+
IntrospectionAuthorization $introspectionAuthorization,
141+
): ?array {
107142
try {
108143
$accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam);
109144
} catch (\Throwable $e) {
@@ -123,6 +158,10 @@ protected function resolveAccessTokenPayload(string $tokenParam): ?array
123158

124159
$clientId = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null;
125160

161+
if (!$this->isTokenIntrospectableBy($introspectionAuthorization, $clientId)) {
162+
return null;
163+
}
164+
126165
return array_filter([
127166
'active' => true,
128167
'scope' => $scopeClaim,
@@ -141,8 +180,10 @@ protected function resolveAccessTokenPayload(string $tokenParam): ?array
141180
/**
142181
* @psalm-suppress MixedAssignment
143182
*/
144-
public function resolveRefreshTokenPayload(string $tokenParam): ?array
145-
{
183+
protected function resolveRefreshTokenPayload(
184+
string $tokenParam,
185+
IntrospectionAuthorization $introspectionAuthorization,
186+
): ?array {
146187
try {
147188
$decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam);
148189
$tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR);
@@ -196,6 +237,10 @@ public function resolveRefreshTokenPayload(string $tokenParam): ?array
196237

197238
$clientId = is_string($clientId = $tokenData['client_id'] ?? null) ? $clientId : null;
198239

240+
if (!$this->isTokenIntrospectableBy($introspectionAuthorization, $clientId)) {
241+
return null;
242+
}
243+
199244
return array_filter([
200245
'active' => true,
201246
'scope' => $scopeClaim,
@@ -218,11 +263,19 @@ protected function prepareScopeString(array $scopes): string
218263
}
219264

220265
/**
266+
* Establish who is asking, and with it which tokens they are entitled to be told about.
267+
*
268+
* Authenticating is not on its own permission to introspect. A client which authenticates as itself is
269+
* held to its own tokens, since anything else would let any registered client - including one which
270+
* registered itself through Dynamic Client Registration - read the subject, scopes and lifetime of
271+
* tokens belonging to every other client of this OP.
272+
*
221273
* @throws AuthorizationException
274+
* @throws \Exception
222275
*/
223-
protected function ensureAuthenticatedClient(Request $request): void
276+
protected function resolveIntrospectionAuthorization(Request $request): IntrospectionAuthorization
224277
{
225-
$this->loggerService->debug('TokenIntrospectionController::ensureAuthenticatedClient - start');
278+
$this->loggerService->debug('TokenIntrospectionController::resolveIntrospectionAuthorization - start');
226279
$this->loggerService->debug('Trying supported OAuth2 client authentication methods.');
227280

228281
// First, try regular OAuth2 client authentication methods.
@@ -232,15 +285,34 @@ protected function ensureAuthenticatedClient(Request $request): void
232285
$resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod &&
233286
$resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone()
234287
) {
288+
$clientId = $resolvedClientAuthenticationMethod->getClient()->getIdentifier();
289+
235290
$this->loggerService->debug(
236291
sprintf(
237292
'Client %s authenticated using supported OAuth2 client authentication method %s.',
238-
$resolvedClientAuthenticationMethod->getClient()->getIdentifier(),
293+
$clientId,
239294
$resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value,
240295
),
241296
);
242297

243-
return;
298+
if (
299+
in_array(
300+
$clientId,
301+
$this->moduleConfig->getApiOAuth2TokenIntrospectionResourceServerClientIds(),
302+
true,
303+
)
304+
) {
305+
$this->loggerService->debug(
306+
sprintf(
307+
'Client %s is configured as a resource server, so it may introspect any token.',
308+
$clientId,
309+
),
310+
);
311+
312+
return IntrospectionAuthorization::forAnyToken();
313+
}
314+
315+
return IntrospectionAuthorization::forTokensOfClient($clientId);
244316
}
245317

246318
$this->loggerService->debug('No regular OAuth2 client authentication method found.');
@@ -252,5 +324,9 @@ protected function ensureAuthenticatedClient(Request $request): void
252324
);
253325

254326
$this->loggerService->debug('API client authenticated.');
327+
328+
// The administrative path. Reaching it means either a logged in SimpleSAMLphp administrator or an
329+
// API token the deployment issued and scoped by hand, neither of which is tied to a single client.
330+
return IntrospectionAuthorization::forAnyToken();
255331
}
256332
}

src/ModuleConfig.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ class ModuleConfig
152152
'api_vci_credential_status_endpoint_enabled';
153153
final public const string OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED =
154154
'api_oauth2_token_introspection_endpoint_enabled';
155+
final public const string OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS =
156+
'api_oauth2_token_introspection_resource_server_client_ids';
155157
final public const string OPTION_API_TOKENS = 'api_tokens';
156158

157159
/** Optional key naming an API token, so that an audit trail can say who made a change. */
@@ -2172,6 +2174,29 @@ public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool
21722174
return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false);
21732175
}
21742176

2177+
/**
2178+
* Clients allowed to introspect tokens issued to any client, and not only to themselves.
2179+
*
2180+
* Introspection is a protected resource's capability, and which resource servers a deployment has
2181+
* is something only that deployment knows, so it is named here instead of being a property of a
2182+
* client registration: were it registered metadata, a client could grant itself the ability to read
2183+
* every other party's tokens by asking for it during Dynamic Client Registration.
2184+
*
2185+
* @return list<string>
2186+
* @throws \Exception
2187+
*/
2188+
public function getApiOAuth2TokenIntrospectionResourceServerClientIds(): array
2189+
{
2190+
$clientIds = $this->config()->getOptionalArray(
2191+
self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_RESOURCE_SERVER_CLIENT_IDS,
2192+
[],
2193+
);
2194+
2195+
return array_values(
2196+
array_filter($clientIds, static fn(mixed $clientId): bool => is_string($clientId) && $clientId !== ''),
2197+
);
2198+
}
2199+
21752200
/**
21762201
* @return mixed[]|null
21772202
*/

0 commit comments

Comments
 (0)