Skip to content

Commit 313d691

Browse files
committed
Support refresh token introspection
1 parent 461c0bd commit 313d691

7 files changed

Lines changed: 219 additions & 31 deletions

File tree

docker/ssp/config-override.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
'language.i18n.backend' => 'gettext/gettext',
1515
'logging.level' => 7,
1616
'usenewui' => false,
17+
1718
] + $config;

docker/ssp/module_oidc.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,16 @@
128128
ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [
129129
// Use defaults
130130
],
131+
132+
ModuleConfig::OPTION_API_ENABLED => true,
133+
134+
ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => true,
135+
136+
ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => true,
137+
138+
ModuleConfig::OPTION_API_TOKENS => [
139+
'strong-random-token-string' => [
140+
\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API.
141+
],
142+
],
131143
];

routing/services/services.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ services:
9696
SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~
9797
SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~
9898
SimpleSAML\Module\oidc\Utils\JwksResolver: ~
99+
SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~
99100
SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor:
100-
SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver:
101101
factory: ['@SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory', 'build']
102102
SimpleSAML\Module\oidc\Utils\FederationCache:
103103
factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forFederation'] # Can return null

src/Bridges/OAuth2Bridge.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\oidc\Bridges;
6+
7+
use Defuse\Crypto\Crypto;
8+
use Defuse\Crypto\Key;
9+
use SimpleSAML\Module\oidc\Exceptions\OidcException;
10+
use SimpleSAML\Module\oidc\ModuleConfig;
11+
12+
class OAuth2Bridge
13+
{
14+
public function __construct(
15+
protected readonly ModuleConfig $moduleConfig,
16+
) {
17+
}
18+
19+
/**
20+
* Bridge `encrypt` function, which can be used instead of
21+
* \League\OAuth2\Server\CryptTrait::encrypt()
22+
*
23+
* @param string $unencryptedData
24+
* @param Key|string $encryptionKey
25+
* @return string
26+
* @throws OidcException
27+
*/
28+
public function encrypt(
29+
string $unencryptedData,
30+
null|Key|string $encryptionKey = null,
31+
): string {
32+
$encryptionKey ??= $this->moduleConfig->getEncryptionKey();
33+
34+
try {
35+
return $encryptionKey instanceof Key ?
36+
Crypto::encrypt($unencryptedData, $encryptionKey) :
37+
Crypto::encryptWithPassword($unencryptedData, $encryptionKey);
38+
} catch (\Exception $e) {
39+
throw new OidcException('Error enrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e);
40+
}
41+
}
42+
43+
/**
44+
* Bridge `decrypt` function, which can be used instead of
45+
* \League\OAuth2\Server\CryptTrait::decrypt()
46+
*
47+
* @param string $encryptedData
48+
* @param Key|string $encryptionKey
49+
* @return string
50+
* @throws OidcException
51+
*/
52+
public function decrypt(
53+
string $encryptedData,
54+
null|Key|string $encryptionKey = null,
55+
): string {
56+
$encryptionKey ??= $this->moduleConfig->getEncryptionKey();
57+
58+
try {
59+
return $encryptionKey instanceof Key ?
60+
Crypto::decrypt($encryptedData, $encryptionKey) :
61+
Crypto::decryptWithPassword($encryptedData, $encryptionKey);
62+
} catch (\Exception $e) {
63+
throw new OidcException('Error decrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e);
64+
}
65+
}
66+
}

src/Controllers/OAuth2/TokenIntrospectionController.php

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace SimpleSAML\Module\oidc\Controllers\OAuth2;
66

7+
use SimpleSAML\Module\oidc\Bridges\OAuth2Bridge;
78
use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum;
89
use SimpleSAML\Module\oidc\Exceptions\AuthorizationException;
910
use SimpleSAML\Module\oidc\ModuleConfig;
11+
use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository;
1012
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
1113
use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator;
1214
use SimpleSAML\Module\oidc\Services\Api\Authorization;
@@ -34,6 +36,8 @@ public function __construct(
3436
protected readonly Authorization $apiAuthorization,
3537
protected readonly RequestParamsResolver $requestParamsResolver,
3638
protected readonly BearerTokenValidator $bearerTokenValidator,
39+
protected readonly OAuth2Bridge $oAuth2Bridge,
40+
protected readonly RefreshTokenRepository $refreshTokenRepository,
3741
) {
3842
if (!$this->moduleConfig->getApiEnabled()) {
3943
$this->loggerService->warning('API capabilities not enabled.');
@@ -48,7 +52,6 @@ public function __construct(
4852

4953
public function __invoke(Request $request): Response
5054
{
51-
// TODO mivanci Add support for Refresh Tokens.
5255
// TODO mivanci Add endpoint to OAuth2 discovery document.
5356

5457
try {
@@ -80,46 +83,140 @@ public function __invoke(Request $request): Response
8083
);
8184
}
8285

83-
// For now, we will only support Access Tokens.
84-
// $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods(
85-
// ParamsEnum::TokenTypeHint->value,
86-
// $request,
87-
// $allowedMethods,
88-
// );
86+
$tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods(
87+
ParamsEnum::TokenTypeHint->value,
88+
$request,
89+
$allowedMethods,
90+
);
8991

92+
$payload = null;
93+
if (is_null($tokenTypeHintParam)) {
94+
$payload = $this->resolveAccessTokenPayload($tokenParam) ??
95+
$this->resolveRefreshTokenPayload($tokenParam);
96+
} elseif ($tokenTypeHintParam === 'access_token') {
97+
$payload = $this->resolveAccessTokenPayload($tokenParam);
98+
} elseif ($tokenTypeHintParam === 'refresh_token') {
99+
$payload = $this->resolveRefreshTokenPayload($tokenParam);
100+
}
101+
102+
$payload = $payload ?? ['active' => false];
103+
104+
return $this->routes->newJsonResponse($payload);
105+
}
106+
107+
protected function resolveAccessTokenPayload(string $tokenParam): ?array
108+
{
90109
try {
91110
$accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam);
92111
} catch (\Throwable $e) {
93-
$this->loggerService->error('Token validation failed: ' . $e->getMessage());
94-
return $this->routes->newJsonResponse(['active' => false]);
112+
$this->loggerService->error('Access token validation failed: ' . $e->getMessage());
113+
return null;
95114
}
115+
116+
// See \SimpleSAML\Module\oidc\Entities\AccessTokenEntity::convertToJWT
117+
// for claims set on the access token.
118+
96119
$scopeClaim = null;
97120
/** @psalm-suppress MixedAssignment */
98121
$accessTokenScopes = $accessToken->getPayloadClaim('scopes');
99122
if (is_array($accessTokenScopes)) {
100-
$accessTokenScopes = array_filter(
101-
$accessTokenScopes,
102-
static fn($scope) => is_string($scope) && !empty($scope),
103-
);
104-
$scopeClaim = implode(' ', $accessTokenScopes);
123+
$scopeClaim = $this->prepareScopeString($accessTokenScopes);
105124
}
106125

107-
$audience = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null;
126+
$clientId = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null;
108127

109-
$payload = array_filter([
128+
return array_filter([
110129
'active' => true,
111130
'scope' => $scopeClaim,
131+
'client_id' => $clientId,
112132
'token_type' => 'Bearer',
113133
ClaimsEnum::Exp->value => $accessToken->getExpirationTime(),
114134
ClaimsEnum::Iat->value => $accessToken->getIssuedAt(),
115135
ClaimsEnum::Nbf->value => $accessToken->getNotBefore(),
116136
ClaimsEnum::Sub->value => $accessToken->getSubject(),
117-
ClaimsEnum::Aud->value => $audience,
137+
ClaimsEnum::Aud->value => $accessToken->getAudience(),
118138
ClaimsEnum::Iss->value => $accessToken->getIssuer(),
119139
ClaimsEnum::Jti->value => $accessToken->getJwtId(),
120140
]);
141+
}
121142

122-
return $this->routes->newJsonResponse($payload);
143+
/**
144+
* @psalm-suppress MixedAssignment
145+
*/
146+
public function resolveRefreshTokenPayload(string $tokenParam): ?array
147+
{
148+
try {
149+
$decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam);
150+
$tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR);
151+
} catch (\Exception $e) {
152+
$this->loggerService->error('Refresh token decrypting failed: ' . $e->getMessage());
153+
return null;
154+
}
155+
156+
if (!is_array($tokenData)) {
157+
$this->loggerService->error('Refresh token has unexpected type.');
158+
return null;
159+
}
160+
161+
// See \League\OAuth2\Server\ResponseTypes\BearerTokenResponse::generateHttpResponse for claims set on
162+
// the refresh token.
163+
164+
$expireTime = is_int($expireTime = $tokenData['expire_time'] ?? null) ? $expireTime : null;
165+
166+
if (is_null($expireTime)) {
167+
$this->loggerService->error('Refresh token has no expiration time.');
168+
return null;
169+
}
170+
171+
if ($expireTime < time()) {
172+
$this->loggerService->error('Refresh token has expired.');
173+
return null;
174+
}
175+
176+
$refreshTokenId = is_string($refreshTokenId = $tokenData['refresh_token_id'] ?? null) ? $refreshTokenId : null;
177+
178+
if (is_null($refreshTokenId)) {
179+
$this->loggerService->error('Refresh token has no ID.');
180+
return null;
181+
}
182+
183+
try {
184+
if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenId)) {
185+
$this->loggerService->error('Refresh token has been revoked.');
186+
return null;
187+
}
188+
} catch (OidcServerException $e) {
189+
$this->loggerService->error('Refresh token revocation check failed: ' . $e->getMessage());
190+
return null;
191+
}
192+
193+
$scopeClaim = null;
194+
$refreshTokenScopes = $tokenData['scopes'] ?? null;
195+
if (is_array($refreshTokenScopes)) {
196+
$scopeClaim = $this->prepareScopeString($refreshTokenScopes);
197+
}
198+
199+
$clientId = is_string($clientId = $tokenData['client_id'] ?? null) ? $clientId : null;
200+
201+
return array_filter([
202+
'active' => true,
203+
'scope' => $scopeClaim,
204+
'client_id' => $clientId,
205+
ClaimsEnum::Exp->value => $expireTime,
206+
ClaimsEnum::Sub->value => is_string($tokenData['user_id'] ?? null) ? $tokenData['user_id'] : null,
207+
ClaimsEnum::Aud->value => $clientId,
208+
ClaimsEnum::Jti->value => $refreshTokenId,
209+
]);
210+
}
211+
212+
protected function prepareScopeString(array $scopes): string
213+
{
214+
$scopes = array_filter(
215+
$scopes,
216+
static fn($scope) => is_string($scope) && !empty($scope),
217+
);
218+
219+
return implode(' ', $scopes);
123220
}
124221

125222
/**
@@ -138,19 +235,24 @@ protected function ensureAuthenticatedClient(Request $request): void
138235
$resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone()
139236
) {
140237
$this->loggerService->debug(
141-
'Client authenticated using supported OAuth2 client authentication method: ' .
142-
$resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value,
238+
sprintf(
239+
'Client %s authenticated using supported OAuth2 client authentication method %s.',
240+
$resolvedClientAuthenticationMethod->getClient()->getIdentifier(),
241+
$resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value,
242+
),
143243
);
244+
144245
return;
145246
}
146247

147248
$this->loggerService->debug('No regular OAuth2 client authentication method found.');
148249
$this->loggerService->debug('Trying API client authentication method.');
149250

150-
151251
$this->apiAuthorization->requireTokenForAnyOfScope(
152252
$request,
153253
[ApiScopesEnum::OAuth2TokenIntrospection, ApiScopesEnum::OAuth2All, ApiScopesEnum::All],
154254
);
255+
256+
$this->loggerService->debug('API client authenticated.');
155257
}
156258
}

src/Services/Api/Authorization.php

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,26 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope
6060
}
6161

6262
if (empty($token = $this->findToken($request))) {
63-
throw new AuthorizationException(Translate::noop('Token not provided.'));
63+
throw new AuthorizationException(Translate::noop('Authorization token not provided.'));
6464
}
6565

6666
if (empty($tokenScopes = $this->moduleConfig->getApiTokenScopes($token))) {
67-
throw new AuthorizationException(Translate::noop('Token does not have defined scopes.'));
67+
throw new AuthorizationException(Translate::noop('Authorization token does not have defined scopes.'));
6868
}
6969

7070
$hasAny = !empty(array_filter($tokenScopes, fn($tokenScope) => in_array($tokenScope, $requiredScopes, true)));
7171

7272
if (!$hasAny) {
73-
throw new AuthorizationException(Translate::noop('Token is not authorized.'));
73+
throw new AuthorizationException(Translate::noop('Authorization token is not authorized for this action.'));
7474
}
7575
}
7676

7777
protected function findToken(Request $request): ?string
7878
{
79-
/** @psalm-suppress InternalMethod */
80-
if ($token = trim((string) $request->get(self::KEY_TOKEN))) {
81-
return $token;
82-
}
83-
84-
if ($request->headers->has(self::KEY_AUTHORIZATION)) {
79+
if (
80+
is_string($authorizationHeader = $request->headers->get(self::KEY_AUTHORIZATION))
81+
&& str_starts_with($authorizationHeader, 'Bearer ')
82+
) {
8583
return trim(
8684
(string) preg_replace(
8785
'/^\s*Bearer\s/',
@@ -91,6 +89,12 @@ protected function findToken(Request $request): ?string
9189
);
9290
}
9391

92+
// Fallback to token parameter.
93+
/** @psalm-suppress InternalMethod */
94+
if ($token = trim((string) $request->get(self::KEY_TOKEN))) {
95+
return $token;
96+
}
97+
9498
return null;
9599
}
96100
}

tests/unit/src/Server/Validators/BearerTokenValidatorTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public function setUp(): void
5151
$this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class);
5252
$this->serverRequest = new ServerRequest();
5353
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
54+
$this->moduleConfigMock->method('getIssuer')->willReturn('issuer123');
5455

5556
$this->jwsMock = $this->createMock(Jws::class);
5657
$this->jwksMock = $this->createMock(Jwks::class);
@@ -62,6 +63,7 @@ public function setUp(): void
6263

6364
$this->accessTokenState = [
6465
'id' => 'accessToken123',
66+
'iss' => 'issuer123',
6567
'scopes' => '{"openid":"openid","profile":"profile"}',
6668
'expires_at' => date('Y-m-d H:i:s', time() + 60),
6769
'user_id' => 'user123',
@@ -80,6 +82,7 @@ public function setUp(): void
8082
$this->parsedJwsMock = $this->createMock(ParsedJws::class);
8183
$this->parsedJwsMock->method('getJwtId')->willReturn('accessToken123');
8284
$this->parsedJwsMock->method('getAudience')->willReturn([$this->clientId]);
85+
$this->parsedJwsMock->method('getIssuer')->willReturn('issuer123');
8386
}
8487

8588
protected function sut(

0 commit comments

Comments
 (0)