From 9df43c5a3eccf100f37c53fe9de28197196102cb Mon Sep 17 00:00:00 2001 From: Jovan Simonoski Date: Thu, 15 Jan 2026 09:28:13 +0100 Subject: [PATCH 01/20] added token introspect controller --- routing/routes/routes.php | 6 ++++++ src/Codebooks/RoutesEnum.php | 2 ++ src/Controllers/TokenIntrospectController.php | 8 ++++++++ 3 files changed, 16 insertions(+) create mode 100644 src/Controllers/TokenIntrospectController.php diff --git a/routing/routes/routes.php b/routing/routes/routes.php index caad53c7..ef90c05a 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -18,6 +18,9 @@ use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController; use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; + +use SimpleSAML\Module\oidc\Controllers\TokenIntrospectController; + use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -86,6 +89,9 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); + $routes->add(RoutesEnum::TokenIntrospect->name, RoutesEnum::TokenIntrospect->value) + ->controller([TokenIntrospectController::class, 'introspect']); + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6c17691a..20b7027c 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -40,6 +40,8 @@ enum RoutesEnum: string case Jwks = 'jwks'; case EndSession = 'end-session'; + case TokenIntrospect = 'token-introspect'; + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ diff --git a/src/Controllers/TokenIntrospectController.php b/src/Controllers/TokenIntrospectController.php new file mode 100644 index 00000000..48bf86ab --- /dev/null +++ b/src/Controllers/TokenIntrospectController.php @@ -0,0 +1,8 @@ + Date: Thu, 15 Jan 2026 15:18:44 +0100 Subject: [PATCH 02/20] added token introspect controller --- routing/routes/routes.php | 6 +- routing/services/services.yml | 6 + src/Codebooks/RoutesEnum.php | 2 +- src/Controllers/TokenIntrospectController.php | 8 -- .../TokenIntrospectionController.php | 114 ++++++++++++++++++ 5 files changed, 124 insertions(+), 12 deletions(-) delete mode 100644 src/Controllers/TokenIntrospectController.php create mode 100644 src/Controllers/TokenIntrospectionController.php diff --git a/routing/routes/routes.php b/routing/routes/routes.php index ef90c05a..4b82991c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -19,7 +19,7 @@ use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; -use SimpleSAML\Module\oidc\Controllers\TokenIntrospectController; +use SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -89,8 +89,8 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); - $routes->add(RoutesEnum::TokenIntrospect->name, RoutesEnum::TokenIntrospect->value) - ->controller([TokenIntrospectController::class, 'introspect']); + $routes->add(RoutesEnum::TokenIntrospection->name, RoutesEnum::TokenIntrospection->value) + ->controller(TokenIntrospectionController::class); /***************************************************************************************************************** * OpenID Federation diff --git a/routing/services/services.yml b/routing/services/services.yml index 60f08be9..0882a717 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -22,6 +22,12 @@ services: resource: '../../src/Controllers/*' tags: ['controller.service_arguments'] + SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController: + autowire: true + autoconfigure: true + public: true + tags: [ 'controller.service_arguments' ] + SimpleSAML\Module\oidc\Services\: resource: '../../src/Services/*' exclude: '../../src/Services/{Container.php}' diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 20b7027c..926c09a8 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -40,7 +40,7 @@ enum RoutesEnum: string case Jwks = 'jwks'; case EndSession = 'end-session'; - case TokenIntrospect = 'token-introspect'; + case TokenIntrospection = 'introspect'; /***************************************************************************************************************** * OpenID Federation diff --git a/src/Controllers/TokenIntrospectController.php b/src/Controllers/TokenIntrospectController.php deleted file mode 100644 index 48bf86ab..00000000 --- a/src/Controllers/TokenIntrospectController.php +++ /dev/null @@ -1,8 +0,0 @@ -getMethod() !== 'POST') { + return new JsonResponse([ + 'error' => 'invalid_request', + 'error_description' => 'Token introspection endpoint only accepts POST requests' + ], 405); + } + + $authHeader = $request->headers->get('Authorization'); + if (empty($authHeader)) { + return new JsonResponse([ + 'error' => 'invalid_client', + 'error_description' => 'Client authentication is required' + ], 401); + } + + if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader)) { + return new JsonResponse([ + 'error' => 'invalid_client', + 'error_description' => 'Invalid authorization header format' + ], 401); + } + + $contentType = $request->headers->get('Content-Type'); + if (empty($contentType) || !str_contains($contentType, 'application/x-www-form-urlencoded')) { + return new JsonResponse([ + 'error' => 'invalid_request', + 'error_description' => 'Content-Type must be application/x-www-form-urlencoded' + ], 400); + } + + $token = $request->request->get('token'); + if (empty($token)) { + return new JsonResponse([ + 'error' => 'invalid_request', + 'error_description' => 'Missing required parameter: token' + ], 400); + } + + return $this->introspectToken($token); + } + + private function introspectToken(string $token): JsonResponse + { + try { + // JWT format: header.payload.signature + $tokenParts = explode('.', $token); + + if (count($tokenParts) !== 3) { + return new JsonResponse(['active' => false], 200); + } + + $payload = json_decode(base64_decode(strtr($tokenParts[1], '-_', '+/')), true); + + if (!is_array($payload) || !isset($payload['jti'])) { + return new JsonResponse(['active' => false], 200); + } + + $tokenId = $payload['jti']; + + $accessToken = $this->accessTokenRepository->findById($tokenId); + + if (!$accessToken instanceof AccessTokenEntity) { + return new JsonResponse(['active' => false], 200); + } + + if ($accessToken->isRevoked()) { + return new JsonResponse(['active' => false], 200); + } + + if ($accessToken->getExpiryDateTime() < new \DateTimeImmutable()) { + return new JsonResponse(['active' => false], 200); + } + + $introspectionResponse = [ + 'active' => true, + 'scope' => implode(' ', array_map(fn($scope) => $scope->getIdentifier(), $accessToken->getScopes())), + 'client_id' => $accessToken->getClient()->getIdentifier(), + 'token_type' => 'Bearer', + 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), + ]; + + if (isset($payload['iat'])) { + $introspectionResponse['iat'] = $payload['iat']; + } + + return new JsonResponse($introspectionResponse, 200); + + } catch (\Exception $e) { + return new JsonResponse(['active' => false], 200); + } + } +} From e86482b783c4704ce438e315939528e26d3b6abe Mon Sep 17 00:00:00 2001 From: Jovan Simonoski Date: Fri, 16 Jan 2026 12:15:07 +0100 Subject: [PATCH 03/20] added token introspection controller (missing jwt check, client auth.) --- useful-scripts/useful-scripts.md | 164 +++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 useful-scripts/useful-scripts.md diff --git a/useful-scripts/useful-scripts.md b/useful-scripts/useful-scripts.md new file mode 100644 index 00000000..e26ae2af --- /dev/null +++ b/useful-scripts/useful-scripts.md @@ -0,0 +1,164 @@ +## Docker command: +``` +docker run --name ssp-oidc-dev \ + -v "$(pwd)":/var/simplesamlphp/staging-modules/oidc:ro \ + -e STAGINGCOMPOSERREPOS=oidc \ + -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-oidc:@dev" \ + -e SSP_ADMIN_PASSWORD=secret1 \ + -v "$(pwd)/docker/ssp/module_oidc.php":/var/simplesamlphp/config/module_oidc.php:ro \ + -v "$(pwd)/docker/ssp/authsources.php":/var/simplesamlphp/config/authsources.php:ro \ + -v "$(pwd)/docker/ssp/config-override.php":/var/simplesamlphp/config/config-override.php:ro \ + -v "$(pwd)/docker/ssp/oidc_module.crt":/var/simplesamlphp/cert/oidc_module.crt:ro \ + -v "$(pwd)/docker/ssp/oidc_module.key":/var/simplesamlphp/cert/oidc_module.key:ro \ + -v "$(pwd)/docker/apache-override.cf":/etc/apache2/sites-enabled/ssp-override.cf:ro \ + -p 443:443 cirrusid/simplesamlphp:v2.4.4 +``` + + +## Insert test token + +Run database migration before! + +``` +docker exec -it ssp-oidc-dev bash +``` + +``` +cat > /tmp/insert_client.php << 'EOF' +prepare("INSERT OR IGNORE INTO oidc_client (id, secret, name, description, auth_source, redirect_uri, scopes, is_enabled, is_confidential) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + +$stmt->execute([ + 'test-client-123', + 'test-secret-456', + 'Test Client', + 'Client for testing introspection', + 'example-userpass', + '["https://localhost/callback"]', + '["openid","profile","email"]', + 1, + 1 +]); + +echo "Client inserted!\n"; + +// Verify +$result = $db->query("SELECT id, name FROM oidc_client")->fetchAll(PDO::FETCH_ASSOC); +echo "Clients in database:\n"; +foreach ($result as $row) { + echo " - " . $row['id'] . " (" . $row['name'] . ")\n"; +} +EOF + +php /tmp/insert_client.php +``` + + +``` +cat > /tmp/test.php << 'EOF' +prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); +$stmt->execute(["test-user-123", '{"sub":"test-user-123","name":"Test User"}']); + +// Get first client +$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); +echo "Using client: " . $client["id"] . "\n"; + +// Generate token ID +$tokenId = "test-token-" . bin2hex(random_bytes(8)); +echo "Token ID: " . $tokenId . "\n"; + +// Insert access token +$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, datetime('now', '+1 hour'), ?, ?, 0)"); +$stmt->execute([$tokenId, '["openid","profile","email"]', "test-user-123", $client["id"]]); + +// Generate JWT +$header = rtrim(strtr(base64_encode(json_encode(["alg" => "RS256", "typ" => "JWT"])), "+/", "-_"), "="); +$payload = rtrim(strtr(base64_encode(json_encode(["jti" => $tokenId, "iat" => time(), "exp" => time() + 3600, "sub" => "test-user-123"])), "+/", "-_"), "="); +$jwt = "$header.$payload." . rtrim(strtr(base64_encode("sig"), "+/", "-_"), "="); + +echo "\nGenerated JWT:\n"; +echo $jwt . "\n"; +echo "\nTest with:\n"; +echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" -H \"Content-Type: application/x-www-form-urlencoded\" -H \"Authorization: Bearer test\" -d \"token=$jwt\" -k\n"; +EOF +``` + +``` +php /tmp/test.php +``` + + +## Single command (not tested) + +``` +docker exec -i ssp-oidc-dev bash -c ' +cat > /tmp/insert_client.php << "EOF" +prepare("INSERT OR IGNORE INTO oidc_client (id, secret, name, description, auth_source, redirect_uri, scopes, is_enabled, is_confidential) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + +$stmt->execute([ + "test-client-123", + "test-secret-456", + "Test Client", + "Client for testing introspection", + "example-userpass", + "[\"https://localhost/callback\"]", + "[\"openid\",\"profile\",\"email\"]", + 1, + 1 +]); + +echo "Client inserted!\n"; + +// Verify +$result = $db->query("SELECT id, name FROM oidc_client")->fetchAll(PDO::FETCH_ASSOC); +echo "Clients in database:\n"; +foreach ($result as $row) { + echo " - " . $row["id"] . " (" . $row["name"] . ")\n"; +} +EOF + +php /tmp/insert_client.php + +cat > /tmp/test.php << "EOF" +prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); +$stmt->execute(["test-user-123", "{\"sub\":\"test-user-123\",\"name\":\"Test User\"}"]); + +// Get first client +$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); +echo "Using client: " . $client["id"] . "\n"; + +// Generate token ID +$tokenId = "test-token-" . bin2hex(random_bytes(8)); +echo "Token ID: " . $tokenId . "\n"; + +// Insert access token +$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, datetime('now', '+1 hour'), ?, ?, 0)"); +$stmt->execute([$tokenId, "[\"openid\",\"profile\",\"email\"]", "test-user-123", $client["id"]]); + +// Generate fake JWT +$header = rtrim(strtr(base64_encode(json_encode(["alg" => "RS256", "typ" => "JWT"])), "+/", "-_"), "="); +$payload = rtrim(strtr(base64_encode(json_encode(["jti" => $tokenId, "iat" => time(), "exp" => time() + 3600, "sub" => "test-user-123"])), "+/", "-_"), "="); +$jwt = "$header.$payload." . rtrim(strtr(base64_encode("sig"), "+/", "-_"), "="); + +echo "\nGenerated JWT:\n$jwt\n"; +echo "\nTest with:\n"; +echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" -H \"Content-Type: application/x-www-form-urlencoded\" -H \"Authorization: Bearer test\" -d \"token=$jwt\" -k\n"; +EOF + +php /tmp/test.php +``` \ No newline at end of file From 2e743c2bbb01bc1df53b0d238da3861ddfa22303 Mon Sep 17 00:00:00 2001 From: Jovan Simonoski Date: Thu, 29 Jan 2026 12:03:23 +0100 Subject: [PATCH 04/20] added client authentication and code refactor/reuse --- routing/services/services.yml | 6 - .../TokenIntrospectionController.php | 132 +++++++++++------- 2 files changed, 84 insertions(+), 54 deletions(-) diff --git a/routing/services/services.yml b/routing/services/services.yml index 0882a717..60f08be9 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -22,12 +22,6 @@ services: resource: '../../src/Controllers/*' tags: ['controller.service_arguments'] - SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController: - autowire: true - autoconfigure: true - public: true - tags: [ 'controller.service_arguments' ] - SimpleSAML\Module\oidc\Services\: resource: '../../src/Services/*' exclude: '../../src/Services/{Container.php}' diff --git a/src/Controllers/TokenIntrospectionController.php b/src/Controllers/TokenIntrospectionController.php index 0482ee87..26068f89 100644 --- a/src/Controllers/TokenIntrospectionController.php +++ b/src/Controllers/TokenIntrospectionController.php @@ -4,84 +4,120 @@ namespace SimpleSAML\Module\oidc\Controllers; +use Laminas\Diactoros\ServerRequestFactory; +use League\OAuth2\Server\ResourceServer; +use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; +use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; +use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; -use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; class TokenIntrospectionController { + private ?string $authenticatedClientId = null; + public function __construct( - private readonly AccessTokenRepository $accessTokenRepository, + private readonly AccessTokenRepository $accessTokenRepository, + private readonly ResourceServer $resourceServer, + private readonly PsrHttpBridge $psrHttpBridge, + private readonly RequestRulesManager $requestRulesManager, + private readonly RefreshTokenRepository $refreshTokenRepository, ) { } public function __invoke(Request $request): Response { + // Check the request method if ($request->getMethod() !== 'POST') { - return new JsonResponse([ - 'error' => 'invalid_request', - 'error_description' => 'Token introspection endpoint only accepts POST requests' - ], 405); + return new JsonResponse( + [ + 'error' => 'invalid_request', + 'error_description' => 'Token introspection endpoint only accepts POST requests.', + ], + 405 + ); } - $authHeader = $request->headers->get('Authorization'); - if (empty($authHeader)) { - return new JsonResponse([ - 'error' => 'invalid_client', - 'error_description' => 'Client authentication is required' - ], 401); + // Check the content type + $contentType = (string)$request->headers->get('Content-Type', ''); + if ($contentType === '' || !str_contains($contentType, 'application/x-www-form-urlencoded')) { + return new JsonResponse( + [ + 'error' => 'invalid_request', + 'error_description' => 'Content-Type must be application/x-www-form-urlencoded.', + ], + 400 + ); } - if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader)) { - return new JsonResponse([ - 'error' => 'invalid_client', - 'error_description' => 'Invalid authorization header format' - ], 401); - } - - $contentType = $request->headers->get('Content-Type'); - if (empty($contentType) || !str_contains($contentType, 'application/x-www-form-urlencoded')) { - return new JsonResponse([ - 'error' => 'invalid_request', - 'error_description' => 'Content-Type must be application/x-www-form-urlencoded' - ], 400); + // Check client authentication + try { + $psr = ServerRequestFactory::fromGlobals(); + $psrRequest = $psr->withParsedBody($request->request->all()); + + $resultBag = $this->requestRulesManager->check( + $psrRequest, + [ + ClientIdRule::class, + ClientAuthenticationRule::class, + ], + false, + ['POST'], + ); + + // Get client id to later check if the token is from the authenticated client + $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $this->authenticatedClientId = $client->getIdentifier(); + } catch (OidcServerException|\Throwable $e) { + return new JsonResponse( + [ + 'error' => 'invalid_client', + 'error_description' => 'Client authentication failed.', + ], + 401 + ); } - $token = $request->request->get('token'); - if (empty($token)) { - return new JsonResponse([ - 'error' => 'invalid_request', - 'error_description' => 'Missing required parameter: token' - ], 400); + // Check if token is provided + $token = (string)$request->request->get('token', ''); + if ($token === '') { + return new JsonResponse( + [ + 'error' => 'invalid_request', + 'error_description' => 'Missing required parameter token.', + ], + 400 + ); } + // Check the token return $this->introspectToken($token); } private function introspectToken(string $token): JsonResponse { try { - // JWT format: header.payload.signature - $tokenParts = explode('.', $token); + $psrRequest = ServerRequestFactory::fromGlobals() + ->withHeader('Authorization', 'Bearer ' . $token); - if (count($tokenParts) !== 3) { - return new JsonResponse(['active' => false], 200); - } + $authorization = $this->resourceServer->validateAuthenticatedRequest($psrRequest); - $payload = json_decode(base64_decode(strtr($tokenParts[1], '-_', '+/')), true); + $tokenId = $authorization->getAttribute('oauth_access_token_id'); + $accessToken = $this->accessTokenRepository->findById($tokenId); - if (!is_array($payload) || !isset($payload['jti'])) { + if (!$accessToken instanceof AccessTokenEntity) { return new JsonResponse(['active' => false], 200); } - $tokenId = $payload['jti']; - - $accessToken = $this->accessTokenRepository->findById($tokenId); - - if (!$accessToken instanceof AccessTokenEntity) { + if ($accessToken->getClient()->getIdentifier() !== $this->authenticatedClientId) { return new JsonResponse(['active' => false], 200); } @@ -95,19 +131,19 @@ private function introspectToken(string $token): JsonResponse $introspectionResponse = [ 'active' => true, - 'scope' => implode(' ', array_map(fn($scope) => $scope->getIdentifier(), $accessToken->getScopes())), + 'scope' => implode(' ', array_map(static fn($scope) => $scope->getIdentifier(), $accessToken->getScopes())), 'client_id' => $accessToken->getClient()->getIdentifier(), 'token_type' => 'Bearer', 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), ]; - if (isset($payload['iat'])) { + $payload = $accessToken->getPayload(); + if (is_array($payload) && isset($payload['iat'])) { $introspectionResponse['iat'] = $payload['iat']; } return new JsonResponse($introspectionResponse, 200); - - } catch (\Exception $e) { + } catch (\Throwable $e) { return new JsonResponse(['active' => false], 200); } } From ab9a58bb5819db1b647397d98abae9c9ac60c63a Mon Sep 17 00:00:00 2001 From: peterbolha Date: Thu, 5 Feb 2026 16:06:51 +0100 Subject: [PATCH 05/20] feat: increase conformance with introspection rfc --- .../TokenIntrospectionController.php | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Controllers/TokenIntrospectionController.php b/src/Controllers/TokenIntrospectionController.php index 26068f89..28b722b8 100644 --- a/src/Controllers/TokenIntrospectionController.php +++ b/src/Controllers/TokenIntrospectionController.php @@ -6,8 +6,10 @@ use Laminas\Diactoros\ServerRequestFactory; use League\OAuth2\Server\ResourceServer; +use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -19,6 +21,13 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\Clock\SystemClock; + class TokenIntrospectionController { private ?string $authenticatedClientId = null; @@ -29,6 +38,7 @@ public function __construct( private readonly PsrHttpBridge $psrHttpBridge, private readonly RequestRulesManager $requestRulesManager, private readonly RefreshTokenRepository $refreshTokenRepository, + private readonly ModuleConfig $moduleConfig = new ModuleConfig(), ) { } @@ -125,25 +135,33 @@ private function introspectToken(string $token): JsonResponse return new JsonResponse(['active' => false], 200); } - if ($accessToken->getExpiryDateTime() < new \DateTimeImmutable()) { - return new JsonResponse(['active' => false], 200); + $tokenParts = explode('.', $token); + $payload = json_decode(base64_decode(strtr($tokenParts[1], '-_', '+/')), true); + + $receivedTokenIssuer = $payload['iss']; + $expectedTokenIssuer = $this->moduleConfig->getIssuer(); + if ($receivedTokenIssuer !== $expectedTokenIssuer) { + return new JsonResponse(['active' => false, + 'cause' => 'token issuer mismatch, expected: ' . $expectedTokenIssuer . ' actual: ' . $receivedTokenIssuer], 200); } $introspectionResponse = [ 'active' => true, 'scope' => implode(' ', array_map(static fn($scope) => $scope->getIdentifier(), $accessToken->getScopes())), 'client_id' => $accessToken->getClient()->getIdentifier(), + 'username' => (string) $accessToken->getUserIdentifier(), 'token_type' => 'Bearer', 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), ]; - $payload = $accessToken->getPayload(); - if (is_array($payload) && isset($payload['iat'])) { - $introspectionResponse['iat'] = $payload['iat']; + $introspectionClaims = ['iat', 'nbf', 'sub', 'aud', 'iss', 'jti']; + foreach ($introspectionClaims as $claim) { + if (isset($payload[$claim])) { + $introspectionResponse[$claim] = $payload[$claim]; + } } - return new JsonResponse($introspectionResponse, 200); - } catch (\Throwable $e) { + } catch (\Throwable) { return new JsonResponse(['active' => false], 200); } } From 236ed3efa0c92446540a290b51f46699f7855125 Mon Sep 17 00:00:00 2001 From: Jovan Simonoski Date: Thu, 19 Feb 2026 13:30:01 +0100 Subject: [PATCH 06/20] add generate-token script for testing add example postman requests and responses from the testing --- .../SSP-OIDC-module.postman_collection.json | 932 ++++++++++++++++++ additional-tools/generate-token.md | 105 ++ 2 files changed, 1037 insertions(+) create mode 100644 additional-tools/SSP-OIDC-module.postman_collection.json create mode 100644 additional-tools/generate-token.md diff --git a/additional-tools/SSP-OIDC-module.postman_collection.json b/additional-tools/SSP-OIDC-module.postman_collection.json new file mode 100644 index 00000000..11131f6e --- /dev/null +++ b/additional-tools/SSP-OIDC-module.postman_collection.json @@ -0,0 +1,932 @@ +{ + "info": { + "_postman_id": "", + "name": "SSP-OIDC-module", + "schema": "", + "_exporter_id": "", + "_collection_link": "" + }, + "item": [ + { + "name": "test - client auth - Correct secret", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", + "type": "string" + }, + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "type": "text", + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - client auth - Correct secret", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "type": "text", + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:13:18 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Keep-Alive", + "value": "timeout=5, max=100" + }, + { + "key": "Connection", + "value": "Keep-Alive" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"active\": false\n}" + } + ] + }, + { + "name": "test - client auth - Incorrect secret", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + }, + { + "key": "password", + "value": "_6300f8ce29f56909838e8e93d07fe086016adcb9e7", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", + "type": "text", + "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - client auth - Incorrect secret", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", + "type": "text", + "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:13:48 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Keep-Alive", + "value": "timeout=5, max=100" + }, + { + "key": "Connection", + "value": "Keep-Alive" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_client\",\n \"error_description\": \"Client authentication failed.\"\n}" + } + ] + }, + { + "name": "test - token introspection", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + }, + { + "key": "password", + "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "type": "text", + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6InJlc291cmNlLXNlcnZlciIsImp0aSI6InRlc3QtdG9rZW4tMjQyMTg0YjkzMjIxY2NkZWYwNmQ1NjQzNmNhODY2NWUiLCJpYXQiOjE3NzE1MDMwNTEuNjE1OTYxLCJuYmYiOjE3NzE1MDMwNTEuNjE1OTYxLCJleHAiOjE3NzE1MDY2NTEuNjE1OTYxLCJzdWIiOiJ0ZXN0LXVzZXItMTIzIiwic2NvcGVzIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJlbWFpbCJdLCJjbGllbnRfaWQiOiJfOGI2Y2YzNGQ4OWJlOWRkYzBiOTc4NTBiM2NiMmUyYTg5YTk1MjNjOWQwIn0.ZcdGTWoNpF7CFb9RxoQfTHmO--EqFtF-KNvUjzDk1DDYkyoMqyTRe7P3YwK0pUotcY_KZ-gex2_rDIhZstEs0Pkr0mlDP0a2fl4oZGs945RW3g5drdzzUTHAtUzRbL6ugSTxqS-VS5U4UWWngA5l7WsxiAfm7UgTArJzpS9gUL1lGg2w96viYTdfy4H0PIp0Q46vt9r-0CAVSZTRxh-M3fkCQhxai3RK8Ql1dT7mco4loq6Y1iHx_Xr9usTIDzMrnOLCwYJlFX9NsxSBCABs4_l9okxIwOK40IXG8xbEg9uW_6ipYEHGOx9NrKSGqowYcKTqpbb7kZJT8vxY1u2gfg" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - token introspection", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "type": "text", + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6InJlc291cmNlLXNlcnZlciIsImp0aSI6InRlc3QtdG9rZW4tMjQyMTg0YjkzMjIxY2NkZWYwNmQ1NjQzNmNhODY2NWUiLCJpYXQiOjE3NzE1MDMwNTEuNjE1OTYxLCJuYmYiOjE3NzE1MDMwNTEuNjE1OTYxLCJleHAiOjE3NzE1MDY2NTEuNjE1OTYxLCJzdWIiOiJ0ZXN0LXVzZXItMTIzIiwic2NvcGVzIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJlbWFpbCJdLCJjbGllbnRfaWQiOiJfOGI2Y2YzNGQ4OWJlOWRkYzBiOTc4NTBiM2NiMmUyYTg5YTk1MjNjOWQwIn0.ZcdGTWoNpF7CFb9RxoQfTHmO--EqFtF-KNvUjzDk1DDYkyoMqyTRe7P3YwK0pUotcY_KZ-gex2_rDIhZstEs0Pkr0mlDP0a2fl4oZGs945RW3g5drdzzUTHAtUzRbL6ugSTxqS-VS5U4UWWngA5l7WsxiAfm7UgTArJzpS9gUL1lGg2w96viYTdfy4H0PIp0Q46vt9r-0CAVSZTRxh-M3fkCQhxai3RK8Ql1dT7mco4loq6Y1iHx_Xr9usTIDzMrnOLCwYJlFX9NsxSBCABs4_l9okxIwOK40IXG8xbEg9uW_6ipYEHGOx9NrKSGqowYcKTqpbb7kZJT8vxY1u2gfg" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:16:23 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Keep-Alive", + "value": "timeout=5, max=100" + }, + { + "key": "Connection", + "value": "Keep-Alive" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"active\": true,\n \"scope\": \"openid profile email\",\n \"client_id\": \"_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0\",\n \"username\": \"test-user-123\",\n \"token_type\": \"Bearer\",\n \"exp\": 1771506651,\n \"iat\": 1771503051.615961,\n \"nbf\": 1771503051.615961,\n \"sub\": \"test-user-123\",\n \"aud\": \"resource-server\",\n \"iss\": \"https://localhost\",\n \"jti\": \"test-token-242184b93221ccdef06d56436ca8665e\"\n}" + } + ] + }, + { + "name": "test - Invalid request type", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", + "type": "string" + }, + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", + "type": "text", + "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - Invalid request type", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", + "type": "text", + "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" + } + ] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "Method Not Allowed", + "code": 405, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:15:00 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Keep-Alive", + "value": "timeout=5, max=100" + }, + { + "key": "Connection", + "value": "Keep-Alive" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Token introspection endpoint only accepts POST requests.\"\n}" + } + ] + }, + { + "name": "test - Invalid Content-type", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + }, + { + "key": "password", + "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - Invalid Content-type", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:15:26 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Connection", + "value": "close" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Content-Type must be application/x-www-form-urlencoded.\"\n}" + } + ] + }, + { + "name": "test - no token provided", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", + "type": "string" + }, + { + "key": "username", + "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "response": [ + { + "name": "test - no token provided", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [] + }, + "url": { + "raw": "https://localhost/simplesaml/module.php/oidc/introspect", + "protocol": "https", + "host": [ + "localhost" + ], + "path": [ + "simplesaml", + "module.php", + "oidc", + "introspect" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Date", + "value": "Thu, 19 Feb 2026 12:15:39 GMT" + }, + { + "key": "Server", + "value": "Apache/2.4.65 (Debian)" + }, + { + "key": "Vary", + "value": "Authorization" + }, + { + "key": "Set-Cookie", + "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" + }, + { + "key": "Expires", + "value": "Thu, 19 Nov 1981 08:52:00 GMT" + }, + { + "key": "Cache-Control", + "value": "no-store, no-cache, must-revalidate" + }, + { + "key": "Cache-Control", + "value": "no-cache, private" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "origin-when-cross-origin" + }, + { + "key": "Connection", + "value": "close" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Missing required parameter token.\"\n}" + } + ] + } + ] +} \ No newline at end of file diff --git a/additional-tools/generate-token.md b/additional-tools/generate-token.md new file mode 100644 index 00000000..95a10f7e --- /dev/null +++ b/additional-tools/generate-token.md @@ -0,0 +1,105 @@ +``` +cat > /tmp/test.php << 'EOF' +prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); +$stmt->execute(["test-user-123", '{"sub":"test-user-123","name":"Test User"}']); + +// Get first client +$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); +if (!$client) { + die("Error: No client found in database. Please create a client first.\n"); +} +echo "Using client: " . $client["id"] . "\n"; + +// Generate token ID +$tokenId = "test-token-" . bin2hex(random_bytes(16)); +echo "Token ID: " . $tokenId . "\n"; + +// Calculate expiration time +$now = new DateTimeImmutable(); +$expiresAt = $now->modify('+1 hour'); + +// Insert access token into database +$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, ?, ?, ?, 0)"); +$stmt->execute([ + $tokenId, + '["openid","profile","email"]', + $expiresAt->format('Y-m-d H:i:s'), + "test-user-123", + $client["id"] +]); + +echo "✓ Token stored in database\n"; + +// Read the actual key contents +$privateKeyContents = file_get_contents('/var/simplesamlphp/cert/oidc_module.key'); +$publicKeyContents = file_get_contents('/var/simplesamlphp/cert/oidc_module.crt'); + +// Configure JWT +$config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($privateKeyContents), + InMemory::plainText($publicKeyContents) +); + +// Build properly signed JWT with nbf claim +$token = $config->builder() + ->issuedBy('https://localhost') + ->permittedFor('resource-server') + ->identifiedBy($tokenId) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) // ← ADD THIS: Sets the nbf (Not Before) claim + ->expiresAt($expiresAt) + ->relatedTo('test-user-123') + ->withClaim('scopes', ['openid', 'profile', 'email']) + ->withClaim('client_id', $client["id"]) + ->getToken($config->signer(), $config->signingKey()); + +$jwt = $token->toString(); + +// Verify the token can be parsed and validated +try { + $parsedToken = $config->parser()->parse($jwt); + $config->validator()->assert($parsedToken, ...$config->validationConstraints()); + echo "✓ Token signature validated successfully!\n"; +} catch (\Exception $e) { + echo "✗ Validation failed: " . $e->getMessage() . "\n"; +} + +// Output the JWT +echo "\n" . str_repeat("=", 80) . "\n"; +echo "Generated JWT:\n"; +echo str_repeat("=", 80) . "\n"; +echo $jwt . "\n"; +echo str_repeat("=", 80) . "\n"; + +// Provide test command +echo "\nTest with curl:\n"; +echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" \\\n"; +echo " -H \"Content-Type: application/x-www-form-urlencoded\" \\\n"; +echo " -H \"Authorization: Bearer test\" \\\n"; +echo " -d \"token=$jwt\" \\\n"; +echo " -k\n"; + +// Also show decoded token for debugging +echo "\n" . str_repeat("=", 80) . "\n"; +echo "Decoded Token Payload:\n"; +echo str_repeat("=", 80) . "\n"; +$parts = explode('.', $jwt); +$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); +echo json_encode($payload, JSON_PRETTY_PRINT) . "\n"; +EOF + + +php /tmp/test.php +``` \ No newline at end of file From 24eab6c648dd0c0ee7ac205ffc6516fe6f038bd8 Mon Sep 17 00:00:00 2001 From: Jovan Simonoski Date: Thu, 19 Feb 2026 13:32:35 +0100 Subject: [PATCH 07/20] remove useful-scripts.md --- useful-scripts/useful-scripts.md | 164 ------------------------------- 1 file changed, 164 deletions(-) delete mode 100644 useful-scripts/useful-scripts.md diff --git a/useful-scripts/useful-scripts.md b/useful-scripts/useful-scripts.md deleted file mode 100644 index e26ae2af..00000000 --- a/useful-scripts/useful-scripts.md +++ /dev/null @@ -1,164 +0,0 @@ -## Docker command: -``` -docker run --name ssp-oidc-dev \ - -v "$(pwd)":/var/simplesamlphp/staging-modules/oidc:ro \ - -e STAGINGCOMPOSERREPOS=oidc \ - -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-oidc:@dev" \ - -e SSP_ADMIN_PASSWORD=secret1 \ - -v "$(pwd)/docker/ssp/module_oidc.php":/var/simplesamlphp/config/module_oidc.php:ro \ - -v "$(pwd)/docker/ssp/authsources.php":/var/simplesamlphp/config/authsources.php:ro \ - -v "$(pwd)/docker/ssp/config-override.php":/var/simplesamlphp/config/config-override.php:ro \ - -v "$(pwd)/docker/ssp/oidc_module.crt":/var/simplesamlphp/cert/oidc_module.crt:ro \ - -v "$(pwd)/docker/ssp/oidc_module.key":/var/simplesamlphp/cert/oidc_module.key:ro \ - -v "$(pwd)/docker/apache-override.cf":/etc/apache2/sites-enabled/ssp-override.cf:ro \ - -p 443:443 cirrusid/simplesamlphp:v2.4.4 -``` - - -## Insert test token - -Run database migration before! - -``` -docker exec -it ssp-oidc-dev bash -``` - -``` -cat > /tmp/insert_client.php << 'EOF' -prepare("INSERT OR IGNORE INTO oidc_client (id, secret, name, description, auth_source, redirect_uri, scopes, is_enabled, is_confidential) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); - -$stmt->execute([ - 'test-client-123', - 'test-secret-456', - 'Test Client', - 'Client for testing introspection', - 'example-userpass', - '["https://localhost/callback"]', - '["openid","profile","email"]', - 1, - 1 -]); - -echo "Client inserted!\n"; - -// Verify -$result = $db->query("SELECT id, name FROM oidc_client")->fetchAll(PDO::FETCH_ASSOC); -echo "Clients in database:\n"; -foreach ($result as $row) { - echo " - " . $row['id'] . " (" . $row['name'] . ")\n"; -} -EOF - -php /tmp/insert_client.php -``` - - -``` -cat > /tmp/test.php << 'EOF' -prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); -$stmt->execute(["test-user-123", '{"sub":"test-user-123","name":"Test User"}']); - -// Get first client -$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); -echo "Using client: " . $client["id"] . "\n"; - -// Generate token ID -$tokenId = "test-token-" . bin2hex(random_bytes(8)); -echo "Token ID: " . $tokenId . "\n"; - -// Insert access token -$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, datetime('now', '+1 hour'), ?, ?, 0)"); -$stmt->execute([$tokenId, '["openid","profile","email"]', "test-user-123", $client["id"]]); - -// Generate JWT -$header = rtrim(strtr(base64_encode(json_encode(["alg" => "RS256", "typ" => "JWT"])), "+/", "-_"), "="); -$payload = rtrim(strtr(base64_encode(json_encode(["jti" => $tokenId, "iat" => time(), "exp" => time() + 3600, "sub" => "test-user-123"])), "+/", "-_"), "="); -$jwt = "$header.$payload." . rtrim(strtr(base64_encode("sig"), "+/", "-_"), "="); - -echo "\nGenerated JWT:\n"; -echo $jwt . "\n"; -echo "\nTest with:\n"; -echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" -H \"Content-Type: application/x-www-form-urlencoded\" -H \"Authorization: Bearer test\" -d \"token=$jwt\" -k\n"; -EOF -``` - -``` -php /tmp/test.php -``` - - -## Single command (not tested) - -``` -docker exec -i ssp-oidc-dev bash -c ' -cat > /tmp/insert_client.php << "EOF" -prepare("INSERT OR IGNORE INTO oidc_client (id, secret, name, description, auth_source, redirect_uri, scopes, is_enabled, is_confidential) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); - -$stmt->execute([ - "test-client-123", - "test-secret-456", - "Test Client", - "Client for testing introspection", - "example-userpass", - "[\"https://localhost/callback\"]", - "[\"openid\",\"profile\",\"email\"]", - 1, - 1 -]); - -echo "Client inserted!\n"; - -// Verify -$result = $db->query("SELECT id, name FROM oidc_client")->fetchAll(PDO::FETCH_ASSOC); -echo "Clients in database:\n"; -foreach ($result as $row) { - echo " - " . $row["id"] . " (" . $row["name"] . ")\n"; -} -EOF - -php /tmp/insert_client.php - -cat > /tmp/test.php << "EOF" -prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); -$stmt->execute(["test-user-123", "{\"sub\":\"test-user-123\",\"name\":\"Test User\"}"]); - -// Get first client -$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); -echo "Using client: " . $client["id"] . "\n"; - -// Generate token ID -$tokenId = "test-token-" . bin2hex(random_bytes(8)); -echo "Token ID: " . $tokenId . "\n"; - -// Insert access token -$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, datetime('now', '+1 hour'), ?, ?, 0)"); -$stmt->execute([$tokenId, "[\"openid\",\"profile\",\"email\"]", "test-user-123", $client["id"]]); - -// Generate fake JWT -$header = rtrim(strtr(base64_encode(json_encode(["alg" => "RS256", "typ" => "JWT"])), "+/", "-_"), "="); -$payload = rtrim(strtr(base64_encode(json_encode(["jti" => $tokenId, "iat" => time(), "exp" => time() + 3600, "sub" => "test-user-123"])), "+/", "-_"), "="); -$jwt = "$header.$payload." . rtrim(strtr(base64_encode("sig"), "+/", "-_"), "="); - -echo "\nGenerated JWT:\n$jwt\n"; -echo "\nTest with:\n"; -echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" -H \"Content-Type: application/x-www-form-urlencoded\" -H \"Authorization: Bearer test\" -d \"token=$jwt\" -k\n"; -EOF - -php /tmp/test.php -``` \ No newline at end of file From eb276be30a8707498ec02fff5ccc51b6f7f7926a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 23 Feb 2026 09:30:11 +0100 Subject: [PATCH 08/20] Add note about cache busting after upgrade --- docs/6-oidc-upgrade.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 1c2ef70f..823a7910 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -3,6 +3,14 @@ This is an upgrade guide from versions 1 → 7. Review the changes and apply those relevant to your deployment. +In general, when upgrading any of the SimpleSAMLphp modules or the +SimpleSAMLphp instance itself, you should clear the SimpleSAMLphp +cache after the upgrade: + +```shell +composer clear-symfony-cache +``` + ## Version 6 to 7 As the database schema has been updated, you will have to run the DB migrations From fdd38f9eb5fe1f7fdf90b5e95a2e5ae161bdd4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 25 Feb 2026 09:36:15 +0100 Subject: [PATCH 09/20] Update upgrade doc --- docs/6-oidc-upgrade.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 823a7910..a2aabe64 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -5,7 +5,8 @@ apply those relevant to your deployment. In general, when upgrading any of the SimpleSAMLphp modules or the SimpleSAMLphp instance itself, you should clear the SimpleSAMLphp -cache after the upgrade: +cache after the upgrade. In newer versions of SimpleSAMLphp, the +following command is available to do that: ```shell composer clear-symfony-cache From 391d7009072390708f9fc27602efc636f28868ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 3 Mar 2026 10:13:51 +0100 Subject: [PATCH 10/20] phpcbf fixes --- routing/routes/routes.php | 4 +-- .../TokenIntrospectionController.php | 34 +++++++------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 4b82991c..0016f06b 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -17,10 +17,8 @@ use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController; use SimpleSAML\Module\oidc\Controllers\JwksController; -use SimpleSAML\Module\oidc\Controllers\UserInfoController; - use SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController; - +use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; diff --git a/src/Controllers/TokenIntrospectionController.php b/src/Controllers/TokenIntrospectionController.php index 28b722b8..dd112cca 100644 --- a/src/Controllers/TokenIntrospectionController.php +++ b/src/Controllers/TokenIntrospectionController.php @@ -6,8 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory; use League\OAuth2\Server\ResourceServer; -use League\OAuth2\Server\Exception\OAuthServerException; -use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; @@ -16,31 +15,22 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; -use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\LooseValidAt; -use Lcobucci\Clock\SystemClock; - class TokenIntrospectionController { private ?string $authenticatedClientId = null; public function __construct( - private readonly AccessTokenRepository $accessTokenRepository, - private readonly ResourceServer $resourceServer, - private readonly PsrHttpBridge $psrHttpBridge, - private readonly RequestRulesManager $requestRulesManager, + private readonly AccessTokenRepository $accessTokenRepository, + private readonly ResourceServer $resourceServer, + private readonly PsrHttpBridge $psrHttpBridge, + private readonly RequestRulesManager $requestRulesManager, private readonly RefreshTokenRepository $refreshTokenRepository, - private readonly ModuleConfig $moduleConfig = new ModuleConfig(), - ) - { + private readonly ModuleConfig $moduleConfig = new ModuleConfig(), + ) { } public function __invoke(Request $request): Response @@ -52,7 +42,7 @@ public function __invoke(Request $request): Response 'error' => 'invalid_request', 'error_description' => 'Token introspection endpoint only accepts POST requests.', ], - 405 + 405, ); } @@ -64,7 +54,7 @@ public function __invoke(Request $request): Response 'error' => 'invalid_request', 'error_description' => 'Content-Type must be application/x-www-form-urlencoded.', ], - 400 + 400, ); } @@ -86,13 +76,13 @@ public function __invoke(Request $request): Response // Get client id to later check if the token is from the authenticated client $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); $this->authenticatedClientId = $client->getIdentifier(); - } catch (OidcServerException|\Throwable $e) { + } catch (OidcServerException | \Throwable $e) { return new JsonResponse( [ 'error' => 'invalid_client', 'error_description' => 'Client authentication failed.', ], - 401 + 401, ); } @@ -104,7 +94,7 @@ public function __invoke(Request $request): Response 'error' => 'invalid_request', 'error_description' => 'Missing required parameter token.', ], - 400 + 400, ); } From 467ea97ef53253a392ca5cc80dece282651f16db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 4 Mar 2026 14:59:08 +0100 Subject: [PATCH 11/20] Merge with wip-version-7 --- routing/routes/routes.php | 2 +- src/Controllers/TokenIntrospectionController.php | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 775bb819..6b883165 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -18,8 +18,8 @@ use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; use SimpleSAML\Module\oidc\Controllers\JwksController; -use SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; +use SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; diff --git a/src/Controllers/TokenIntrospectionController.php b/src/Controllers/TokenIntrospectionController.php index dd112cca..970a4f81 100644 --- a/src/Controllers/TokenIntrospectionController.php +++ b/src/Controllers/TokenIntrospectionController.php @@ -131,13 +131,22 @@ private function introspectToken(string $token): JsonResponse $receivedTokenIssuer = $payload['iss']; $expectedTokenIssuer = $this->moduleConfig->getIssuer(); if ($receivedTokenIssuer !== $expectedTokenIssuer) { - return new JsonResponse(['active' => false, - 'cause' => 'token issuer mismatch, expected: ' . $expectedTokenIssuer . ' actual: ' . $receivedTokenIssuer], 200); + return new JsonResponse( + [ + 'active' => false, + 'cause' => 'token issuer mismatch, expected: ' . $expectedTokenIssuer . ' actual: ' . + $receivedTokenIssuer, + ], + 200, + ); } $introspectionResponse = [ 'active' => true, - 'scope' => implode(' ', array_map(static fn($scope) => $scope->getIdentifier(), $accessToken->getScopes())), + 'scope' => implode( + ' ', + array_map(static fn($scope) => $scope->getIdentifier(), $accessToken->getScopes()), + ), 'client_id' => $accessToken->getClient()->getIdentifier(), 'username' => (string) $accessToken->getUserIdentifier(), 'token_type' => 'Bearer', From 77dd5c591c7d18feeaf724a6d926e2bad14789b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 5 Mar 2026 15:13:27 +0100 Subject: [PATCH 12/20] WIP client authn refactor --- .../Rules/ClientAuthenticationRule.php | 27 +- .../AuthenticatedOAuth2ClientResolver.php | 413 ++++++++++ .../ResolvedClientAuthenticationMethod.php | 27 + .../AuthenticatedOAuth2ClientResolverTest.php | 729 ++++++++++++++++++ 4 files changed, 1194 insertions(+), 2 deletions(-) create mode 100644 src/Utils/AuthenticatedOAuth2ClientResolver.php create mode 100644 src/ValueAbstracts/ResolvedClientAuthenticationMethod.php create mode 100644 tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index 478d4689..d0adae6e 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -13,6 +14,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -29,6 +31,7 @@ public function __construct( Helpers $helpers, protected ModuleConfig $moduleConfig, protected JwksResolver $jwksResolver, + protected AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, protected ?ProtocolCache $protocolCache, ) { parent::__construct($requestParamsResolver, $helpers); @@ -46,8 +49,28 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + + $loggerService->debug('ClientAuthenticationRule::checkRule'); + + // TODO mivanci Instead of ClientRule which mandates client, this should + // be refactored to use optional client_id parameter and then + // fetch client if present. + /** @var ?ClientEntityInterface $preFetchedClient */ + $preFetchedClient = $currentResultBag->get(ClientRule::class)?->getValue(); + + $client = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod( + request: $request, + preFetchedClient: $preFetchedClient, + ); + + if (is_null($client)) { + throw OidcServerException::accessDenied('Not a single client authentication method presented.'); + } + + // TODO mivanci continue + //////////////////////// + + // We will only perform client authentication if the client type is confidential. if (!$client->isConfidential()) { diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php new file mode 100644 index 00000000..002fd159 --- /dev/null +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -0,0 +1,413 @@ +forPrivateKeyJwt($request, $preFetchedClient) ?? + $this->forClientSecretBasic($request, $preFetchedClient) ?? + $this->forClientSecretPost($request, $preFetchedClient) ?? + $this->forPublicClient($request, $preFetchedClient); + } catch (\Throwable $exception) { + $this->loggerService->error( + 'Error while trying to resolve authenticated client: ' . + $exception->getMessage(), + ); + return null; + } + } + + /** + * @throws AuthorizationException + */ + public function forPublicClient( + ServerRequestInterface|Request $request, + ?ClientEntityInterface $preFetchedClient, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve public client for request client ID.'); + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::GET, HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP request, skipping for public client.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP request: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + if ($client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is confidential, aborting for public client.', + ); + throw new AuthorizationException('Client is confidential.'); + } + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::None, + ); + } + + /** + * @throws AuthorizationException + */ + public function forClientSecretBasic( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from basic auth.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $authorizationHeader = $request->getHeader('Authorization')[0] ?? null; + + if (!is_string($authorizationHeader)) { + $this->loggerService->debug( + 'No authorization header available for basic auth, skipping.', + ); + return null; + } + + if (!str_starts_with($authorizationHeader, 'Basic ')) { + $this->loggerService->debug( + 'Authorization header is not in basic auth format, skipping.', + ); + return null; + } + + $decodedAuthorizationHeader = base64_decode(substr($authorizationHeader, 6), true); + + if ($decodedAuthorizationHeader === false) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + if (!str_contains($decodedAuthorizationHeader, ':')) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + $parts = explode(':', $decodedAuthorizationHeader, 2); + $clientId = $parts[0]; + $clientSecret = $parts[1] ?? ''; + + if ($clientId === '') { + $this->loggerService->debug( + 'No client ID available in basic auth header, skipping.', + ); + return null; + } + + $this->loggerService->debug('Client ID from basic auth: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting basic auth validation.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + if ($clientSecret === '') { + $this->loggerService->error('No client secret available in basic auth header.'); + throw new AuthorizationException('No client secret available in basic auth header.'); + } + + $this->loggerService->debug('Client secret provided for basic auth, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from basic auth validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretBasic, + ); + } + + /** + * For client_secret_post authentication method. + * + * @throws AuthorizationException + */ + public function forClientSecretPost( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from HTTP POST body.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::POST], + ); + $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientSecret->value, + $request, + [HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP POST body, skipping.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP POST body: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting client_secret_post validation.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + if (!is_string($clientSecret) || $clientSecret === '') { + $this->loggerService->debug( + 'No client secret available in HTTP POST body, aborting client_secret_post validation.', + ); + throw new AuthorizationException('No client secret available in HTTP POST body'); + } + + $this->loggerService->debug('Client secret provided for HTTP POST body, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from HTTP POST body validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + } + + /** + * @throws JwsException + * @throws ClientAssertionException + * @throws InvalidArgumentException + * @throws AuthorizationException + */ + public function forPrivateKeyJwt( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from private key JWT.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $allowedServerRequestMethods = [HttpMethodsEnum::POST]; + + $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertion->value, + $request, + $allowedServerRequestMethods, + ); + + if (!is_string($clientAssertionParam)) { + $this->loggerService->debug('No client assertion available, skipping.'); + return null; + } + + $this->loggerService->debug('Client assertion param received: ' . $clientAssertionParam); + + // private_key_jwt authentication method is used. + // Check the expected assertion type param. + $clientAssertionType = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertionType->value, + $request, + $allowedServerRequestMethods, + ); + + if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { + $this->loggerService->debug( + 'Client assertion type is not expected value, skipping.', + ['expected' => ClientAssertionTypesEnum::JwtBaerer->value, 'actual' => $clientAssertionType], + ); + return null; + } + + $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); + + $client = $this->resolveClientOrFail($clientAssertion->getIssuer(), $preFetchedClient); + + ($jwks = $this->jwksResolver->forClient($client)) || throw new AuthorizationException( + 'Can not validate Client Assertion, client JWKS not available.', + ); + + try { + $clientAssertion->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { + throw new AuthorizationException( + 'Client Assertion validation failed: ' . $exception->getMessage(), + ); + } + + // Check if the Client Assertion token has already been used. Only + // applicable if we have a cache available. + if ($this->protocolCache) { + ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) + || throw new AuthorizationException('Client Assertion reused.'); + } + + ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw new AuthorizationException( + 'Invalid Client Assertion Issuer claim.', + ); + + ($client->getIdentifier() === $clientAssertion->getSubject()) || throw new AuthorizationException( + 'Invalid Client Assertion Subject claim.', + ); + + // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. + // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization + // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. + $expectedAudience = [ + $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), + $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), + $this->moduleConfig->getIssuer(), + ]; + + (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || + throw new AuthorizationException('Invalid Client Assertion Audience claim.'); + + // Everything seems ok. Save it in a cache so we can check for reuse. + $this->protocolCache?->set( + $clientAssertion->getJwtId(), + $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), + self::KEY_CLIENT_ASSERTION_JTI, + $clientAssertion->getJwtId(), + ); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + ); + } + + public function findActiveClient(string $clientId): ?ClientEntityInterface + { + $client = $this->clientRepository->findById($clientId); + + if (is_null($client)) { + $this->loggerService->debug('No client with ID ' . $clientId . ' found.'); + return null; + } + + if (!$client->isEnabled()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is disabled.'); + return null; + } + + if ($client->isExpired()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is expired.'); + return null; + } + + $this->loggerService->debug('Client with ID ' . $clientId . ' is active, returning its instance.'); + return $client; + } + + /** + * @throws AuthorizationException + */ + protected function resolveClientOrFail( + string $clientId, + ?ClientEntityInterface $preFetchedClient, + ): ClientEntityInterface { + $client = $preFetchedClient ?: $this->findActiveClientOrFail($clientId); + + if ($client->getIdentifier() !== $clientId) { + $this->loggerService->error( + 'Client ID does not match, expected: ' . $clientId . ', actual: ' . $client->getIdentifier(), + ); + throw new AuthorizationException('Client ID does not match.'); + } + + return $client; + } + + /** + * @throws AuthorizationException + */ + public function findActiveClientOrFail(string $clientId): ClientEntityInterface + { + return $this->findActiveClient($clientId) ?? throw new AuthorizationException( + 'Client with ID ' . $clientId . ' is not active (either not found, not enabled, or expired).', + ); + } + + /** + * @throws AuthorizationException + */ + public function validateClientSecret(ClientEntityInterface $client, string $clientSecret): void + { + hash_equals($client->getSecret(), $clientSecret) || throw new AuthorizationException( + 'Client secret is not valid.', + ); + } +} diff --git a/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php new file mode 100644 index 00000000..9e0f3590 --- /dev/null +++ b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php @@ -0,0 +1,27 @@ +client; + } + + public function getClientAuthenticationMethod(): ClientAuthenticationMethodsEnum + { + return $this->clientAuthenticationMethod; + } +} diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php new file mode 100644 index 00000000..4b6529d7 --- /dev/null +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -0,0 +1,729 @@ +clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($this->psrHttpFactoryMock); + $this->jwksResolverMock = $this->createMock(JwksResolver::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getModuleUrl') + ->willReturnMap([ + [RoutesEnum::Token->value, self::TOKEN_ENDPOINT], + [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], + ]); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); + $this->protocolCacheStub = $this->createStub(ProtocolCache::class); + + $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); + + $this->clientEntityMock = $this->createMock(ClientEntityInterface::class); + $this->clientEntityMock->method('getIdentifier')->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isEnabled')->willReturn(true); + $this->clientEntityMock->method('isExpired')->willReturn(false); + + $this->clientAssertionMock = $this->createMock(ClientAssertion::class); + $this->clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getAudience')->willReturn([self::TOKEN_ENDPOINT]); + $this->clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $this->clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + } + + protected function sut(?ProtocolCache $protocolCache = null): AuthenticatedOAuth2ClientResolver + { + return new AuthenticatedOAuth2ClientResolver( + $this->clientRepositoryMock, + $this->requestParamsResolverMock, + $this->loggerServiceMock, + $this->psrHttpBridgeMock, + $this->jwksResolverMock, + $this->moduleConfigMock, + $this->helpersMock, + $protocolCache, + ); + } + + // ----------------------------------------------------------------------- + // Construction + // ----------------------------------------------------------------------- + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(AuthenticatedOAuth2ClientResolver::class, $this->sut()); + } + + // ----------------------------------------------------------------------- + // forPublicClient + // ----------------------------------------------------------------------- + + public function testForPublicClientReturnsNullWhenNoClientIdInRequest(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientReturnsNullWhenClientIdIsEmptyString(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(''); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientThrowsWhenClientIsConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientThrowsWhenClientNotFound(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientReturnsResolvedResultForPublicClient(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, null); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::None, $result->getClientAuthenticationMethod()); + } + + public function testForPublicClientUsesPreFetchedClientWhenProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + // Repository must NOT be called when a pre-fetched client is provided. + $this->clientRepositoryMock->expects($this->never())->method('findById'); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, $this->clientEntityMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forClientSecretBasic + // ----------------------------------------------------------------------- + + public function testForClientSecretBasicReturnsNullWhenNoAuthorizationHeader(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenHeaderIsNotBasic(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn(['Bearer some-token']); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenBase64DecodeFailsStrictMode(): void + { + // Characters outside [A-Za-z0-9+/=] are invalid in strict mode. + $invalidBase64 = 'Basic !!!'; + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$invalidBase64]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenDecodedValueHasNoColon(): void + { + // Valid base64 of a string with no colon. + $encoded = 'Basic ' . base64_encode('clientidonly'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenClientIdIsEmpty(): void + { + // Colon present but client ID part is empty: ":secret" + $encoded = 'Basic ' . base64_encode(':some-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicThrowsWhenClientIsNotConfidential(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsEmpty(): void + { + // Colon present but secret part is empty: "clientid:" + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsInvalid(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':wrong-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicReturnsResolvedResultOnSuccess(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretBasic($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForClientSecretBasicConvertsSymfonyRequestToPsr(): void + { + $symfonyRequest = Request::create('/', 'POST'); + + $psrRequest = $this->createMock(ServerRequestInterface::class); + $psrRequest->method('getHeader')->with('Authorization')->willReturn([]); + + $this->psrHttpFactoryMock->expects($this->once()) + ->method('createRequest') + ->with($symfonyRequest) + ->willReturn($psrRequest); + + $result = $this->sut()->forClientSecretBasic($symfonyRequest); + + $this->assertNull($result); + } + + // ----------------------------------------------------------------------- + // forClientSecretPost + // ----------------------------------------------------------------------- + + public function testForClientSecretPostReturnsNullWhenNoClientIdInPostBody(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostReturnsNullWhenClientIdIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(''); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostThrowsWhenSecretIsAbsent(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, null); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostThrowsWhenSecretIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, ''); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, 'wrong-secret'); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretPost($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretPost, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // forPrivateKeyJwt + // ----------------------------------------------------------------------- + + public function testForPrivateKeyJwtReturnsNullWhenNoClientAssertionParam(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtReturnsNullWhenAssertionTypeIsNotJwtBearer(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', 'unexpected_type'); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtThrowsWhenJwksNotAvailable(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(null); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('client JWKS not available'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenSignatureVerificationFails(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->clientAssertionMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Signature mismatch')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion validation failed'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenJtiAlreadyUsed(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has') + ->with('client_assertion_jti', 'unique-jti-value') + ->willReturn(true); // JTI already in cache → replay attempt + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion reused'); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenIssuerClaimDoesNotMatchClientId(): void + { + // The assertion issuer is CLIENT_ID, but we pass a pre-fetched client with a different + // identifier. resolveClientOrFail will detect the mismatch and throw. + $mismatchedClient = $this->createMock(ClientEntityInterface::class); + $mismatchedClient->method('getIdentifier')->willReturn('different-client-id'); + $mismatchedClient->method('isEnabled')->willReturn(true); + $mismatchedClient->method('isExpired')->willReturn(false); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + + // Pass the mismatched client as a pre-fetched client to trigger the ID check. + $this->sut()->forPrivateKeyJwt($this->serverRequestMock, $mismatchedClient); + } + + public function testForPrivateKeyJwtThrowsWhenSubjectClaimDoesNotMatchClientId(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn('different-subject'); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Subject claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpectedValue(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getAudience')->willReturn(['https://unrelated-aud.example.org']); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Audience claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $result = $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForPrivateKeyJwtStoresJtiInCacheAfterSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has')->willReturn(false); + $protocolCacheMock->expects($this->once()) + ->method('set') + ->with( + 'unique-jti-value', + 60, + 'client_assertion_jti', + 'unique-jti-value', + ); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtSkipsJtiCheckWhenNoCacheProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // No cache passed — must succeed without any replay check. + $result = $this->sut(null)->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forAnySupportedMethod + // ----------------------------------------------------------------------- + + public function testForAnySupportedMethodReturnsNullWhenNoMethodMatches(): void + { + // All four methods return null (no matching credentials anywhere). + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forAnySupportedMethod($this->serverRequestMock)); + } + + public function testForAnySupportedMethodReturnsNullAndLogsErrorOnException(): void + { + // Trigger a hard exception to verify the catch-all swallows it and logs. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willThrowException(new \RuntimeException('Unexpected error')); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertNull($result); + } + + public function testForAnySupportedMethodPrefersPrivateKeyJwtOverOtherMethods(): void + { + // private_key_jwt assertion present — should resolve first and win. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnCallback(function (string $paramKey) { + if ($paramKey === ParamsEnum::ClientAssertion->value) { + return 'some-assertion-token'; + } + if ($paramKey === ParamsEnum::ClientAssertionType->value) { + return ClientAssertionTypesEnum::JwtBaerer->value; + } + return null; + }); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // forClientSecretBasic will be tried after forPrivateKeyJwt succeeds and short-circuits, + // so getHeader should never actually be reached. The PSR bridge is never used here + // because the request is already a ServerRequestInterface. + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // findActiveClient + // ----------------------------------------------------------------------- + + public function testFindActiveClientReturnsNullWhenClientNotFound(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsDisabled(): void + { + $disabledClient = $this->createMock(ClientEntityInterface::class); + $disabledClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $disabledClient->method('isEnabled')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($disabledClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsExpired(): void + { + $expiredClient = $this->createMock(ClientEntityInterface::class); + $expiredClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $expiredClient->method('isEnabled')->willReturn(true); + $expiredClient->method('isExpired')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($expiredClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClient(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // findActiveClientOrFail + // ----------------------------------------------------------------------- + + public function testFindActiveClientOrFailThrowsWhenClientNotActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->findActiveClientOrFail(self::CLIENT_ID); + } + + public function testFindActiveClientOrFailReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClientOrFail(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // validateClientSecret + // ----------------------------------------------------------------------- + + public function testValidateClientSecretThrowsWhenSecretDoesNotMatch(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + $this->expectException(AuthorizationException::class); + + $this->sut()->validateClientSecret($this->clientEntityMock, 'wrong-secret'); + } + + public function testValidateClientSecretDoesNotThrowWhenSecretMatches(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + // Must not throw. + $this->sut()->validateClientSecret($this->clientEntityMock, self::CLIENT_SECRET); + $this->addToAssertionCount(1); + } +} From 461c0bd3d15ad27bb026168f9785c04ad32b0cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 6 Mar 2026 17:29:32 +0100 Subject: [PATCH 13/20] Initial access token support --- config/module_oidc.php.dist | 24 ++- routing/routes/routes.php | 10 +- routing/services/services.yml | 1 + src/Codebooks/ApiScopesEnum.php | 4 + src/Codebooks/RoutesEnum.php | 3 +- .../Api/VciCredentialOfferApiController.php | 6 + .../OAuth2/TokenIntrospectionController.php | 156 ++++++++++++++++ .../TokenIntrospectionController.php | 167 ------------------ src/Factories/RequestRulesManagerFactory.php | 6 +- src/ModuleConfig.php | 13 ++ src/Server/Grants/AuthCodeGrant.php | 14 +- .../Rules/ClientAuthenticationRule.php | 120 ++----------- .../Validators/BearerTokenValidator.php | 60 +++++-- src/Services/Container.php | 66 ++++--- .../AuthenticatedOAuth2ClientResolver.php | 30 ++-- src/Utils/RequestParamsResolver.php | 74 ++++---- .../AuthenticatedOAuth2ClientResolverTest.php | 16 +- .../src/Utils/RequestParamsResolverTest.php | 7 +- 18 files changed, 384 insertions(+), 393 deletions(-) create mode 100644 src/Controllers/OAuth2/TokenIntrospectionController.php delete mode 100644 src/Controllers/TokenIntrospectionController.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index e88813ce..e67bfafe 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1037,10 +1037,27 @@ $config = [ /** * (optional) Enable or disable API capabilities. Default is disabled - * (false). + * (false). If API capabilities are enabled, you can enable or disable + * specific API endpoints as needed and set up API tokens to allow + * access to those endpoints. If API capabilities are disabled, all API + * endpoints will be inaccessible regardless of the settings for + * specific endpoints and API tokens. + * */ ModuleConfig::OPTION_API_ENABLED => false, + /** + * (optional) API Enable VCI Credential Offer API endpoint. Default is + * disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => false, + + /** + * (optional) API Enable OAuth2 Token Introspection API endpoint. Default + * is disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => false, + /** * List of API tokens which can be used to access API endpoints based on * given scopes. The format is: ['token' => [ApiScopesEnum]] @@ -1050,6 +1067,11 @@ $config = [ // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll, // Gives access to all VCI-related endpoints. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer, // Gives access to the credential offer endpoint. +// ], +// 'strong-random-token-string-2' => [ +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All, // Gives access to all OAuth2-related endpoints. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection, // Gives access to the token introspection endpoint. // ], ], ]; diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 6b883165..47d02af3 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -19,7 +19,7 @@ use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; -use SimpleSAML\Module\oidc\Controllers\TokenIntrospectionController; +use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -105,8 +105,6 @@ $routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value) ->controller(OAuth2ServerConfigurationController::class); - $routes->add(RoutesEnum::TokenIntrospection->name, RoutesEnum::TokenIntrospection->value) - ->controller(TokenIntrospectionController::class); /***************************************************************************************************************** * OpenID Federation @@ -145,4 +143,10 @@ RoutesEnum::ApiVciCredentialOffer->value, )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) ->methods([HttpMethodsEnum::POST->value]); + + $routes->add( + RoutesEnum::ApiOAuth2TokenIntrospection->name, + RoutesEnum::ApiOAuth2TokenIntrospection->value, + )->controller(TokenIntrospectionController::class) + ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index 8548e461..3efd8030 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -97,6 +97,7 @@ services: SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor: + SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: factory: ['@SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory', 'build'] SimpleSAML\Module\oidc\Utils\FederationCache: factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forFederation'] # Can return null diff --git a/src/Codebooks/ApiScopesEnum.php b/src/Codebooks/ApiScopesEnum.php index 00006ac2..b87ba692 100644 --- a/src/Codebooks/ApiScopesEnum.php +++ b/src/Codebooks/ApiScopesEnum.php @@ -11,4 +11,8 @@ enum ApiScopesEnum: string // Verifiable Credential Issuance related scopes. case VciAll = 'vci_all'; // Gives access to all VCI-related endpoints. case VciCredentialOffer = 'vci_credential_offer'; // Gives access to the credential offer endpoint. + + // OAuth2 related scopes. + case OAuth2All = 'oauth2_all'; // Gives access to all OAuth2-related endpoints. + case OAuth2TokenIntrospection = 'oauth2_token_introspection'; // Gives access to the token introspection endpoint. } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index e4c9eba8..a97d0efe 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -42,8 +42,6 @@ enum RoutesEnum: string case Jwks = 'jwks'; case EndSession = 'end-session'; - case TokenIntrospection = 'introspect'; - /***************************************************************************************************************** * OAuth 2.0 Authorization Server ****************************************************************************************************************/ @@ -77,4 +75,5 @@ enum RoutesEnum: string ****************************************************************************************************************/ case ApiVciCredentialOffer = 'api/vci/credential-offer'; + case ApiOAuth2TokenIntrospection = 'api/oauth2/token-introspection'; } diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index 77d4b52e..58e84f81 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -40,9 +40,15 @@ public function __construct( } /** + * @throws OidcServerException */ public function credentialOffer(Request $request): Response { + if (!$this->moduleConfig->getApiVciCredentialOfferEndpointEnabled()) { + $this->loggerService->warning('Credential Offer API endpoint not enabled.'); + throw OidcServerException::forbidden('Credential Offer API endpoint not enabled.'); + } + $this->loggerService->debug('VciCredentialOfferApiController::credentialOffer'); $this->loggerService->debug( diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php new file mode 100644 index 00000000..59871297 --- /dev/null +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -0,0 +1,156 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + + if (!$this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled()) { + $this->loggerService->warning('OAuth2 Token Introspection API endpoint not enabled.'); + throw OidcServerException::forbidden('OAuth2 Token Introspection API endpoint not enabled.'); + } + } + + public function __invoke(Request $request): Response + { + // TODO mivanci Add support for Refresh Tokens. + // TODO mivanci Add endpoint to OAuth2 discovery document. + + try { + $this->ensureAuthenticatedClient($request); + } catch (AuthorizationException $e) { + $this->loggerService->error( + 'TokenIntrospectionController::invoke: AuthorizationException: ' . $e->getMessage(), + ); + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $e->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + ); + } + + $allowedMethods = [HttpMethodsEnum::POST]; + + $tokenParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Token->value, + $request, + $allowedMethods, + ); + + if (!$tokenParam) { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Missing token parameter.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + // For now, we will only support Access Tokens. +// $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( +// ParamsEnum::TokenTypeHint->value, +// $request, +// $allowedMethods, +// ); + + try { + $accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam); + } catch (\Throwable $e) { + $this->loggerService->error('Token validation failed: ' . $e->getMessage()); + return $this->routes->newJsonResponse(['active' => false]); + } + $scopeClaim = null; + /** @psalm-suppress MixedAssignment */ + $accessTokenScopes = $accessToken->getPayloadClaim('scopes'); + if (is_array($accessTokenScopes)) { + $accessTokenScopes = array_filter( + $accessTokenScopes, + static fn($scope) => is_string($scope) && !empty($scope), + ); + $scopeClaim = implode(' ', $accessTokenScopes); + } + + $audience = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null; + + $payload = array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'token_type' => 'Bearer', + ClaimsEnum::Exp->value => $accessToken->getExpirationTime(), + ClaimsEnum::Iat->value => $accessToken->getIssuedAt(), + ClaimsEnum::Nbf->value => $accessToken->getNotBefore(), + ClaimsEnum::Sub->value => $accessToken->getSubject(), + ClaimsEnum::Aud->value => $audience, + ClaimsEnum::Iss->value => $accessToken->getIssuer(), + ClaimsEnum::Jti->value => $accessToken->getJwtId(), + ]); + + return $this->routes->newJsonResponse($payload); + } + + /** + * @throws AuthorizationException + */ + protected function ensureAuthenticatedClient(Request $request): void + { + $this->loggerService->debug('TokenIntrospectionController::ensureAuthenticatedClient - start'); + $this->loggerService->debug('Trying supported OAuth2 client authentication methods.'); + + // First, try regular OAuth2 client authentication methods. + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $this->loggerService->debug( + 'Client authenticated using supported OAuth2 client authentication method: ' . + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value, + ); + return; + } + + $this->loggerService->debug('No regular OAuth2 client authentication method found.'); + $this->loggerService->debug('Trying API client authentication method.'); + + + $this->apiAuthorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::OAuth2TokenIntrospection, ApiScopesEnum::OAuth2All, ApiScopesEnum::All], + ); + } +} diff --git a/src/Controllers/TokenIntrospectionController.php b/src/Controllers/TokenIntrospectionController.php deleted file mode 100644 index 970a4f81..00000000 --- a/src/Controllers/TokenIntrospectionController.php +++ /dev/null @@ -1,167 +0,0 @@ -getMethod() !== 'POST') { - return new JsonResponse( - [ - 'error' => 'invalid_request', - 'error_description' => 'Token introspection endpoint only accepts POST requests.', - ], - 405, - ); - } - - // Check the content type - $contentType = (string)$request->headers->get('Content-Type', ''); - if ($contentType === '' || !str_contains($contentType, 'application/x-www-form-urlencoded')) { - return new JsonResponse( - [ - 'error' => 'invalid_request', - 'error_description' => 'Content-Type must be application/x-www-form-urlencoded.', - ], - 400, - ); - } - - // Check client authentication - try { - $psr = ServerRequestFactory::fromGlobals(); - $psrRequest = $psr->withParsedBody($request->request->all()); - - $resultBag = $this->requestRulesManager->check( - $psrRequest, - [ - ClientIdRule::class, - ClientAuthenticationRule::class, - ], - false, - ['POST'], - ); - - // Get client id to later check if the token is from the authenticated client - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); - $this->authenticatedClientId = $client->getIdentifier(); - } catch (OidcServerException | \Throwable $e) { - return new JsonResponse( - [ - 'error' => 'invalid_client', - 'error_description' => 'Client authentication failed.', - ], - 401, - ); - } - - // Check if token is provided - $token = (string)$request->request->get('token', ''); - if ($token === '') { - return new JsonResponse( - [ - 'error' => 'invalid_request', - 'error_description' => 'Missing required parameter token.', - ], - 400, - ); - } - - // Check the token - return $this->introspectToken($token); - } - - private function introspectToken(string $token): JsonResponse - { - try { - $psrRequest = ServerRequestFactory::fromGlobals() - ->withHeader('Authorization', 'Bearer ' . $token); - - $authorization = $this->resourceServer->validateAuthenticatedRequest($psrRequest); - - $tokenId = $authorization->getAttribute('oauth_access_token_id'); - $accessToken = $this->accessTokenRepository->findById($tokenId); - - if (!$accessToken instanceof AccessTokenEntity) { - return new JsonResponse(['active' => false], 200); - } - - if ($accessToken->getClient()->getIdentifier() !== $this->authenticatedClientId) { - return new JsonResponse(['active' => false], 200); - } - - if ($accessToken->isRevoked()) { - return new JsonResponse(['active' => false], 200); - } - - $tokenParts = explode('.', $token); - $payload = json_decode(base64_decode(strtr($tokenParts[1], '-_', '+/')), true); - - $receivedTokenIssuer = $payload['iss']; - $expectedTokenIssuer = $this->moduleConfig->getIssuer(); - if ($receivedTokenIssuer !== $expectedTokenIssuer) { - return new JsonResponse( - [ - 'active' => false, - 'cause' => 'token issuer mismatch, expected: ' . $expectedTokenIssuer . ' actual: ' . - $receivedTokenIssuer, - ], - 200, - ); - } - - $introspectionResponse = [ - 'active' => true, - 'scope' => implode( - ' ', - array_map(static fn($scope) => $scope->getIdentifier(), $accessToken->getScopes()), - ), - 'client_id' => $accessToken->getClient()->getIdentifier(), - 'username' => (string) $accessToken->getUserIdentifier(), - 'token_type' => 'Bearer', - 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), - ]; - - $introspectionClaims = ['iat', 'nbf', 'sub', 'aud', 'iss', 'jti']; - foreach ($introspectionClaims as $claim) { - if (isset($payload[$claim])) { - $introspectionResponse[$claim] = $payload[$claim]; - } - } - return new JsonResponse($introspectionResponse, 200); - } catch (\Throwable) { - return new JsonResponse(['active' => false], 200); - } - } -} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 8e865d41..9b77e7c0 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -38,6 +38,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Services\AuthenticationService; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; @@ -68,6 +69,7 @@ public function __construct( private readonly SspBridge $sspBridge, private readonly Jwks $jwks, private readonly Core $core, + private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -145,9 +147,7 @@ private function getDefaultRules(): array new ClientAuthenticationRule( $this->requestParamsResolver, $this->helpers, - $this->moduleConfig, - $this->jwksResolver, - $this->protocolCache, + $this->authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 55d22640..a2037f94 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -100,6 +100,9 @@ class ModuleConfig final public const OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = 'vci_user_attribute_to_credential_claim_path_map'; final public const OPTION_API_ENABLED = 'api_enabled'; + final public const OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED = 'api_vci_credential_offer_endpoint_enabled'; + final public const OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED = + 'api_oauth2_token_introspection_endpoint_enabled'; final public const OPTION_API_TOKENS = 'api_tokens'; final public const OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; final public const OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = @@ -1038,6 +1041,16 @@ public function getApiEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_API_ENABLED, false); } + public function getApiVciCredentialOfferEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED, false); + } + + public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false); + } + /** * @return mixed[]|null */ diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 5693e220..dbbbce0b 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -70,6 +70,7 @@ use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -511,8 +512,8 @@ public function respondToAccessTokenRequest( $authorizationClientEntity : $resultBag->getOrFail(ClientRule::class)->getValue(); - /** @var ?string $clientAuthenticationParam */ - $clientAuthenticationParam = $authorizationClientEntity->isGeneric() ? + /** @var ?ResolvedClientAuthenticationMethod $resolvedClientAuthenticationMethod */ + $resolvedClientAuthenticationMethod = $authorizationClientEntity->isGeneric() ? null : $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); @@ -521,8 +522,13 @@ public function respondToAccessTokenRequest( $utilizedClientAuthenticationParams = []; - if (!is_null($clientAuthenticationParam)) { - $utilizedClientAuthenticationParams[] = $clientAuthenticationParam; + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $utilizedClientAuthenticationParams[] = $resolvedClientAuthenticationMethod + ->getClientAuthenticationMethod() + ->value; } if (!is_null($codeVerifier)) { $utilizedClientAuthenticationParams[] = ParamsEnum::CodeVerifier->value; diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index d0adae6e..7a062bd6 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -5,34 +5,22 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; -use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Codebooks\ClientAssertionTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Codebooks\ParamsEnum; class ClientAuthenticationRule extends AbstractRule { - protected const KEY_CLIENT_ASSERTION_JTI = 'client_assertion_jti'; - public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, - protected ModuleConfig $moduleConfig, - protected JwksResolver $jwksResolver, protected AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, - protected ?ProtocolCache $protocolCache, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -55,116 +43,28 @@ public function checkRule( // TODO mivanci Instead of ClientRule which mandates client, this should // be refactored to use optional client_id parameter and then // fetch client if present. - /** @var ?ClientEntityInterface $preFetchedClient */ + /** @var ?\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $preFetchedClient */ $preFetchedClient = $currentResultBag->get(ClientRule::class)?->getValue(); - $client = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod( + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod( request: $request, preFetchedClient: $preFetchedClient, ); - if (is_null($client)) { - throw OidcServerException::accessDenied('Not a single client authentication method presented.'); - } - - // TODO mivanci continue - //////////////////////// - - - - // We will only perform client authentication if the client type is confidential. - if (!$client->isConfidential()) { - return new Result($this->getKey(), null); - } - - // Let's check if client secret is provided. - /** @var ?string $clientSecret */ - $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientSecret->value, - $request, - $allowedServerRequestMethods, - ) ?? $request->getServerParams()['PHP_AUTH_PW'] ?? null; - - if ($clientSecret) { - hash_equals($client->getSecret(), $clientSecret) || throw OidcServerException::invalidClient($request); - return new Result($this->getKey(), ParamsEnum::ClientSecret->value); - } - - // No client_secret provided, meaning client_secret_post or client_secret_basic client authentication methods - // were not used. Let's check for private_key_jwt method. - $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertion->value, - $request, - $allowedServerRequestMethods, - ); - - if (is_null($clientAssertionParam)) { + if (is_null($resolvedClientAuthenticationMethod)) { throw OidcServerException::accessDenied('Not a single client authentication method presented.'); } - // private_key_jwt authentication method is used. - // Check the expected assertion type param. - $clientAssertionType = $this->requestParamsResolver->getfromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertionType->value, - $request, - $allowedServerRequestMethods, - ); - - if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { - throw OidcServerException::invalidRequest(ParamsEnum::ClientAssertionType->value); - } - - $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); - - // Check if the Client Assertion token has already been used. Only applicable if we have cache available. - if ($this->protocolCache) { - ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) - || throw OidcServerException::invalidRequest( - ParamsEnum::ClientAssertion->value, - 'Client Assertion reused.', - ); - } - - ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( - 'Can not validate Client Assertion, client JWKS not available.', - ); - - try { - $clientAssertion->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + // Ensure we that the method is not 'None' if client is confidential. + if ( + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNone() && + $resolvedClientAuthenticationMethod->getClient()->isConfidential() + ) { throw OidcServerException::accessDenied( - 'Client Assertion validation failed: ' . $exception->getMessage(), + 'Confidential client must use an authentication method other than "none".', ); } - ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Issuer claim.', - ); - - ($client->getIdentifier() === $clientAssertion->getSubject()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Subject claim.', - ); - - // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. - // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization - // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. - $expectedAudience = [ - $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), - $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), - $this->moduleConfig->getIssuer(), - ]; - - (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || - throw OidcServerException::accessDenied('Invalid Client Assertion Audience claim.'); - - // Everything seems ok. Save it in cache so we can check for reuse. - $this->protocolCache?->set( - $clientAssertion->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), - self::KEY_CLIENT_ASSERTION_JTI, - $clientAssertion->getJwtId(), - ); - - return new Result($this->getKey(), ParamsEnum::ClientAssertion->value); + return new Result($this->getKey(), $resolvedClientAuthenticationMethod); } } diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 35ee4d8f..29de4c02 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -75,31 +75,15 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe } try { - // Attempt to parse the JWT - $token = $this->jws->parsedJwsFactory()->fromToken($jwt); - } catch (JwsException $exception) { + $token = $this->ensureValidAccessToken($jwt); + } catch (\Throwable $exception) { throw OidcServerException::accessDenied($exception->getMessage(), null, $exception); } - try { - // Attempt to validate the JWT - $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), - )->jsonSerialize(); - $token->verifyWithKeySet($jwks); - } catch (JwsException) { - throw OidcServerException::accessDenied('Access token could not be verified'); - } - if (is_null($jti = $token->getJwtId()) || empty($jti)) { throw OidcServerException::accessDenied('Access token malformed (jti missing or unexpected type)'); } - // Check if token has been revoked - if ($this->accessTokenRepository->isAccessTokenRevoked($jti)) { - throw OidcServerException::accessDenied('Access token has been revoked'); - } - // Return the request with additional attributes return $request ->withAttribute('oauth_access_token_id', $jti) @@ -108,6 +92,44 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe ->withAttribute('oauth_scopes', $token->getPayloadClaim('scopes')); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function ensureValidAccessToken(string $accessTokenJwt): Jws\ParsedJws + { + // Attempt to parse the JWT + $token = $this->jws->parsedJwsFactory()->fromToken($accessTokenJwt); + + // Attempt to validate the JWT + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + $token->verifyWithKeySet($jwks); + + $token->getExpirationTime(); + + if (is_null($iss = $token->getIssuer()) || empty($iss)) { + throw new JwsException('Access token malformed (iss missing or unexpected type)'); + } + + if ($iss !== $this->moduleConfig->getIssuer()) { + throw new JwsException('Access token malformed (iss does not match)'); + } + + if (is_null($jti = $token->getJwtId()) || empty($jti)) { + throw new JwsException('Access token malformed (jti missing or unexpected type)'); + } + + // Check if the token has been revoked + if ($this->accessTokenRepository->isAccessTokenRevoked($jti)) { + throw new JwsException('Access token has been revoked'); + } + + return $token; + } + protected function getTokenFromAuthorizationBearer(string $authorizationHeader): string { return trim((string) preg_replace('/^\s*Bearer\s/', '', $authorizationHeader)); @@ -121,7 +143,7 @@ protected function getTokenFromAuthorizationBearer(string $authorizationHeader): * @return array|string * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - protected function convertSingleRecordAudToString(mixed $aud): array|string + public function convertSingleRecordAudToString(mixed $aud): array|string { if (is_string($aud)) { return $aud; diff --git a/src/Services/Container.php b/src/Services/Container.php index 5accbbcf..6aea40f6 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -103,6 +103,7 @@ use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; use SimpleSAML\Module\oidc\Utils\FederationCache; @@ -227,7 +228,31 @@ public function __construct() $federation = $federationFactory->build(); $this->services[Federation::class] = $federation; - $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); + $httpFoundationFactory = new HttpFoundationFactory(); + $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; + + $serverRequestFactory = new ServerRequestFactory(); + $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; + + $responseFactory = new ResponseFactory(); + $this->services[ResponseFactoryInterface::class] = $responseFactory; + + $streamFactory = new StreamFactory(); + $this->services[StreamFactoryInterface::class] = $streamFactory; + + $uploadedFileFactory = new UploadedFileFactory(); + $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; + + $psrHttpBridge = new PsrHttpBridge( + $httpFoundationFactory, + $serverRequestFactory, + $responseFactory, + $streamFactory, + $uploadedFileFactory, + ); + $this->services[PsrHttpBridge::class] = $psrHttpBridge; + + $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation, $psrHttpBridge); $this->services[RequestParamsResolver::class] = $requestParamsResolver; $clientEntityFactory = new ClientEntityFactory( @@ -383,6 +408,18 @@ public function __construct() ); $this->services[FederationParticipationValidator::class] = $federationParticipationValidator; + $authenticatedOAuth2ClientResolver = new AuthenticatedOAuth2ClientResolver( + clientRepository: $clientRepository, + requestParamsResolver: $requestParamsResolver, + loggerService: $loggerService, + psrHttpBridge: $psrHttpBridge, + jwksResolver: $jwksResolver, + moduleConfig: $moduleConfig, + helpers: $helpers, + protocolCache: $protocolCache, + ); + $this->services[AuthenticatedOAuth2ClientResolver::class] = $authenticatedOAuth2ClientResolver; + $requestRules = [ new StateRule($requestParamsResolver, $helpers), new ClientRule( @@ -423,9 +460,7 @@ public function __construct() new ClientAuthenticationRule( $requestParamsResolver, $helpers, - $moduleConfig, - $jwksResolver, - $protocolCache, + $authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($requestParamsResolver, $helpers), ]; @@ -545,29 +580,6 @@ public function __construct() ); $this->services[ResourceServer::class] = $resourceServer; - $httpFoundationFactory = new HttpFoundationFactory(); - $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; - - $serverRequestFactory = new ServerRequestFactory(); - $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; - - $responseFactory = new ResponseFactory(); - $this->services[ResponseFactoryInterface::class] = $responseFactory; - - $streamFactory = new StreamFactory(); - $this->services[StreamFactoryInterface::class] = $streamFactory; - - $uploadedFileFactory = new UploadedFileFactory(); - $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; - - $psrHttpBridge = new PsrHttpBridge( - $httpFoundationFactory, - $serverRequestFactory, - $responseFactory, - $streamFactory, - $uploadedFileFactory, - ); - $this->services[PsrHttpBridge::class] = $psrHttpBridge; $errorResponder = new ErrorResponder($psrHttpBridge); $this->services[ErrorResponder::class] = $errorResponder; diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 002fd159..5c86e39c 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -64,6 +64,10 @@ public function forPublicClient( ): ?ResolvedClientAuthenticationMethod { $this->loggerService->debug('Trying to resolve public client for request client ID.'); + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::ClientId->value, $request, @@ -208,7 +212,14 @@ public function forClientSecretPost( if (!is_string($clientId) || $clientId === '') { $this->loggerService->debug( - 'No client ID available in HTTP POST body, skipping.', + 'No client ID available in HTTP POST body, skipping client_secret_post.', + ); + return null; + } + + if (!is_string($clientSecret) || $clientSecret === '') { + $this->loggerService->debug( + 'No client secret available in HTTP POST body, skipping client_secret_post.', ); return null; } @@ -221,18 +232,11 @@ public function forClientSecretPost( // should not have a secret provided. if (!$client->isConfidential()) { $this->loggerService->debug( - 'Client with ID ' . $clientId . ' is not confidential, aborting client_secret_post validation.', + 'Client with ID ' . $clientId . ' is not confidential, aborting client_secret_post.', ); throw new AuthorizationException('Client is not confidential.'); } - if (!is_string($clientSecret) || $clientSecret === '') { - $this->loggerService->debug( - 'No client secret available in HTTP POST body, aborting client_secret_post validation.', - ); - throw new AuthorizationException('No client secret available in HTTP POST body'); - } - $this->loggerService->debug('Client secret provided for HTTP POST body, validating credentials.'); $this->validateClientSecret($client, $clientSecret); @@ -246,10 +250,10 @@ public function forClientSecretPost( } /** - * @throws JwsException - * @throws ClientAssertionException - * @throws InvalidArgumentException - * @throws AuthorizationException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\ClientAssertionException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function forPrivateKeyJwt( Request|ServerRequestInterface $request, diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index c18600c7..09176946 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -5,47 +5,57 @@ namespace SimpleSAML\Module\oidc\Utils; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use Symfony\Component\HttpFoundation\Request; /** - * Resolve authorization params from HTTP request (based or not based on used method), and from Request Object param if - * present. + * Resolve authorization params from an HTTP request (based or not based on + * a used method), and from Request Object param if present. */ class RequestParamsResolver { public function __construct( - protected Helpers $helpers, - protected Core $core, - protected Federation $federation, + protected readonly Helpers $helpers, + protected readonly Core $core, + protected readonly Federation $federation, + protected readonly PsrHttpBridge $psrHttpBridge, ) { } /** * Get all HTTP request params (not from Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request - * @return array + * @return mixed[] */ - public function getAllFromRequest(ServerRequestInterface $request): array + public function getAllFromRequest(Request|ServerRequestInterface $request): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParams($request); } /** - * Get all HTTP request params based on allowed methods (not from Request Object). + * Get all HTTP request params based on allowed methods (not from + * Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods - * @return array + * @return mixed[] */ public function getAllFromRequestBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParamsBasedOnAllowedMethods( $request, $allowedMethods, @@ -57,7 +67,7 @@ public function getAllFromRequestBasedOnAllowedMethods( * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function getAll(ServerRequestInterface $request): array + public function getAll(Request|ServerRequestInterface $request): array { $requestParams = $this->getAllFromRequest($request); @@ -69,13 +79,14 @@ public function getAll(ServerRequestInterface $request): array /** - * Get all request params based on allowed methods, including those from Request Object if present. + * Get all request params based on allowed methods, including those from + * Request Object if present. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAllBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { $requestParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -87,24 +98,25 @@ public function getAllBasedOnAllowedMethods( } /** - * Get param value from HTTP request or Request Object if present. + * Get param value from an HTTP request or Request Object if present. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function get(string $paramKey, ServerRequestInterface $request): mixed + public function get(string $paramKey, Request|ServerRequestInterface $request): mixed { return $this->getAll($request)[$paramKey] ?? null; } /** - * Get param value from HTTP request or Request Object if present, based on allowed methods. + * Get param value from an HTTP request or Request Object if present, + * based on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): mixed { $allParams = $this->getAllBasedOnAllowedMethods($request, $allowedMethods); @@ -112,18 +124,18 @@ public function getBasedOnAllowedMethods( } /** - * Get param value as null or string from HTTP request or Request Object if present, based on allowed methods. - * This is convenience method, since in most cases params will be strings (or absent). + * Get param value as null or string from an HTTP request or Request Object + * if present, based on allowed methods. This is a convenience method, + * since in most cases params will be strings (or absent). * * @param string $paramKey - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @return string|null * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAsStringBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { /** @psalm-suppress MixedAssignment */ @@ -133,13 +145,14 @@ public function getAsStringBasedOnAllowedMethods( } /** - * Get param value from HTTP request (not from Request Object), based on allowed methods. + * Get param value from an HTTP request (not from Request Object), based + * on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods */ public function getFromRequestBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { $allParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -148,7 +161,8 @@ public function getFromRequestBasedOnAllowedMethods( } /** - * Check if Request Object is present as request param and parse it to use its claims as params. + * Check if Request Object is present as a request param and parse it to + * use its claims as params. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -176,8 +190,8 @@ public function parseRequestObjectToken(string $token): Core\RequestObject } /** - * Parse the Request Object token according to OpenID Federation specification. - * Note that this won't do signature validation of it. + * Parse the Request Object token according to OpenID Federation + * specification. Note that this won't do signature validation of it. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\RequestObjectException @@ -199,13 +213,11 @@ public function parseClientAssertionToken(string $clientAssertionParam): Core\Cl } /** - * @param ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods - * @return bool * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function isVciAuthorizationCodeRequest( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): bool { return diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php index 4b6529d7..b788ec7c 100644 --- a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -338,28 +338,20 @@ public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void $this->sut()->forClientSecretPost($this->serverRequestMock); } - public function testForClientSecretPostThrowsWhenSecretIsAbsent(): void + public function testForClientSecretPostReturnsNullWhenSecretIsNull(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') ->willReturnOnConsecutiveCalls(self::CLIENT_ID, null); - $this->clientEntityMock->method('isConfidential')->willReturn(true); - $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); - - $this->expectException(AuthorizationException::class); - $this->sut()->forClientSecretPost($this->serverRequestMock); + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } - public function testForClientSecretPostThrowsWhenSecretIsEmpty(): void + public function testForClientSecretPostReturnsNullWhenSecretIsEmpty(): void { $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') ->willReturnOnConsecutiveCalls(self::CLIENT_ID, ''); - $this->clientEntityMock->method('isConfidential')->willReturn(true); - $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); - - $this->expectException(AuthorizationException::class); - $this->sut()->forClientSecretPost($this->serverRequestMock); + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); } public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 0cbc269a..a183bf91 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -26,6 +27,7 @@ class RequestParamsResolverTest extends TestCase protected MockObject $requestObjectMock; protected MockObject $requestObjectFactoryMock; protected MockObject $federationMock; + protected MockObject $psrHttpBridgeMock; protected array $queryParams = [ 'a' => 'b', @@ -54,18 +56,21 @@ protected function setUp(): void $this->coreMock = $this->createMock(Core::class); $this->coreMock->method('requestObjectFactory')->willReturn($this->requestObjectFactoryMock); $this->federationMock = $this->createMock(Federation::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); } protected function mock( ?MockObject $helpersMock = null, ?MockObject $coreMock = null, ?MockObject $federationMock = null, + ?MockObject $psrHttpBridgeMock = null, ): RequestParamsResolver { $helpersMock ??= $this->helpersMock; $coreMock ??= $this->coreMock; $federationMock ??= $this->federationMock; + $psrHttpBridgeMock ??= $this->psrHttpBridgeMock; - return new RequestParamsResolver($helpersMock, $coreMock, $federationMock); + return new RequestParamsResolver($helpersMock, $coreMock, $federationMock, $psrHttpBridgeMock); } public function testCanCreateInstance(): void From 313d69179402e5076b4b0da03b10365de622e277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 7 Mar 2026 11:14:02 +0100 Subject: [PATCH 14/20] Support refresh token introspection --- docker/ssp/config-override.php | 1 + docker/ssp/module_oidc.php | 12 ++ routing/services/services.yml | 2 +- src/Bridges/OAuth2Bridge.php | 66 ++++++++ .../OAuth2/TokenIntrospectionController.php | 144 +++++++++++++++--- src/Services/Api/Authorization.php | 22 +-- .../Validators/BearerTokenValidatorTest.php | 3 + 7 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 src/Bridges/OAuth2Bridge.php diff --git a/docker/ssp/config-override.php b/docker/ssp/config-override.php index 0aa1e88f..f5e0136f 100644 --- a/docker/ssp/config-override.php +++ b/docker/ssp/config-override.php @@ -14,4 +14,5 @@ 'language.i18n.backend' => 'gettext/gettext', 'logging.level' => 7, 'usenewui' => false, + ] + $config; \ No newline at end of file diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index 5aacdb73..e44f3049 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -128,4 +128,16 @@ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Use defaults ], + + ModuleConfig::OPTION_API_ENABLED => true, + + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_TOKENS => [ + 'strong-random-token-string' => [ + \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. + ], + ], ]; diff --git a/routing/services/services.yml b/routing/services/services.yml index 3efd8030..84054d20 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -96,8 +96,8 @@ services: SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ + SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~ SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor: - SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: factory: ['@SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory', 'build'] SimpleSAML\Module\oidc\Utils\FederationCache: factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forFederation'] # Can return null diff --git a/src/Bridges/OAuth2Bridge.php b/src/Bridges/OAuth2Bridge.php new file mode 100644 index 00000000..5ecc0207 --- /dev/null +++ b/src/Bridges/OAuth2Bridge.php @@ -0,0 +1,66 @@ +moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::encrypt($unencryptedData, $encryptionKey) : + Crypto::encryptWithPassword($unencryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error enrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Bridge `decrypt` function, which can be used instead of + * \League\OAuth2\Server\CryptTrait::decrypt() + * + * @param string $encryptedData + * @param Key|string $encryptionKey + * @return string + * @throws OidcException + */ + public function decrypt( + string $encryptedData, + null|Key|string $encryptionKey = null, + ): string { + $encryptionKey ??= $this->moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::decrypt($encryptedData, $encryptionKey) : + Crypto::decryptWithPassword($encryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error decrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } +} diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php index 59871297..3ebb8032 100644 --- a/src/Controllers/OAuth2/TokenIntrospectionController.php +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -4,9 +4,11 @@ namespace SimpleSAML\Module\oidc\Controllers\OAuth2; +use SimpleSAML\Module\oidc\Bridges\OAuth2Bridge; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; use SimpleSAML\Module\oidc\Services\Api\Authorization; @@ -34,6 +36,8 @@ public function __construct( protected readonly Authorization $apiAuthorization, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly BearerTokenValidator $bearerTokenValidator, + protected readonly OAuth2Bridge $oAuth2Bridge, + protected readonly RefreshTokenRepository $refreshTokenRepository, ) { if (!$this->moduleConfig->getApiEnabled()) { $this->loggerService->warning('API capabilities not enabled.'); @@ -48,7 +52,6 @@ public function __construct( public function __invoke(Request $request): Response { - // TODO mivanci Add support for Refresh Tokens. // TODO mivanci Add endpoint to OAuth2 discovery document. try { @@ -80,46 +83,140 @@ public function __invoke(Request $request): Response ); } - // For now, we will only support Access Tokens. -// $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( -// ParamsEnum::TokenTypeHint->value, -// $request, -// $allowedMethods, -// ); + $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::TokenTypeHint->value, + $request, + $allowedMethods, + ); + $payload = null; + if (is_null($tokenTypeHintParam)) { + $payload = $this->resolveAccessTokenPayload($tokenParam) ?? + $this->resolveRefreshTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'access_token') { + $payload = $this->resolveAccessTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'refresh_token') { + $payload = $this->resolveRefreshTokenPayload($tokenParam); + } + + $payload = $payload ?? ['active' => false]; + + return $this->routes->newJsonResponse($payload); + } + + protected function resolveAccessTokenPayload(string $tokenParam): ?array + { try { $accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam); } catch (\Throwable $e) { - $this->loggerService->error('Token validation failed: ' . $e->getMessage()); - return $this->routes->newJsonResponse(['active' => false]); + $this->loggerService->error('Access token validation failed: ' . $e->getMessage()); + return null; } + + // See \SimpleSAML\Module\oidc\Entities\AccessTokenEntity::convertToJWT + // for claims set on the access token. + $scopeClaim = null; /** @psalm-suppress MixedAssignment */ $accessTokenScopes = $accessToken->getPayloadClaim('scopes'); if (is_array($accessTokenScopes)) { - $accessTokenScopes = array_filter( - $accessTokenScopes, - static fn($scope) => is_string($scope) && !empty($scope), - ); - $scopeClaim = implode(' ', $accessTokenScopes); + $scopeClaim = $this->prepareScopeString($accessTokenScopes); } - $audience = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null; + $clientId = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null; - $payload = array_filter([ + return array_filter([ 'active' => true, 'scope' => $scopeClaim, + 'client_id' => $clientId, 'token_type' => 'Bearer', ClaimsEnum::Exp->value => $accessToken->getExpirationTime(), ClaimsEnum::Iat->value => $accessToken->getIssuedAt(), ClaimsEnum::Nbf->value => $accessToken->getNotBefore(), ClaimsEnum::Sub->value => $accessToken->getSubject(), - ClaimsEnum::Aud->value => $audience, + ClaimsEnum::Aud->value => $accessToken->getAudience(), ClaimsEnum::Iss->value => $accessToken->getIssuer(), ClaimsEnum::Jti->value => $accessToken->getJwtId(), ]); + } - return $this->routes->newJsonResponse($payload); + /** + * @psalm-suppress MixedAssignment + */ + public function resolveRefreshTokenPayload(string $tokenParam): ?array + { + try { + $decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam); + $tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->loggerService->error('Refresh token decrypting failed: ' . $e->getMessage()); + return null; + } + + if (!is_array($tokenData)) { + $this->loggerService->error('Refresh token has unexpected type.'); + return null; + } + + // See \League\OAuth2\Server\ResponseTypes\BearerTokenResponse::generateHttpResponse for claims set on + // the refresh token. + + $expireTime = is_int($expireTime = $tokenData['expire_time'] ?? null) ? $expireTime : null; + + if (is_null($expireTime)) { + $this->loggerService->error('Refresh token has no expiration time.'); + return null; + } + + if ($expireTime < time()) { + $this->loggerService->error('Refresh token has expired.'); + return null; + } + + $refreshTokenId = is_string($refreshTokenId = $tokenData['refresh_token_id'] ?? null) ? $refreshTokenId : null; + + if (is_null($refreshTokenId)) { + $this->loggerService->error('Refresh token has no ID.'); + return null; + } + + try { + if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenId)) { + $this->loggerService->error('Refresh token has been revoked.'); + return null; + } + } catch (OidcServerException $e) { + $this->loggerService->error('Refresh token revocation check failed: ' . $e->getMessage()); + return null; + } + + $scopeClaim = null; + $refreshTokenScopes = $tokenData['scopes'] ?? null; + if (is_array($refreshTokenScopes)) { + $scopeClaim = $this->prepareScopeString($refreshTokenScopes); + } + + $clientId = is_string($clientId = $tokenData['client_id'] ?? null) ? $clientId : null; + + return array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'client_id' => $clientId, + ClaimsEnum::Exp->value => $expireTime, + ClaimsEnum::Sub->value => is_string($tokenData['user_id'] ?? null) ? $tokenData['user_id'] : null, + ClaimsEnum::Aud->value => $clientId, + ClaimsEnum::Jti->value => $refreshTokenId, + ]); + } + + protected function prepareScopeString(array $scopes): string + { + $scopes = array_filter( + $scopes, + static fn($scope) => is_string($scope) && !empty($scope), + ); + + return implode(' ', $scopes); } /** @@ -138,19 +235,24 @@ protected function ensureAuthenticatedClient(Request $request): void $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() ) { $this->loggerService->debug( - 'Client authenticated using supported OAuth2 client authentication method: ' . - $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value, + sprintf( + 'Client %s authenticated using supported OAuth2 client authentication method %s.', + $resolvedClientAuthenticationMethod->getClient()->getIdentifier(), + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value, + ), ); + return; } $this->loggerService->debug('No regular OAuth2 client authentication method found.'); $this->loggerService->debug('Trying API client authentication method.'); - $this->apiAuthorization->requireTokenForAnyOfScope( $request, [ApiScopesEnum::OAuth2TokenIntrospection, ApiScopesEnum::OAuth2All, ApiScopesEnum::All], ); + + $this->loggerService->debug('API client authenticated.'); } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 66979574..fed8e1f0 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -60,28 +60,26 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope } if (empty($token = $this->findToken($request))) { - throw new AuthorizationException(Translate::noop('Token not provided.')); + throw new AuthorizationException(Translate::noop('Authorization token not provided.')); } if (empty($tokenScopes = $this->moduleConfig->getApiTokenScopes($token))) { - throw new AuthorizationException(Translate::noop('Token does not have defined scopes.')); + throw new AuthorizationException(Translate::noop('Authorization token does not have defined scopes.')); } $hasAny = !empty(array_filter($tokenScopes, fn($tokenScope) => in_array($tokenScope, $requiredScopes, true))); if (!$hasAny) { - throw new AuthorizationException(Translate::noop('Token is not authorized.')); + throw new AuthorizationException(Translate::noop('Authorization token is not authorized for this action.')); } } protected function findToken(Request $request): ?string { - /** @psalm-suppress InternalMethod */ - if ($token = trim((string) $request->get(self::KEY_TOKEN))) { - return $token; - } - - if ($request->headers->has(self::KEY_AUTHORIZATION)) { + if ( + is_string($authorizationHeader = $request->headers->get(self::KEY_AUTHORIZATION)) + && str_starts_with($authorizationHeader, 'Bearer ') + ) { return trim( (string) preg_replace( '/^\s*Bearer\s/', @@ -91,6 +89,12 @@ protected function findToken(Request $request): ?string ); } + // Fallback to token parameter. + /** @psalm-suppress InternalMethod */ + if ($token = trim((string) $request->get(self::KEY_TOKEN))) { + return $token; + } + return null; } } diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index 162cce45..72f9aa55 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -51,6 +51,7 @@ public function setUp(): void $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); $this->serverRequest = new ServerRequest(); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn('issuer123'); $this->jwsMock = $this->createMock(Jws::class); $this->jwksMock = $this->createMock(Jwks::class); @@ -62,6 +63,7 @@ public function setUp(): void $this->accessTokenState = [ 'id' => 'accessToken123', + 'iss' => 'issuer123', 'scopes' => '{"openid":"openid","profile":"profile"}', 'expires_at' => date('Y-m-d H:i:s', time() + 60), 'user_id' => 'user123', @@ -80,6 +82,7 @@ public function setUp(): void $this->parsedJwsMock = $this->createMock(ParsedJws::class); $this->parsedJwsMock->method('getJwtId')->willReturn('accessToken123'); $this->parsedJwsMock->method('getAudience')->willReturn([$this->clientId]); + $this->parsedJwsMock->method('getIssuer')->willReturn('issuer123'); } protected function sut( From 121137bb2035a6b8a2b08237f1529fa2aa46ffef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 7 Mar 2026 18:40:28 +0100 Subject: [PATCH 15/20] Include introspection endpoint in OAuth2 metadata --- .../OAuth2ServerConfigurationController.php | 26 ++++++++++++++++++- src/Utils/Routes.php | 5 ++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php index 9984d16a..46b385d0 100644 --- a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -4,8 +4,12 @@ namespace SimpleSAML\Module\oidc\Controllers\OAuth2; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\AccessTokenTypesEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use Symfony\Component\HttpFoundation\JsonResponse; class OAuth2ServerConfigurationController @@ -13,14 +17,34 @@ class OAuth2ServerConfigurationController public function __construct( protected readonly OpMetadataService $opMetadataService, protected readonly Routes $routes, + protected readonly ModuleConfig $moduleConfig, ) { } public function __invoke(): JsonResponse { // We'll reuse OIDC configuration. + $configuration = $this->opMetadataService->getMetadata(); + + if ( + $this->moduleConfig->getApiEnabled() && + $this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled() + ) { + $configuration[ClaimsEnum::IntrospectionEndpoint->value] = $this->routes->urlApiOAuth2TokenIntrospection(); + $configuration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $configuration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); + } + return $this->routes->newJsonResponse( - $this->opMetadataService->getMetadata(), + $configuration, ); // TODO mivanci Add ability for claim 'signed_metadata' when moving to simplesamlphp/openid, as per diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index f7763577..ba43de0c 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -244,4 +244,9 @@ public function urlApiVciCredentialOffer(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); } + + public function urlApiOAuth2TokenIntrospection(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiOAuth2TokenIntrospection->value, $parameters); + } } From 72d9054848450ba38cb5579ffab3ba5463e6a10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 7 Mar 2026 18:41:34 +0100 Subject: [PATCH 16/20] Upate config-override --- docker/ssp/config-override.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker/ssp/config-override.php b/docker/ssp/config-override.php index f5e0136f..29fdb306 100644 --- a/docker/ssp/config-override.php +++ b/docker/ssp/config-override.php @@ -11,8 +11,6 @@ 'database.dsn' => getenv('DB.DSN') ?: 'sqlite:/var/simplesamlphp/data/mydb.sq3', 'database.username' => getenv('DB.USERNAME') ?: 'user', 'database.password' => getenv('DB.PASSWORD') ?: 'password', - 'language.i18n.backend' => 'gettext/gettext', 'logging.level' => 7, - 'usenewui' => false, ] + $config; \ No newline at end of file From a5c1e39e02b5de5a53e1b6731ff2aa554367b3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 9 Mar 2026 11:37:26 +0100 Subject: [PATCH 17/20] Add unit tests --- src/Bridges/OAuth2Bridge.php | 2 +- .../OAuth2/TokenIntrospectionController.php | 2 - tests/unit/src/Bridges/OAuth2BridgeTest.php | 81 ++++ ...Auth2ServerConfigurationControllerTest.php | 129 ++++++ .../TokenIntrospectionControllerTest.php | 414 ++++++++++++++++++ ...ResolvedClientAuthenticationMethodTest.php | 52 +++ 6 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 tests/unit/src/Bridges/OAuth2BridgeTest.php create mode 100644 tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php create mode 100644 tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php create mode 100644 tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php diff --git a/src/Bridges/OAuth2Bridge.php b/src/Bridges/OAuth2Bridge.php index 5ecc0207..0eebec4d 100644 --- a/src/Bridges/OAuth2Bridge.php +++ b/src/Bridges/OAuth2Bridge.php @@ -36,7 +36,7 @@ public function encrypt( Crypto::encrypt($unencryptedData, $encryptionKey) : Crypto::encryptWithPassword($unencryptedData, $encryptionKey); } catch (\Exception $e) { - throw new OidcException('Error enrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + throw new OidcException('Error encrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); } } diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php index 3ebb8032..bf2bf1a5 100644 --- a/src/Controllers/OAuth2/TokenIntrospectionController.php +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -52,8 +52,6 @@ public function __construct( public function __invoke(Request $request): Response { - // TODO mivanci Add endpoint to OAuth2 discovery document. - try { $this->ensureAuthenticatedClient($request); } catch (AuthorizationException $e) { diff --git a/tests/unit/src/Bridges/OAuth2BridgeTest.php b/tests/unit/src/Bridges/OAuth2BridgeTest.php new file mode 100644 index 00000000..5963247e --- /dev/null +++ b/tests/unit/src/Bridges/OAuth2BridgeTest.php @@ -0,0 +1,81 @@ +moduleConfig = $this->createMock(ModuleConfig::class); + $this->bridge = new OAuth2Bridge($this->moduleConfig); + } + + + public function testEncryptDecryptWithPasswordFromConfig(): void + { + $password = 'secret-password'; + $this->moduleConfig->method('getEncryptionKey')->willReturn($password); + + $unencrypted = 'secret-data-2'; + $encrypted = $this->bridge->encrypt($unencrypted); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitKey(): void + { + $key = Key::createNewRandomKey(); + + $unencrypted = 'secret-data-3'; + $encrypted = $this->bridge->encrypt($unencrypted, $key); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $key); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitPassword(): void + { + $password = 'secret-password-explicit'; + + $unencrypted = 'secret-data-4'; + $encrypted = $this->bridge->encrypt($unencrypted, $password); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $password); + $this->assertEquals($unencrypted, $decrypted); + } + + + public function testDecryptThrowsOidcExceptionOnInvalidData(): void + { + $this->moduleConfig->method('getEncryptionKey')->willReturn('secret-password'); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('Error decrypting data:'); + + $this->bridge->decrypt('invalid-encrypted-data'); + } +} diff --git a/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php new file mode 100644 index 00000000..b0ea6ef0 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php @@ -0,0 +1,129 @@ + 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorization', + 'token_endpoint' => 'http://localhost/token', + ]; + + protected MockObject $opMetadataServiceMock; + protected MockObject $routesMock; + protected MockObject $moduleConfigMock; + + protected function setUp(): void + { + $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); + $this->routesMock = $this->createMock(Routes::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->opMetadataServiceMock->method('getMetadata')->willReturn(self::OIDC_OP_METADATA); + } + + protected function mock( + ?OpMetadataService $opMetadataService = null, + ?Routes $routes = null, + ?ModuleConfig $moduleConfig = null, + ): OAuth2ServerConfigurationController { + return new OAuth2ServerConfigurationController( + $opMetadataService ?? $this->opMetadataServiceMock, + $routes ?? $this->routesMock, + $moduleConfig ?? $this->moduleConfigMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf( + OAuth2ServerConfigurationController::class, + $this->mock(), + ); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfApiDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfIntrospectionDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithIntrospectionEndpointEnabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $signatureAlgorithmBagMock = $this->createMock(SignatureAlgorithmBag::class); + $signatureAlgorithmBagMock->method('getAllNamesUnique')->willReturn(['RS256', 'ES256']); + + $supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); + $supportedAlgorithmsMock->method('getSignatureAlgorithmBag')->willReturn($signatureAlgorithmBagMock); + + $this->moduleConfigMock->method('getSupportedAlgorithms')->willReturn($supportedAlgorithmsMock); + + $introspectionEndpoint = 'http://localhost/introspect'; + $this->routesMock->method('urlApiOAuth2TokenIntrospection')->willReturn($introspectionEndpoint); + + $expectedConfiguration = self::OIDC_OP_METADATA; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpoint->value] = $introspectionEndpoint; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = [ + 'RS256', + 'ES256', + ]; + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($expectedConfiguration) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } +} diff --git a/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php new file mode 100644 index 00000000..fbb9be28 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php @@ -0,0 +1,414 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $this->authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); + $this->routesMock = $this->createMock(Routes::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->apiAuthorizationMock = $this->createMock(Authorization::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->bearerTokenValidatorMock = $this->createMock(BearerTokenValidator::class); + $this->oAuth2BridgeMock = $this->createMock(OAuth2Bridge::class); + $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepository::class); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver = null, + ?Routes $routes = null, + ?LoggerService $loggerService = null, + ?Authorization $apiAuthorization = null, + ?RequestParamsResolver $requestParamsResolver = null, + ?BearerTokenValidator $bearerTokenValidator = null, + ?OAuth2Bridge $oAuth2Bridge = null, + ?RefreshTokenRepository $refreshTokenRepository = null, + ): TokenIntrospectionController { + return new TokenIntrospectionController( + $moduleConfig ?? $this->moduleConfigMock, + $authenticatedOAuth2ClientResolver ?? $this->authenticatedOAuth2ClientResolverMock, + $routes ?? $this->routesMock, + $loggerService ?? $this->loggerServiceMock, + $apiAuthorization ?? $this->apiAuthorizationMock, + $requestParamsResolver ?? $this->requestParamsResolverMock, + $bearerTokenValidator ?? $this->bearerTokenValidatorMock, + $oAuth2Bridge ?? $this->oAuth2BridgeMock, + $refreshTokenRepository ?? $this->refreshTokenRepositoryMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(TokenIntrospectionController::class, $this->sut()); + } + + public function testConstructThrowsForbiddenIfApiNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('API capabilities not enabled.', $e->getHint()); + throw $e; + } + } + + public function testConstructThrowsForbiddenIfIntrospectionNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('OAuth2 Token Introspection API endpoint not enabled.', $e->getHint()); + throw $e; + } + } + + private function createValidResolvedClientAuthenticationMethodMock(): MockObject&ResolvedClientAuthenticationMethod + { + $mock = $this->createMock(ResolvedClientAuthenticationMethod::class); + $mock->method('getClientAuthenticationMethod')->willReturn(ClientAuthenticationMethodsEnum::ClientSecretBasic); + $clientMock = $this->createMock(ClientEntity::class); + $clientMock->method('getIdentifier')->willReturn('client-id'); + $mock->method('getClient')->willReturn($clientMock); + + return $mock; + } + + public function testInvokeReturnsUnauthorizedOnAuthorizationException(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn(null); + + $this->apiAuthorizationMock->expects($this->once()) + ->method('requireTokenForAnyOfScope') + ->willThrowException(new AuthorizationException('Unauthorized client.')); + + $this->loggerServiceMock->expects($this->once()) + ->method('error') + ->with($this->stringContains('AuthorizationException: Unauthorized client.')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('unauthorized', 'Unauthorized client.', 401) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsBadRequestIfMissingToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); // client is authenticated + + $this->requestParamsResolverMock->expects($this->once()) + ->method('getFromRequestBasedOnAllowedMethods') + ->with('token', $requestMock, [HttpMethodsEnum::POST]) + ->willReturn(null); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('invalid_request', 'Missing token parameter.', 400) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsActiveFalseIfTokenInvalid(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-token') + ->willThrowException(new \Exception('bad refresh token')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(['active' => false]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-access-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-access-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintAccessToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope2']); + $jwsMock->method('getAudience')->willReturn(['client2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $this->oAuth2BridgeMock->expects($this->never())->method('decrypt'); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client2'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintRefreshToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->bearerTokenValidatorMock->expects($this->never())->method('ensureValidAccessToken'); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client3', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client3'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedAccessTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope1', 'scope2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + $jwsMock->method('getIssuedAt')->willReturn(500); + $jwsMock->method('getNotBefore')->willReturn(500); + $jwsMock->method('getSubject')->willReturn('sub1'); + $jwsMock->method('getAudience')->willReturn(['client1']); + $jwsMock->method('getIssuer')->willReturn('iss1'); + $jwsMock->method('getJwtId')->willReturn('jti1'); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'token_type' => 'Bearer', + 'exp' => 1000, + 'iat' => 500, + 'nbf' => 500, + 'sub' => 'sub1', + 'aud' => ['client1'], + 'iss' => 'iss1', + 'jti' => 'jti1', + ]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedRefreshTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'jti1', + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + 'user_id' => 'sub1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('jti1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true + && $data['scope'] === 'scope1 scope2' + && $data['client_id'] === 'client1' + && $data['exp'] > time() + && $data['sub'] === 'sub1' + && $data['aud'] === 'client1' + && $data['jti'] === 'jti1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } +} diff --git a/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php new file mode 100644 index 00000000..73ea7dfd --- /dev/null +++ b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php @@ -0,0 +1,52 @@ +clientMock = $this->createMock(ClientEntityInterface::class); + } + + protected function sut( + ?ClientEntityInterface $client = null, + ?ClientAuthenticationMethodsEnum $clientAuthenticationMethod = null, + ): ResolvedClientAuthenticationMethod { + $client ??= $this->clientMock; + $clientAuthenticationMethod ??= ClientAuthenticationMethodsEnum::ClientSecretBasic; + + return new ResolvedClientAuthenticationMethod( + $client, + $clientAuthenticationMethod, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $this->sut()); + } + + public function testCanGetProperties(): void + { + $sut = $this->sut( + $this->clientMock, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + + $this->assertSame($this->clientMock, $sut->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::ClientSecretPost, $sut->getClientAuthenticationMethod()); + } +} From 5198f16e71286e5ed42988a5423d99d68f4b633d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 9 Mar 2026 11:37:44 +0100 Subject: [PATCH 18/20] Remove additional-tools --- .../SSP-OIDC-module.postman_collection.json | 932 ------------------ additional-tools/generate-token.md | 105 -- 2 files changed, 1037 deletions(-) delete mode 100644 additional-tools/SSP-OIDC-module.postman_collection.json delete mode 100644 additional-tools/generate-token.md diff --git a/additional-tools/SSP-OIDC-module.postman_collection.json b/additional-tools/SSP-OIDC-module.postman_collection.json deleted file mode 100644 index 11131f6e..00000000 --- a/additional-tools/SSP-OIDC-module.postman_collection.json +++ /dev/null @@ -1,932 +0,0 @@ -{ - "info": { - "_postman_id": "", - "name": "SSP-OIDC-module", - "schema": "", - "_exporter_id": "", - "_collection_link": "" - }, - "item": [ - { - "name": "test - client auth - Correct secret", - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "password", - "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", - "type": "string" - }, - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - } - ] - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "type": "text", - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - client auth - Correct secret", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "type": "text", - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "OK", - "code": 200, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:13:18 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Keep-Alive", - "value": "timeout=5, max=100" - }, - { - "key": "Connection", - "value": "Keep-Alive" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"active\": false\n}" - } - ] - }, - { - "name": "test - client auth - Incorrect secret", - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - }, - { - "key": "password", - "value": "_6300f8ce29f56909838e8e93d07fe086016adcb9e7", - "type": "string" - } - ] - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", - "type": "text", - "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - client auth - Incorrect secret", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", - "type": "text", - "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "Unauthorized", - "code": 401, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:13:48 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Keep-Alive", - "value": "timeout=5, max=100" - }, - { - "key": "Connection", - "value": "Keep-Alive" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"error\": \"invalid_client\",\n \"error_description\": \"Client authentication failed.\"\n}" - } - ] - }, - { - "name": "test - token introspection", - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - }, - { - "key": "password", - "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", - "type": "string" - } - ] - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "type": "text", - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6InJlc291cmNlLXNlcnZlciIsImp0aSI6InRlc3QtdG9rZW4tMjQyMTg0YjkzMjIxY2NkZWYwNmQ1NjQzNmNhODY2NWUiLCJpYXQiOjE3NzE1MDMwNTEuNjE1OTYxLCJuYmYiOjE3NzE1MDMwNTEuNjE1OTYxLCJleHAiOjE3NzE1MDY2NTEuNjE1OTYxLCJzdWIiOiJ0ZXN0LXVzZXItMTIzIiwic2NvcGVzIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJlbWFpbCJdLCJjbGllbnRfaWQiOiJfOGI2Y2YzNGQ4OWJlOWRkYzBiOTc4NTBiM2NiMmUyYTg5YTk1MjNjOWQwIn0.ZcdGTWoNpF7CFb9RxoQfTHmO--EqFtF-KNvUjzDk1DDYkyoMqyTRe7P3YwK0pUotcY_KZ-gex2_rDIhZstEs0Pkr0mlDP0a2fl4oZGs945RW3g5drdzzUTHAtUzRbL6ugSTxqS-VS5U4UWWngA5l7WsxiAfm7UgTArJzpS9gUL1lGg2w96viYTdfy4H0PIp0Q46vt9r-0CAVSZTRxh-M3fkCQhxai3RK8Ql1dT7mco4loq6Y1iHx_Xr9usTIDzMrnOLCwYJlFX9NsxSBCABs4_l9okxIwOK40IXG8xbEg9uW_6ipYEHGOx9NrKSGqowYcKTqpbb7kZJT8vxY1u2gfg" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - token introspection", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "type": "text", - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6InJlc291cmNlLXNlcnZlciIsImp0aSI6InRlc3QtdG9rZW4tMjQyMTg0YjkzMjIxY2NkZWYwNmQ1NjQzNmNhODY2NWUiLCJpYXQiOjE3NzE1MDMwNTEuNjE1OTYxLCJuYmYiOjE3NzE1MDMwNTEuNjE1OTYxLCJleHAiOjE3NzE1MDY2NTEuNjE1OTYxLCJzdWIiOiJ0ZXN0LXVzZXItMTIzIiwic2NvcGVzIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJlbWFpbCJdLCJjbGllbnRfaWQiOiJfOGI2Y2YzNGQ4OWJlOWRkYzBiOTc4NTBiM2NiMmUyYTg5YTk1MjNjOWQwIn0.ZcdGTWoNpF7CFb9RxoQfTHmO--EqFtF-KNvUjzDk1DDYkyoMqyTRe7P3YwK0pUotcY_KZ-gex2_rDIhZstEs0Pkr0mlDP0a2fl4oZGs945RW3g5drdzzUTHAtUzRbL6ugSTxqS-VS5U4UWWngA5l7WsxiAfm7UgTArJzpS9gUL1lGg2w96viYTdfy4H0PIp0Q46vt9r-0CAVSZTRxh-M3fkCQhxai3RK8Ql1dT7mco4loq6Y1iHx_Xr9usTIDzMrnOLCwYJlFX9NsxSBCABs4_l9okxIwOK40IXG8xbEg9uW_6ipYEHGOx9NrKSGqowYcKTqpbb7kZJT8vxY1u2gfg" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "OK", - "code": 200, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:16:23 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Keep-Alive", - "value": "timeout=5, max=100" - }, - { - "key": "Connection", - "value": "Keep-Alive" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"active\": true,\n \"scope\": \"openid profile email\",\n \"client_id\": \"_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0\",\n \"username\": \"test-user-123\",\n \"token_type\": \"Bearer\",\n \"exp\": 1771506651,\n \"iat\": 1771503051.615961,\n \"nbf\": 1771503051.615961,\n \"sub\": \"test-user-123\",\n \"aud\": \"resource-server\",\n \"iss\": \"https://localhost\",\n \"jti\": \"test-token-242184b93221ccdef06d56436ca8665e\"\n}" - } - ] - }, - { - "name": "test - Invalid request type", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "password", - "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", - "type": "string" - }, - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - } - ] - }, - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", - "type": "text", - "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - Invalid request type", - "originalRequest": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", - "type": "text", - "uuid": "2e523702-fd7e-40ed-bbd6-fac7167932a4" - } - ] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "Method Not Allowed", - "code": 405, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:15:00 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Keep-Alive", - "value": "timeout=5, max=100" - }, - { - "key": "Connection", - "value": "Keep-Alive" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Token introspection endpoint only accepts POST requests.\"\n}" - } - ] - }, - { - "name": "test - Invalid Content-type", - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - }, - { - "key": "password", - "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", - "type": "string" - } - ] - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - Invalid Content-type", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "Bad Request", - "code": 400, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:15:26 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Connection", - "value": "close" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Content-Type must be application/x-www-form-urlencoded.\"\n}" - } - ] - }, - { - "name": "test - no token provided", - "request": { - "auth": { - "type": "basic", - "basic": [ - { - "key": "password", - "value": "_29bffbc1cbad82169add5e806efbb5916ba0eee401", - "type": "string" - }, - { - "key": "username", - "value": "_8b6cf34d89be9ddc0b97850b3cb2e2a89a9523c9d0", - "type": "string" - } - ] - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "response": [ - { - "name": "test - no token provided", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [] - }, - "url": { - "raw": "https://localhost/simplesaml/module.php/oidc/introspect", - "protocol": "https", - "host": [ - "localhost" - ], - "path": [ - "simplesaml", - "module.php", - "oidc", - "introspect" - ] - } - }, - "status": "Bad Request", - "code": 400, - "_postman_previewlanguage": null, - "header": [ - { - "key": "Date", - "value": "Thu, 19 Feb 2026 12:15:39 GMT" - }, - { - "key": "Server", - "value": "Apache/2.4.65 (Debian)" - }, - { - "key": "Vary", - "value": "Authorization" - }, - { - "key": "Set-Cookie", - "value": "SimpleSAML=96tdgt7kbhmtv28u4ki9rsh33k; path=/; secure; HttpOnly; SameSite=None" - }, - { - "key": "Expires", - "value": "Thu, 19 Nov 1981 08:52:00 GMT" - }, - { - "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate" - }, - { - "key": "Cache-Control", - "value": "no-cache, private" - }, - { - "key": "Pragma", - "value": "no-cache" - }, - { - "key": "Content-Security-Policy", - "value": "default-src 'none'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; media-src data:; img-src 'self' data:; base-uri 'none'" - }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "origin-when-cross-origin" - }, - { - "key": "Connection", - "value": "close" - }, - { - "key": "Transfer-Encoding", - "value": "chunked" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"Missing required parameter token.\"\n}" - } - ] - } - ] -} \ No newline at end of file diff --git a/additional-tools/generate-token.md b/additional-tools/generate-token.md deleted file mode 100644 index 95a10f7e..00000000 --- a/additional-tools/generate-token.md +++ /dev/null @@ -1,105 +0,0 @@ -``` -cat > /tmp/test.php << 'EOF' -prepare("INSERT OR IGNORE INTO oidc_user (id, claims) VALUES (?, ?)"); -$stmt->execute(["test-user-123", '{"sub":"test-user-123","name":"Test User"}']); - -// Get first client -$client = $db->query("SELECT id FROM oidc_client LIMIT 1")->fetch(PDO::FETCH_ASSOC); -if (!$client) { - die("Error: No client found in database. Please create a client first.\n"); -} -echo "Using client: " . $client["id"] . "\n"; - -// Generate token ID -$tokenId = "test-token-" . bin2hex(random_bytes(16)); -echo "Token ID: " . $tokenId . "\n"; - -// Calculate expiration time -$now = new DateTimeImmutable(); -$expiresAt = $now->modify('+1 hour'); - -// Insert access token into database -$stmt = $db->prepare("INSERT INTO oidc_access_token (id, scopes, expires_at, user_id, client_id, is_revoked) VALUES (?, ?, ?, ?, ?, 0)"); -$stmt->execute([ - $tokenId, - '["openid","profile","email"]', - $expiresAt->format('Y-m-d H:i:s'), - "test-user-123", - $client["id"] -]); - -echo "✓ Token stored in database\n"; - -// Read the actual key contents -$privateKeyContents = file_get_contents('/var/simplesamlphp/cert/oidc_module.key'); -$publicKeyContents = file_get_contents('/var/simplesamlphp/cert/oidc_module.crt'); - -// Configure JWT -$config = Configuration::forAsymmetricSigner( - new Sha256(), - InMemory::plainText($privateKeyContents), - InMemory::plainText($publicKeyContents) -); - -// Build properly signed JWT with nbf claim -$token = $config->builder() - ->issuedBy('https://localhost') - ->permittedFor('resource-server') - ->identifiedBy($tokenId) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) // ← ADD THIS: Sets the nbf (Not Before) claim - ->expiresAt($expiresAt) - ->relatedTo('test-user-123') - ->withClaim('scopes', ['openid', 'profile', 'email']) - ->withClaim('client_id', $client["id"]) - ->getToken($config->signer(), $config->signingKey()); - -$jwt = $token->toString(); - -// Verify the token can be parsed and validated -try { - $parsedToken = $config->parser()->parse($jwt); - $config->validator()->assert($parsedToken, ...$config->validationConstraints()); - echo "✓ Token signature validated successfully!\n"; -} catch (\Exception $e) { - echo "✗ Validation failed: " . $e->getMessage() . "\n"; -} - -// Output the JWT -echo "\n" . str_repeat("=", 80) . "\n"; -echo "Generated JWT:\n"; -echo str_repeat("=", 80) . "\n"; -echo $jwt . "\n"; -echo str_repeat("=", 80) . "\n"; - -// Provide test command -echo "\nTest with curl:\n"; -echo "curl -X POST \"https://localhost/simplesaml/module.php/oidc/introspect\" \\\n"; -echo " -H \"Content-Type: application/x-www-form-urlencoded\" \\\n"; -echo " -H \"Authorization: Bearer test\" \\\n"; -echo " -d \"token=$jwt\" \\\n"; -echo " -k\n"; - -// Also show decoded token for debugging -echo "\n" . str_repeat("=", 80) . "\n"; -echo "Decoded Token Payload:\n"; -echo str_repeat("=", 80) . "\n"; -$parts = explode('.', $jwt); -$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); -echo json_encode($payload, JSON_PRETTY_PRINT) . "\n"; -EOF - - -php /tmp/test.php -``` \ No newline at end of file From 7147c1f8ca41129d6fafb035f156ab41dc2702f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 9 Mar 2026 11:50:42 +0100 Subject: [PATCH 19/20] Update docs --- docs/1-oidc.md | 1 + docs/{api.md => 8-api.md} | 119 +++++++++++++++++++++++++++++++++++++- docs/index.md | 3 - 3 files changed, 118 insertions(+), 5 deletions(-) rename docs/{api.md => 8-api.md} (60%) delete mode 100644 docs/index.md diff --git a/docs/1-oidc.md b/docs/1-oidc.md index a5891f74..94eb17e7 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -77,3 +77,4 @@ Upgrading? See the [upgrade guide](6-oidc-upgrade.md). - Conformance tests: [OpenID Conformance](5-oidc-conformance.md) - Upgrading between versions: [Upgrade guide](6-oidc-upgrade.md) - Common questions: [FAQ](7-oidc-faq.md) +- API documentation: [API](8-api.md) diff --git a/docs/api.md b/docs/8-api.md similarity index 60% rename from docs/api.md rename to docs/8-api.md index 68b8ee62..7444dc31 100644 --- a/docs/api.md +++ b/docs/8-api.md @@ -30,8 +30,10 @@ ModuleConfig::OPTION_API_TOKENS => [ Scopes determine which endpoints are accessible by the API access token. The following scopes are available: * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All`: Access to all endpoints. -* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer`: Access to credential offer endpoint. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All`: Access to all OAuth2-related endpoints. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection`: Access to the OAuth2 token introspection endpoint. ## API Endpoints @@ -142,4 +144,117 @@ Response: { "credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"urn:ietf:params:oauth:grant-type:pre-authorized_code\":{\"pre-authorized_code\":\"_ffcdf6d86cd564c300346351dce0b4ccb2fde304e2\",\"tx_code\":{\"input_mode\":\"numeric\",\"length\":4,\"description\":\"Please provide the one-time code that was sent to e-mail testuser@example.com\"}}}}" } -``` \ No newline at end of file +``` + +### Token Introspection + +Enables token introspection for OAuth2 access tokens and refresh tokens as per +[RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662). + +#### Path + +`/api/oauth2/token-introspection` + +#### Method + +`POST` + +#### Authorization + +Access is granted if: +* The client is authenticated using one of the supported OAuth2 client +authentication methods (Basic, Post, Private Key JWT, Bearer). +* Or, if the request is authorized using an API Bearer Token with +the appropriate scope. + +#### Request + +The request is sent with `application/x-www-form-urlencoded` encoding with the +following parameters: + +* __token__ (string, mandatory): The string value of the token. +* __token_type_hint__ (string, optional): A hint about the type of the +token submitted for introspection. Allowed values: + * `access_token` + * `refresh_token` + +#### Response + +The response is a JSON object with the following fields: + +* __active__ (boolean, mandatory): Indicator of whether or not the presented +token is currently active. +* __scope__ (string, optional): A JSON string containing a space-separated +list of scopes associated with this token. +* __client_id__ (string, optional): Client identifier for the OAuth 2.0 client +that requested this token. +* __token_type__ (string, optional): Type of the token as defined in OAuth 2.0. +* __exp__ (integer, optional): Expiration time. +* __iat__ (integer, optional): Issued at time. +* __nbf__ (integer, optional): Not before time. +* __sub__ (string, optional): Subject identifier for the user who +authorized the token. +* __aud__ (string/array, optional): Audience for the token. +* __iss__ (string, optional): Issuer of the token. +* __jti__ (string, optional): Identifier for the token. + +If the token is not active, only the `active` field with a value of +`false` is returned. + +#### Sample 1 + +Introspect an active access token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=access-token-string' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile email", + "client_id": "test-client", + "token_type": "Bearer", + "exp": 1712662800, + "iat": 1712659200, + "sub": "user-id", + "aud": "test-client", + "iss": "https://idp.mivanci.incubator.hexaa.eu", + "jti": "token-id" +} +``` + +#### Sample 2 + +Introspect a refresh token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=refresh-token-string' \ +--data-urlencode 'token_type_hint=refresh_token' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile", + "client_id": "test-client", + "exp": 1715251200, + "sub": "user-id", + "aud": "test-client", + "jti": "refresh-token-id" +} +``` diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index bbce5f8e..00000000 --- a/docs/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# SimpleSAMLphp OIDC module - -* [API](api.md) From 52ffe31f38fe15d7f9a77aa32b3d7a9f150f8016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 9 Mar 2026 11:56:13 +0100 Subject: [PATCH 20/20] Update upgrade log --- docs/6-oidc-upgrade.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index a2aabe64..699c27fa 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -24,6 +24,8 @@ keys for protocol (Connect), Federation, and VCI purposes. This was introduced to support signature algorithm negotiation with the clients. - Clients can now be configured with new properties: - ID Token Signing Algorithm (`id_token_signed_response_alg`) +- Optional OAuth2 Token Introspection endpoint, as per RFC7662. Check the API +documentation for more details. - Initial support for OpenID for Verifiable Credential Issuance (OpenID4VCI). Note that the implementation is experimental. You should not use it in production. @@ -43,6 +45,8 @@ key roll-ower scenarios, etc. - `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for setting allowed time tolerance for timestamp validation in artifacts like JWSs. multiple Federation-related signing algorithms and key pairs. +- `ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED` - +optional, enables the OAuth2 token introspection endpoint as per RFC7662. - Several new options regarding experimental support for OpenID4VCI. Major impact changes: