diff --git a/README.md b/README.md index 8754fab5cc2780757d3fc953aa795bd75ae71398..29abaec79374fd7aa22de9435f4fdc2524cc892a 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,25 @@ created via `./bin/console config:dump-reference DbpRelayAuthBundle | sed '/^$/d ```yaml # Default configuration for "DbpRelayAuthBundle" dbp_relay_auth: - # The Keycloak server URL - server_url: ~ # Example: 'https://keycloak.example.com/auth' - # The Keycloak Realm - realm: ~ # Example: myrealm - # The ID for the keycloak client (authorization code flow) used for API docs or similar - frontend_client_id: ~ # Example: client-docs + # The base URL for the OIDC server (in case of Keycloak fort the specific realm) + server_url: ~ # Example: 'https://keycloak.example.com/auth/realms/my-realm' + # If set only tokens which contain this audience are accepted (optional) + required_audience: ~ # Example: my-api + # How much the system time of the API server and the Keycloak server + # can be out of sync (in seconds). Used for local token validation. + local_validation_leeway: 120 # If remote validation should be used. If set to false the token signature will # be only checked locally and not send to the keycloak server remote_validation: false # The ID of the client (client credentials flow) used for remote token validation # (optional) - remote_validation_client_id: ~ # Example: client-token-check + remote_validation_id: ~ # Example: client-token-check # The client secret for the client referenced by client_id (optional) - remote_validation_client_secret: ~ # Example: mysecret - # If set only tokens which contain this audience are accepted (optional) - required_audience: ~ # Example: my-api - # How much the system time of the API server and the Keycloak server - # can be out of sync (in seconds). Used for local token validation. - local_validation_leeway: 120 - + remote_validation_secret: ~ # Example: mysecret + # The Keycloak server base URL + frontend_keycloak_server: ~ # Example: 'https://keycloak.example.com/auth' + # The keycloak realm + frontend_keycloak_realm: ~ # Example: client-docs + # The ID for the keycloak client (authorization code flow) used for API docs or similar + frontend_keycloak_client_id: ~ # Example: client-docs ``` \ No newline at end of file diff --git a/src/Keycloak/KeycloakBearerAuthenticator.php b/src/Authenticator/BearerAuthenticator.php similarity index 80% rename from src/Keycloak/KeycloakBearerAuthenticator.php rename to src/Authenticator/BearerAuthenticator.php index 141e6579ff794f039e62f703560e81a967ac8468..138606a108238edde62e5b799ab0a0deea049117 100644 --- a/src/Keycloak/KeycloakBearerAuthenticator.php +++ b/src/Authenticator/BearerAuthenticator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; @@ -17,13 +17,13 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -class KeycloakBearerAuthenticator extends AbstractAuthenticator implements LoggerAwareInterface +class BearerAuthenticator extends AbstractAuthenticator implements LoggerAwareInterface { use LoggerAwareTrait; private $userProvider; - public function __construct(KeycloakBearerUserProviderInterface $userProvider) + public function __construct(BearerUserProviderInterface $userProvider) { $this->userProvider = $userProvider; } @@ -52,8 +52,10 @@ class KeycloakBearerAuthenticator extends AbstractAuthenticator implements Logge $token = trim(preg_replace('/^(?:\s+)?Bearer\s/', '', $auth)); - return new SelfValidatingPassport(new UserBadge($token, function ($token) { - return $this->userProvider->loadUserByToken($token); + $user = $this->userProvider->loadUserByToken($token); + + return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), function ($token) use ($user) { + return $user; })); } } diff --git a/src/Keycloak/KeycloakBearerUser.php b/src/Authenticator/BearerUser.php similarity index 90% rename from src/Keycloak/KeycloakBearerUser.php rename to src/Authenticator/BearerUser.php index 846324ca5650c5308a226b33823f8316f1be3467..b22a03665fa92b91ca7ccc8b7546322c402c72ea 100644 --- a/src/Keycloak/KeycloakBearerUser.php +++ b/src/Authenticator/BearerUser.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; use Symfony\Component\Security\Core\User\UserInterface; -class KeycloakBearerUser implements UserInterface +class BearerUser implements UserInterface { /** * @var string[] diff --git a/src/Keycloak/KeycloakBearerUserProvider.php b/src/Authenticator/BearerUserProvider.php similarity index 59% rename from src/Keycloak/KeycloakBearerUserProvider.php rename to src/Authenticator/BearerUserProvider.php index c8b2fcdaaf37bb982fcd0bea78ee78b7a36a3c57..e2b85cac2bded8d130174f731ce2c2f90b3a6a91 100644 --- a/src/Keycloak/KeycloakBearerUserProvider.php +++ b/src/Authenticator/BearerUserProvider.php @@ -2,28 +2,28 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; use Dbp\Relay\CoreBundle\API\UserSessionInterface; -use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; -use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\User\UserInterface; -class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, LoggerAwareInterface +class BearerUserProvider implements BearerUserProviderInterface, LoggerAwareInterface { use LoggerAwareTrait; private $config; - private $certCachePool; - private $personCachePool; private $userSession; + private $oidProvider; - public function __construct(UserSessionInterface $userSession) + public function __construct(UserSessionInterface $userSession, OIDProvider $oidProvider) { $this->userSession = $userSession; $this->config = []; + $this->oidProvider = $oidProvider; } public function setConfig(array $config) @@ -31,37 +31,30 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, $this->config = $config; } - public function setCertCache(?CacheItemPoolInterface $cachePool) - { - $this->certCachePool = $cachePool; - } - public function loadUserByToken(string $accessToken): UserInterface { $config = $this->config; - $keycloak = new Keycloak( - $config['server_url'], $config['realm'], - $config['remote_validation_client_id'], $config['remote_validation_client_secret']); - if (!$config['remote_validation']) { $leeway = $config['local_validation_leeway']; - $validator = new KeycloakLocalTokenValidator($keycloak, $this->certCachePool, $leeway); + $validator = new LocalTokenValidator($this->oidProvider, $leeway); } else { - $validator = new KeycloakRemoteTokenValidator($keycloak); + $validator = new RemoteTokenValidator($this->oidProvider); + } + if ($this->logger !== null) { + $validator->setLogger($this->logger); } - $validator->setLogger($this->logger); try { $jwt = $validator->validate($accessToken); } catch (TokenValidationException $e) { - throw new AccessDeniedException('Invalid token'); + throw new AuthenticationException('Invalid token'); } if (($config['required_audience'] ?? '') !== '') { try { $validator::checkAudience($jwt, $config['required_audience']); } catch (TokenValidationException $e) { - throw new AccessDeniedException('Invalid token audience'); + throw new AuthenticationException('Invalid token audience'); } } @@ -75,7 +68,7 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, $identifier = $session->getUserIdentifier(); $userRoles = $session->getUserRoles(); - return new KeycloakBearerUser( + return new BearerUser( $identifier, $userRoles ); diff --git a/src/Keycloak/KeycloakBearerUserProviderInterface.php b/src/Authenticator/BearerUserProviderInterface.php similarity index 73% rename from src/Keycloak/KeycloakBearerUserProviderInterface.php rename to src/Authenticator/BearerUserProviderInterface.php index b0bbbd25c9fb9ead0b6fd20ff11bcab87c1d0c1e..80108f244e0464cb1592c62b7f60f350c8c7e8a9 100644 --- a/src/Keycloak/KeycloakBearerUserProviderInterface.php +++ b/src/Authenticator/BearerUserProviderInterface.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; use Symfony\Component\Security\Core\User\UserInterface; -interface KeycloakBearerUserProviderInterface +interface BearerUserProviderInterface { public function loadUserByToken(string $accessToken): UserInterface; diff --git a/src/Authenticator/LocalTokenValidator.php b/src/Authenticator/LocalTokenValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..d6625b52e422fb33e0aea97726540c6ac83c3e84 --- /dev/null +++ b/src/Authenticator/LocalTokenValidator.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\Authenticator; + +use Dbp\Relay\AuthBundle\OIDC\OIDError; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; +use Jose\Component\Core\JWKSet; +use Jose\Easy\Load; +use Jose\Easy\Validate; + +class LocalTokenValidator extends TokenValidatorBase +{ + private $oidProvider; + private $leewaySeconds; + + public function __construct(OIDProvider $oidProvider, int $leewaySeconds) + { + $this->oidProvider = $oidProvider; + $this->leewaySeconds = $leewaySeconds; + } + + /** + * Validates the token locally using the public JWK of the OIDC server. + * + * This is faster because everything can be cached, but tokens/sessions revoked on the OIDC server + * will still be considered valid as long as they are not expired. + * + * @return array the token + * + * @throws TokenValidationException + */ + public function validate(string $accessToken): array + { + try { + $jwks = $this->oidProvider->getJWKs(); + $providerConfig = $this->oidProvider->getProviderConfig(); + } catch (OIDError $e) { + throw new TokenValidationException($e->getMessage()); + } + + $issuer = $providerConfig->getIssuer(); + // Allow the same algorithms that the introspection endpoint allows + $algs = $providerConfig->getIntrospectionEndpointSigningAlgorithms(); + // The spec doesn't allow this, but just to be sure + assert(!in_array('none', $algs, true)); + + // Checks not needed/used here: + // * sub(): This is the keycloak user ID by default, nothing we know beforehand + // * jti(): Nothing we know beforehand + // * aud(): The audience needs to be checked afterwards with checkAudience() + try { + $keySet = JWKSet::createFromKeyData($jwks); + $validate = Load::jws($accessToken); + $validate = $validate + ->algs($algs) + ->keyset($keySet) + ->exp($this->leewaySeconds) + ->iat($this->leewaySeconds) + ->nbf($this->leewaySeconds) + ->iss($issuer); + assert($validate instanceof Validate); + $jwtResult = $validate->run(); + } catch (\Exception $e) { + throw new TokenValidationException('Token validation failed: '.$e->getMessage()); + } + + $jwt = $jwtResult->claims->all(); + + // XXX: Keycloak will add extra data to the token returned by introspection, mirror this behaviour here + // to avoid breakage when switching between local/remote validation. + // https://github.com/keycloak/keycloak/blob/8225157a1cecef30034530aa/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java#L59 + if (isset($jwt['preferred_username'])) { + $jwt['username'] = $jwt['preferred_username']; + } + if (!isset($jwt['username'])) { + $jwt['username'] = null; + } + if (isset($jwt['azp'])) { + $jwt['client_id'] = $jwt['azp']; + } + $jwt['active'] = true; + + return $jwt; + } +} diff --git a/src/Authenticator/RemoteTokenValidator.php b/src/Authenticator/RemoteTokenValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..2646bace2a35717088159a36d272c6a3c810b40e --- /dev/null +++ b/src/Authenticator/RemoteTokenValidator.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\Authenticator; + +use Dbp\Relay\AuthBundle\OIDC\OIDError; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; + +class RemoteTokenValidator extends TokenValidatorBase +{ + private $oidProvider; + + public function __construct(OIDProvider $oidProvider) + { + $this->oidProvider = $oidProvider; + } + + /** + * Validates the token with the Keycloak introspection endpoint. + * + * @return array the token + * + * @throws TokenValidationException + */ + public function validate(string $accessToken): array + { + try { + $jwt = $this->oidProvider->introspectToken($accessToken); + } catch (OIDError $e) { + throw new TokenValidationException('Introspection failed: '.$e->getMessage()); + } + + if (!$jwt['active']) { + throw new TokenValidationException('The token does not exist or is not valid anymore'); + } + + return $jwt; + } +} diff --git a/src/Keycloak/TokenValidationException.php b/src/Authenticator/TokenValidationException.php similarity index 65% rename from src/Keycloak/TokenValidationException.php rename to src/Authenticator/TokenValidationException.php index 4f2530f45223cd25713e1977969959fb29a23576..fe3311af7af6ffd3237ad7d6aadf76976a3a82b1 100644 --- a/src/Keycloak/TokenValidationException.php +++ b/src/Authenticator/TokenValidationException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; class TokenValidationException extends \Exception { diff --git a/src/Keycloak/KeycloakTokenValidatorBase.php b/src/Authenticator/TokenValidatorBase.php similarity index 91% rename from src/Keycloak/KeycloakTokenValidatorBase.php rename to src/Authenticator/TokenValidatorBase.php index 9b7153ef2f1db476e3802928598904ba1c33c966..89609e50d0b2cda7c3ff06cdcd64b6a91f2e4474 100644 --- a/src/Keycloak/KeycloakTokenValidatorBase.php +++ b/src/Authenticator/TokenValidatorBase.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Keycloak; +namespace Dbp\Relay\AuthBundle\Authenticator; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; -abstract class KeycloakTokenValidatorBase implements LoggerAwareInterface +abstract class TokenValidatorBase implements LoggerAwareInterface { use LoggerAwareTrait; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index ba387bbb7e57d9000dc7dec5fb0b70bffc56f625..1ddc1f651e98b6a27cd269c1dbd8ed5122b9c960 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,42 +14,51 @@ class Configuration implements ConfigurationInterface $treeBuilder = new TreeBuilder('dbp_relay_auth'); $treeBuilder->getRootNode() ->children() + // Note: "<server_url>/.well-known/openid-configuration" has to exist ->scalarNode('server_url') - ->info('The Keycloak server URL') - ->example('https://keycloak.example.com/auth') + ->info('The base URL for the OIDC server (in case of Keycloak fort the specific realm)') + ->example('https://keycloak.example.com/auth/realms/my-realm') ->end() - ->scalarNode('realm') - ->info('The Keycloak Realm') - ->example('myrealm') + + // Settings for token validation + ->scalarNode('required_audience') + ->info('If set only tokens which contain this audience are accepted (optional)') + ->example('my-api') ->end() - // API docs - ->scalarNode('frontend_client_id') - ->info('The ID for the keycloak client (authorization code flow) used for API docs or similar') - ->example('client-docs') + ->integerNode('local_validation_leeway') + ->defaultValue(120) + ->min(0) + ->info("How much the system time of the API server and the Keycloak server\ncan be out of sync (in seconds). Used for local token validation.") ->end() + // Remote validation ->booleanNode('remote_validation') ->info("If remote validation should be used. If set to false the token signature will\nbe only checked locally and not send to the keycloak server") ->example(false) ->defaultFalse() ->end() - ->scalarNode('remote_validation_client_id') + ->scalarNode('remote_validation_id') ->info("The ID of the client (client credentials flow) used for remote token validation\n(optional)") ->example('client-token-check') ->end() - ->scalarNode('remote_validation_client_secret') + ->scalarNode('remote_validation_secret') ->info('The client secret for the client referenced by client_id (optional)') ->example('mysecret') ->end() - // Settings for token validation - ->scalarNode('required_audience') - ->info('If set only tokens which contain this audience are accepted (optional)') - ->example('my-api') + + // API docs. This is still Keycloak specific because we only have a keycloak + // web component right now. + ->scalarNode('frontend_keycloak_server') + ->info('The Keycloak server base URL') + ->example('https://keycloak.example.com/auth') ->end() - ->integerNode('local_validation_leeway') - ->defaultValue(120) - ->min(0) - ->info("How much the system time of the API server and the Keycloak server\ncan be out of sync (in seconds). Used for local token validation.") + ->scalarNode('frontend_keycloak_realm') + ->info('The keycloak realm') + ->example('client-docs') + ->end() + ->scalarNode('frontend_keycloak_client_id') + ->info('The ID for the keycloak client (authorization code flow) used for API docs or similar') + ->example('client-docs') ->end() ->end(); diff --git a/src/DependencyInjection/DbpRelayAuthExtension.php b/src/DependencyInjection/DbpRelayAuthExtension.php index a50116715c413d74c29dfe1708fdf60e1ae917bf..0b6e0587e9b3e0f69f303a7d17e0f6f3fd65864b 100644 --- a/src/DependencyInjection/DbpRelayAuthExtension.php +++ b/src/DependencyInjection/DbpRelayAuthExtension.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Dbp\Relay\AuthBundle\DependencyInjection; +use Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -21,22 +23,25 @@ class DbpRelayAuthExtension extends ConfigurableExtension implements PrependExte ); $loader->load('services.yaml'); - $certCacheDef = $container->register('dbp_api.cache.keycloak.keycloak_cert', FilesystemAdapter::class); - $certCacheDef->setArguments(['core-keycloak-cert', 60, '%kernel.cache_dir%/dbp/keycloak-keycloak-cert']); - $certCacheDef->addTag('cache.pool'); + $cacheDef = $container->register('dbp_api.cache.auth.oid_provider', FilesystemAdapter::class); + $cacheDef->setArguments(['core-keycloak-cert', 60, '%kernel.cache_dir%/dbp/auth-oid-provider']); + $cacheDef->addTag('cache.pool'); - $definition = $container->getDefinition('Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider'); + $definition = $container->getDefinition(BearerUserProvider::class); $definition->addMethodCall('setConfig', [$mergedConfig]); - $definition->addMethodCall('setCertCache', [$certCacheDef]); + + $definition = $container->getDefinition(OIDProvider::class); + $definition->addMethodCall('setConfig', [$mergedConfig]); + $definition->addMethodCall('setCache', [$cacheDef]); } public function prepend(ContainerBuilder $container) { $config = $container->getExtensionConfig($this->getAlias())[0]; $this->extendArrayParameter($container, 'dbp_api.twig_globals', [ - 'keycloak_server_url' => $config['server_url'] ?? '', - 'keycloak_realm' => $config['realm'] ?? '', - 'keycloak_frontend_client_id' => $config['frontend_client_id'] ?? '', + 'keycloak_server_url' => $config['frontend_keycloak_server'] ?? '', + 'keycloak_realm' => $config['frontend_keycloak_realm'] ?? '', + 'keycloak_frontend_client_id' => $config['frontend_keycloak_client_id'] ?? '', ]); } diff --git a/src/Keycloak/Keycloak.php b/src/Keycloak/Keycloak.php deleted file mode 100644 index 1d6762b3e39fffd727c5a0f16b872cca929ebe4c..0000000000000000000000000000000000000000 --- a/src/Keycloak/Keycloak.php +++ /dev/null @@ -1,66 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Keycloak; - -class Keycloak -{ - /** - * @var string - */ - private $authServerUrl = null; - - /** - * @var string - */ - private $realm = null; - - /** - * @var string - */ - private $clientId = null; - - /** - * @var string - */ - private $clientSecret = null; - - public function __construct(string $serverUrl, string $realm, string $cliendId = null, string $clientSecret = null) - { - $this->authServerUrl = $serverUrl; - $this->realm = $realm; - $this->clientId = $cliendId; - $this->clientSecret = $clientSecret; - } - - public function getClientId() - { - return $this->clientId; - } - - public function getClientSecret() - { - return $this->clientSecret; - } - - public function getBaseUrlWithRealm() - { - return sprintf('%s/realms/%s', $this->authServerUrl, $this->realm); - } - - public function getBaseAuthorizationUrl(): string - { - return sprintf('%s/protocol/openid-connect/auth', $this->getBaseUrlWithRealm()); - } - - public function getBaseAccessTokenUrl(array $params): string - { - return sprintf('%s/protocol/openid-connect/token', $this->getBaseUrlWithRealm()); - } - - public function getTokenIntrospectionUrl(): string - { - return sprintf('%s/protocol/openid-connect/token/introspect', $this->getBaseUrlWithRealm()); - } -} diff --git a/src/Keycloak/KeycloakLocalTokenValidator.php b/src/Keycloak/KeycloakLocalTokenValidator.php deleted file mode 100644 index 1d3e535d0f022ad493e171c640f989a552941c19..0000000000000000000000000000000000000000 --- a/src/Keycloak/KeycloakLocalTokenValidator.php +++ /dev/null @@ -1,146 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Keycloak; - -use Dbp\Relay\AuthBundle\Helpers\Tools; -use GuzzleHttp\Client; -use GuzzleHttp\HandlerStack; -use Jose\Component\Core\JWKSet; -use Jose\Easy\Load; -use Jose\Easy\Validate; -use Kevinrob\GuzzleCache\CacheMiddleware; -use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage; -use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy; -use Psr\Cache\CacheItemPoolInterface; - -class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase -{ - private $keycloak; - private $cachePool; - private $clientHandler; - private $leewaySeconds; - - /* The duration the public keycloak cert is cached */ - private const CERT_CACHE_TTL_SECONDS = 3600; - - public function __construct(Keycloak $keycloak, ?CacheItemPoolInterface $cachePool, int $leewaySeconds) - { - $this->keycloak = $keycloak; - $this->cachePool = $cachePool; - $this->leewaySeconds = $leewaySeconds; - $this->clientHandler = null; - } - - /** - * Replace the guzzle client handler for testing. - * - * @param object $handler - */ - public function setClientHandler(?object $handler) - { - $this->clientHandler = $handler; - } - - /** - * Fetches the JWKs from the keycloak server and caches them. - * - * @throws TokenValidationException - */ - private function fetchJWKs(): array - { - $provider = $this->keycloak; - $certsUrl = sprintf('%s/protocol/openid-connect/certs', $provider->getBaseUrlWithRealm()); - - $stack = HandlerStack::create($this->clientHandler); - if ($this->logger !== null) { - $stack->push(Tools::createLoggerMiddleware($this->logger)); - } - $options = [ - 'handler' => $stack, - 'headers' => [ - 'Accept' => 'application/json', - ], - ]; - $client = new Client($options); - - if ($this->cachePool !== null) { - $cacheMiddleWare = new CacheMiddleware( - new GreedyCacheStrategy( - new Psr6CacheStorage($this->cachePool), - self::CERT_CACHE_TTL_SECONDS - ) - ); - $stack->push($cacheMiddleWare); - } - - try { - $response = $client->request('GET', $certsUrl); - } catch (\Exception $e) { - throw new TokenValidationException('Cert fetching failed: '.$e->getMessage()); - } - - try { - $jwks = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new TokenValidationException('Cert fetching, invalid json: '.$e->getMessage()); - } - - return $jwks; - } - - /** - * Validates the token locally using the public JWK of the keycloak server. - * - * This is faster because everything can be cached, but tokens/sessions revoked on the keycloak server - * will still be considered valid as long as they are not expired. - * - * @return array the token - * - * @throws TokenValidationException - */ - public function validate(string $accessToken): array - { - $jwks = $this->fetchJWKs(); - $issuer = $this->keycloak->getBaseUrlWithRealm(); - - // Checks not needed/used here: - // * sub(): This is the keycloak user ID by default, nothing we know beforehand - // * jti(): Nothing we know beforehand - // * aud(): The audience needs to be checked afterwards with checkAudience() - try { - $keySet = JWKSet::createFromKeyData($jwks); - $validate = Load::jws($accessToken); - $validate = $validate - ->algs(['RS256', 'RS512']) - ->keyset($keySet) - ->exp($this->leewaySeconds) - ->iat($this->leewaySeconds) - ->nbf($this->leewaySeconds) - ->iss($issuer); - assert($validate instanceof Validate); - $jwtResult = $validate->run(); - } catch (\Exception $e) { - throw new TokenValidationException('Token validation failed: '.$e->getMessage()); - } - - $jwt = $jwtResult->claims->all(); - - // XXX: Keycloak will add extra data to the token returned by introspection, mirror this behaviour here - // to avoid breakage when switching between local/remote validation. - // https://github.com/keycloak/keycloak/blob/8225157a1cecef30034530aa/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java#L59 - if (isset($jwt['preferred_username'])) { - $jwt['username'] = $jwt['preferred_username']; - } - if (!isset($jwt['username'])) { - $jwt['username'] = null; - } - if (isset($jwt['azp'])) { - $jwt['client_id'] = $jwt['azp']; - } - $jwt['active'] = true; - - return $jwt; - } -} diff --git a/src/Keycloak/KeycloakRemoteTokenValidator.php b/src/Keycloak/KeycloakRemoteTokenValidator.php deleted file mode 100644 index c416673742b560a6e9a3d5db3dc58aba24414c93..0000000000000000000000000000000000000000 --- a/src/Keycloak/KeycloakRemoteTokenValidator.php +++ /dev/null @@ -1,87 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Keycloak; - -use Dbp\Relay\AuthBundle\Helpers\Tools; -use GuzzleHttp\Client; -use GuzzleHttp\HandlerStack; - -class KeycloakRemoteTokenValidator extends KeycloakTokenValidatorBase -{ - private $keycloak; - private $clientHandler; - - public function __construct(Keycloak $keycloak) - { - $this->keycloak = $keycloak; - $this->clientHandler = null; - } - - /** - * Replace the guzzle client handler for testing. - * - * @param object $handler - */ - public function setClientHandler(?object $handler) - { - $this->clientHandler = $handler; - } - - /** - * Validates the token with the Keycloak introspection endpoint. - * - * @return array the token - * - * @throws TokenValidationException - */ - public function validate(string $accessToken): array - { - $stack = HandlerStack::create($this->clientHandler); - $options = [ - 'handler' => $stack, - 'headers' => [ - 'Accept' => 'application/json', - ], - ]; - - $client = new Client($options); - if ($this->logger !== null) { - $stack->push(Tools::createLoggerMiddleware($this->logger)); - } - - $provider = $this->keycloak; - $client_secret = $provider->getClientSecret(); - $client_id = $provider->getClientId(); - - if (!$client_secret || !$client_id) { - throw new TokenValidationException('Keycloak client ID or secret not set!'); - } - - try { - // keep in mind that even if we are doing this request with a different client id the data returned will be - // from the client id of token $accessToken (that's important for mapped attributes) - $response = $client->request('POST', $provider->getTokenIntrospectionUrl(), [ - 'auth' => [$client_id, $client_secret], - 'form_params' => [ - 'token' => $accessToken, - ], - ]); - } catch (\Exception $e) { - throw new TokenValidationException('Keycloak introspection failed'); - } - - try { - $jwt = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new TokenValidationException('Cert fetching, invalid json: '.$e->getMessage()); - } - - if (!$jwt['active']) { - throw new TokenValidationException('The token does not exist or is not valid anymore'); - } - - return $jwt; - } -} diff --git a/src/OIDC/OIDError.php b/src/OIDC/OIDError.php new file mode 100644 index 0000000000000000000000000000000000000000..59e60995813e352bdbed4ae6e7b31346053f6f13 --- /dev/null +++ b/src/OIDC/OIDError.php @@ -0,0 +1,9 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\OIDC; + +class OIDError extends \Exception +{ +} diff --git a/src/OIDC/OIDProvider.php b/src/OIDC/OIDProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..052b1a0282d6c31001b63891fb14c96bc97bb355 --- /dev/null +++ b/src/OIDC/OIDProvider.php @@ -0,0 +1,174 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\OIDC; + +use Dbp\Relay\AuthBundle\Helpers\Tools; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\HandlerStack; +use Kevinrob\GuzzleCache\CacheMiddleware; +use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage; +use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; + +class OIDProvider implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + private $config; + private $cachePool; + private $clientHandler; + private $serverConfig; + + /* The duration the public keycloak config/cert is cached */ + private const CACHE_TTL_SECONDS = 3600; + + public function __construct() + { + $this->config = []; + } + + public function setConfig(array $config) + { + $this->config = $config; + } + + public function setCache(?CacheItemPoolInterface $cachePool) + { + $this->cachePool = $cachePool; + } + + /** + * Replace the guzzle client handler for testing. + * + * @param object $handler + */ + public function setClientHandler(?object $handler) + { + $this->clientHandler = $handler; + } + + private function getClient(): Client + { + $stack = HandlerStack::create($this->clientHandler); + if ($this->logger !== null) { + $stack->push(Tools::createLoggerMiddleware($this->logger)); + } + $options = [ + 'handler' => $stack, + 'headers' => [ + 'Accept' => 'application/json', + ], + ]; + + $client = new Client($options); + + if ($this->cachePool !== null) { + $cacheMiddleWare = new CacheMiddleware( + new GreedyCacheStrategy( + new Psr6CacheStorage($this->cachePool), + self::CACHE_TTL_SECONDS + ) + ); + $stack->push($cacheMiddleWare); + } + + return $client; + } + + /** + * @throws OIDError + */ + public function getProviderConfig(): OIDProviderConfig + { + if (!$this->serverConfig) { + $serverUrl = $this->config['server_url'] ?? ''; + $configUrl = $serverUrl.'/.well-known/openid-configuration'; + $client = $this->getClient(); + try { + $response = $client->request('GET', $configUrl); + } catch (GuzzleException $e) { + throw new OIDError('Config fetching failed: '.$e->getMessage()); + } + $data = (string) $response->getBody(); + $this->serverConfig = OIDProviderConfig::fromString($data); + } + + return $this->serverConfig; + } + + /** + * Fetches the JWKs from the OID server. + * + * @throws OIDError + */ + public function getJWKs(): array + { + $providerConfig = $this->getProviderConfig(); + $certsUrl = $providerConfig->getJwksUri(); + $client = $this->getClient(); + + try { + $response = $client->request('GET', $certsUrl); + } catch (GuzzleException $e) { + throw new OIDError('Cert fetching failed: '.$e->getMessage()); + } + + try { + $jwks = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new OIDError('Cert fetching, invalid json: '.$e->getMessage()); + } + + return $jwks; + } + + /** + * Introspect the token via the provider. Note that you have to check the result to see if the + * token is valid/active. + * + * @throws OIDError + */ + public function introspectToken(string $token): array + { + $providerConfig = $this->getProviderConfig(); + $introspectEndpoint = $providerConfig->getIntrospectionEndpoint(); + if ($introspectEndpoint === null) { + throw new OIDError('No introspection endpoint'); + } + + $authId = $this->config['remote_validation_id'] ?? ''; + $authSecret = $this->config['remote_validation_secret'] ?? ''; + if ($authId === '' || $authSecret === '') { + throw new OIDError('remote_validation_id/secret not set'); + } + + $client = $this->getClient(); + + try { + // keep in mind that even if we are doing this request with a different client id the data returned will be + // from the client id of token $token (that's important for mapped attributes) + $response = $client->request('POST', $introspectEndpoint, [ + 'auth' => [$authId, $authSecret], + 'form_params' => [ + 'token' => $token, + ], + ]); + } catch (GuzzleException $e) { + throw new OIDError('Token introspection failed'); + } + + $data = (string) $response->getBody(); + try { + $jwt = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new OIDError('Token introspection failed, invalid json: '.$e->getMessage()); + } + + return $jwt; + } +} diff --git a/src/OIDC/OIDProviderConfig.php b/src/OIDC/OIDProviderConfig.php new file mode 100644 index 0000000000000000000000000000000000000000..8f1602fd541d1c843fb5c62e8fd92f2d14755e43 --- /dev/null +++ b/src/OIDC/OIDProviderConfig.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\OIDC; + +/** + * discover: https://openid.net/specs/openid-connect-discovery-1_0.html + * introspection: https://datatracker.ietf.org/doc/html/rfc8414. + */ +class OIDProviderConfig +{ + private $config; + + public function __construct(array $config) + { + $this->config = $config; + } + + /** + * @throws OIDError + */ + public static function fromString(string $data): OIDProviderConfig + { + try { + $config = json_decode( + $data, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new OIDError('Invalid config: '.$e->getMessage()); + } + + return new OIDProviderConfig($config); + } + + public function getIssuer(): string + { + return $this->config['issuer']; + } + + public function getJwksUri(): string + { + return $this->config['jwks_uri']; + } + + public function getIntrospectionEndpoint(): ?string + { + return $this->config['introspection_endpoint'] ?? null; + } + + public function getIntrospectionEndpointSigningAlgorithms(): array + { + return $this->config['introspection_endpoint_auth_signing_alg_values_supported'] ?? []; + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 0b40a6594c32bc34d2e41a9d8c1b154fc3e5bc0f..953727798a578238a846c03fe305405dbaa3c48f 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -1,21 +1,25 @@ services: - Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerAuthenticator: + Dbp\Relay\AuthBundle\Authenticator\BearerAuthenticator: autowire: true autoconfigure: true - Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider: + Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider: autowire: true autoconfigure: true - Dbp\Relay\AuthBundle\Service\KeycloakUserSession: + Dbp\Relay\AuthBundle\Service\OIDCUserSession: autowire: true autoconfigure: true - Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProviderInterface: - '@Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider' + Dbp\Relay\AuthBundle\OIDC\OIDProvider: + autowire: true + autoconfigure: true - Dbp\Relay\CoreBundle\API\UserSessionInterface: - '@Dbp\Relay\AuthBundle\Service\KeycloakUserSession' + Dbp\Relay\AuthBundle\Authenticator\BearerUserProviderInterface: + '@Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider' Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface: - '@Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerAuthenticator' + '@Dbp\Relay\AuthBundle\Authenticator\BearerAuthenticator' + + Dbp\Relay\CoreBundle\API\UserSessionInterface: + '@Dbp\Relay\AuthBundle\Service\OIDCUserSession' diff --git a/src/Service/KeycloakUserSession.php b/src/Service/OIDCUserSession.php similarity index 98% rename from src/Service/KeycloakUserSession.php rename to src/Service/OIDCUserSession.php index 2a15ef7d99a964d080c947c48a78d85a63753a27..e47fe163b4e20f90e597c78cc06e5e86f912cdd8 100644 --- a/src/Service/KeycloakUserSession.php +++ b/src/Service/OIDCUserSession.php @@ -7,7 +7,7 @@ namespace Dbp\Relay\AuthBundle\Service; use Dbp\Relay\CoreBundle\API\UserSessionInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -class KeycloakUserSession implements UserSessionInterface +class OIDCUserSession implements UserSessionInterface { /** * @var ?array diff --git a/tests/Authenticator/BearerAuthenticatorTest.php b/tests/Authenticator/BearerAuthenticatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f54a3c3e5bbe4f4b99b8778af7f67793de13d1d5 --- /dev/null +++ b/tests/Authenticator/BearerAuthenticatorTest.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\Tests\Authenticator; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; +use Dbp\Relay\AuthBundle\Authenticator\BearerAuthenticator; +use Dbp\Relay\AuthBundle\Authenticator\BearerUser; +use Dbp\Relay\AuthBundle\Tests\DummyUserProvider; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\NullToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class BearerAuthenticatorTest extends ApiTestCase +{ + public function testAuthenticateNoHeader() + { + $user = new BearerUser('foo', ['role']); + $provider = new DummyUserProvider($user, 'nope'); + $auth = new BearerAuthenticator($provider); + + $req = new Request(); + $this->expectException(BadCredentialsException::class); + $auth->authenticate($req); + } + + public function testAuthenticate() + { + $user = new BearerUser('foo', ['role']); + $provider = new DummyUserProvider($user, 'nope'); + $auth = new BearerAuthenticator($provider); + + $req = new Request(); + $req->headers->set('Authorization', 'Bearer nope'); + $passport = $auth->authenticate($req); + $badge = $passport->getBadge(UserBadge::class); + assert($badge instanceof UserBadge); + $this->assertSame('foo', $badge->getUser()->getUserIdentifier()); + } + + public function testSupports() + { + $user = new BearerUser('foo', ['role']); + $provider = new DummyUserProvider($user, 'bar'); + $auth = new BearerAuthenticator($provider); + + $this->assertFalse($auth->supports(new Request())); + + $r = new Request(); + $r->headers->set('Authorization', 'foobar'); + $this->assertTrue($auth->supports($r)); + } + + public function testOnAuthenticationSuccess() + { + $user = new BearerUser('foo', ['role']); + $provider = new DummyUserProvider($user, 'bar'); + $auth = new BearerAuthenticator($provider); + $response = $auth->onAuthenticationSuccess(new Request(), new NullToken(), 'firewall'); + $this->assertNull($response); + } + + public function testOnAuthenticationFailure() + { + $user = new BearerUser('foo', ['role']); + $provider = new DummyUserProvider($user, 'bar'); + $auth = new BearerAuthenticator($provider); + $response = $auth->onAuthenticationFailure(new Request(), new AuthenticationException()); + $this->assertSame(403, $response->getStatusCode()); + $this->assertNotNull(json_decode($response->getContent())); + } +} diff --git a/tests/Authenticator/BearerUserProviderTest.php b/tests/Authenticator/BearerUserProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f57e1de8bf1cf54bc22ec8bd81df73d21d6eefc0 --- /dev/null +++ b/tests/Authenticator/BearerUserProviderTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\Tests\Authenticator; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; +use Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; +use Dbp\Relay\AuthBundle\Tests\DummyUserSession; +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +class BearerUserProviderTest extends ApiTestCase +{ + public function testWithIdentifier() + { + $oid = new OIDProvider(); + $udprov = new DummyUserSession('foo', ['role']); + $prov = new BearerUserProvider($udprov, $oid); + $user = $prov->loadUserByValidatedToken([]); + $this->assertSame('foo', $user->getUserIdentifier()); + $this->assertSame(['role'], $user->getRoles()); + } + + public function testWithoutIdentifier() + { + $oid = new OIDProvider(); + $udprov = new DummyUserSession(null, ['role']); + $prov = new BearerUserProvider($udprov, $oid); + $user = $prov->loadUserByValidatedToken([]); + $this->assertSame('', $user->getUserIdentifier()); + $this->assertSame(['role'], $user->getRoles()); + } + + public function testInvalidTokenLocal() + { + $oid = new OIDProvider(); + $udprov = new DummyUserSession('foo', ['role']); + $prov = new BearerUserProvider($udprov, $oid); + $prov->setConfig([ + 'remote_validation' => false, + 'local_validation_leeway' => 0, + ]); + $this->expectException(AuthenticationException::class); + $prov->loadUserByToken('mytoken'); + } +} diff --git a/tests/Keycloak/KeycloakBearerUserTest.php b/tests/Authenticator/BearerUserTest.php similarity index 59% rename from tests/Keycloak/KeycloakBearerUserTest.php rename to tests/Authenticator/BearerUserTest.php index 3719941e35a0afdad2ce4f0a7cc61a07005aee80..55b3e2fd1ce7dd9e6d109cd8eca02abc8841c3b8 100644 --- a/tests/Keycloak/KeycloakBearerUserTest.php +++ b/tests/Authenticator/BearerUserTest.php @@ -2,25 +2,25 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; +namespace Dbp\Relay\AuthBundle\Tests\Authenticator; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUser; +use Dbp\Relay\AuthBundle\Authenticator\BearerUser; use PHPUnit\Framework\TestCase; -class KeycloakBearerUserTest extends TestCase +class BearerUserTest extends TestCase { public function testRolesWithNoRealUser() { - $user = new KeycloakBearerUser(null, ['foobar']); + $user = new BearerUser(null, ['foobar']); $this->assertSame(['foobar'], $user->getRoles()); } public function testGetUserIdentifier() { - $user = new KeycloakBearerUser(null, ['foobar']); + $user = new BearerUser(null, ['foobar']); $this->assertSame('', $user->getUserIdentifier()); $this->assertSame('', $user->getUsername()); - $user = new KeycloakBearerUser('quux', ['foobar']); + $user = new BearerUser('quux', ['foobar']); $this->assertSame('quux', $user->getUserIdentifier()); $this->assertSame('quux', $user->getUsername()); } diff --git a/tests/Keycloak/KeycloakLocalTokenValidatorTest.php b/tests/Authenticator/LocalTokenValidatorTest.php similarity index 82% rename from tests/Keycloak/KeycloakLocalTokenValidatorTest.php rename to tests/Authenticator/LocalTokenValidatorTest.php index 64a1bd262166f86bbd9dde5ec0af3ab39fd95293..4b899da1c047bf59051dbbbf42f981757a599aa8 100644 --- a/tests/Keycloak/KeycloakLocalTokenValidatorTest.php +++ b/tests/Authenticator/LocalTokenValidatorTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; +namespace Dbp\Relay\AuthBundle\Tests\Authenticator; -use Dbp\Relay\AuthBundle\Keycloak\Keycloak; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakLocalTokenValidator; -use Dbp\Relay\AuthBundle\Keycloak\TokenValidationException; +use Dbp\Relay\AuthBundle\Authenticator\LocalTokenValidator; +use Dbp\Relay\AuthBundle\Authenticator\TokenValidationException; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; @@ -14,23 +14,18 @@ use Jose\Component\Core\JWK; use Jose\Easy\Build; use Jose\Easy\JWSBuilder; use PHPUnit\Framework\TestCase; -use Symfony\Component\Cache\Adapter\ArrayAdapter; -class KeycloakLocalTokenValidatorTest extends TestCase +class LocalTokenValidatorTest extends TestCase { - /* @var KeycloakLocalTokenValidator */ + /* @var LocalTokenValidator */ private $tokenValidator; - /* @var Keycloak */ - private $keycloak; + private $oid; protected function setUp(): void { - $keycloak = new Keycloak('https://auth.example.com/auth', 'tugraz'); - $this->keycloak = $keycloak; - $cache = new ArrayAdapter(); - - $this->tokenValidator = new KeycloakLocalTokenValidator($keycloak, $cache, 0); + $this->oid = new OIDProvider(); + $this->tokenValidator = new LocalTokenValidator($this->oid, 0); $this->mockResponses([]); } @@ -58,6 +53,16 @@ class KeycloakLocalTokenValidatorTest extends TestCase return ['keys' => [$this->getJWK()->toPublic()->jsonSerialize()]]; } + private function getDiscoverResponse() + { + return [ + 'issuer' => 'https://nope/issuer', + 'jwks_uri' => 'https://nope/certs', + 'introspection_endpoint' => 'https://nope/introspect', + 'introspection_endpoint_auth_signing_alg_values_supported' => ['RS256'], + ]; + } + private function getJWT(array $options = []) { $jwk = $this->getJWK(); @@ -69,7 +74,7 @@ class KeycloakLocalTokenValidatorTest extends TestCase ->nbf($time) ->jti('0123456789') ->alg('RS256') - ->iss($options['issuer'] ?? $this->keycloak->getBaseUrlWithRealm()) + ->iss($options['issuer'] ?? $this->oid->getProviderConfig()->getIssuer()) ->aud('audience1') ->aud('audience2') ->sub('subject'); @@ -81,13 +86,15 @@ class KeycloakLocalTokenValidatorTest extends TestCase private function mockResponses(array $responses) { $stack = HandlerStack::create(new MockHandler($responses)); - $this->tokenValidator->setClientHandler($stack); + $this->oid->setClientHandler($stack); } private function mockJWKResponse() { $jwks = $this->getPublicJWKs(); + $discover = $this->getDiscoverResponse(); $this->mockResponses([ + new Response(200, ['Content-Type' => 'application/json'], json_encode($discover)), new Response(200, ['Content-Type' => 'application/json'], json_encode($jwks)), ]); } @@ -97,21 +104,21 @@ class KeycloakLocalTokenValidatorTest extends TestCase $this->mockJWKResponse(); $result = $this->tokenValidator->validate($this->getJWT()); $this->expectExceptionMessageMatches('/Bad audience/'); - KeycloakLocalTokenValidator::checkAudience($result, 'foo'); + LocalTokenValidator::checkAudience($result, 'foo'); } public function testCheckAudienceGood() { $this->mockJWKResponse(); $result = $this->tokenValidator->validate($this->getJWT()); - KeycloakLocalTokenValidator::checkAudience($result, 'audience2'); - KeycloakLocalTokenValidator::checkAudience($result, 'audience1'); + LocalTokenValidator::checkAudience($result, 'audience2'); + LocalTokenValidator::checkAudience($result, 'audience1'); $this->assertTrue(true); } public function testLocalNoResponse() { - $this->mockResponses([]); + $this->mockJWKResponse(); $this->expectException(TokenValidationException::class); $this->tokenValidator->validate('foobar'); } diff --git a/tests/Keycloak/KeycloakRemoteTokenValidatorTest.php b/tests/Authenticator/RemoteTokenValidatorTest.php similarity index 73% rename from tests/Keycloak/KeycloakRemoteTokenValidatorTest.php rename to tests/Authenticator/RemoteTokenValidatorTest.php index b8d10d622f8f0c755570ebb1b21f63d4124ff95e..037bc8232fe1fcd0e2d043265644e9c272b2956b 100644 --- a/tests/Keycloak/KeycloakRemoteTokenValidatorTest.php +++ b/tests/Authenticator/RemoteTokenValidatorTest.php @@ -4,24 +4,29 @@ declare(strict_types=1); namespace Dbp\Relay\AuthBundle\Tests\Keycloak; -use Dbp\Relay\AuthBundle\Keycloak\Keycloak; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakRemoteTokenValidator; -use Dbp\Relay\AuthBundle\Keycloak\TokenValidationException; +use Dbp\Relay\AuthBundle\Authenticator\RemoteTokenValidator; +use Dbp\Relay\AuthBundle\Authenticator\TokenValidationException; +use Dbp\Relay\AuthBundle\OIDC\OIDProvider; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; -class KeycloakRemoteTokenValidatorTest extends TestCase +class RemoteTokenValidatorTest extends TestCase { - /* @var KeycloakRemoteTokenValidator */ + /* @var RemoteTokenValidator */ private $tokenValidator; + private $oid; + protected function setUp(): void { - $keycloak = new Keycloak('https://auth.example.com/auth', 'tugraz', 'client', 'secret'); - - $this->tokenValidator = new KeycloakRemoteTokenValidator($keycloak); + $this->oid = new OIDProvider(); + $this->oid->setConfig([ + 'remote_validation_id' => 'foo', + 'remote_validation_secret' => 'bar', + ]); + $this->tokenValidator = new RemoteTokenValidator($this->oid); $this->mockResponses([]); } @@ -33,7 +38,7 @@ class KeycloakRemoteTokenValidatorTest extends TestCase private function mockResponses(array $responses) { $stack = HandlerStack::create(new MockHandler($responses)); - $this->tokenValidator->setClientHandler($stack); + $this->oid->setClientHandler($stack); } public function testValidateOK() @@ -54,6 +59,12 @@ class KeycloakRemoteTokenValidatorTest extends TestCase ]; $this->mockResponses([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'issuer' => 'https://auth.example.com/auth/realms/tugraz', + 'jwks_uri' => 'https://nope/certs', + 'introspection_endpoint' => 'https://nope/introspect', + 'introspection_endpoint_auth_signing_alg_values_supported' => ['RS256'], + ])), new Response(200, ['Content-Type' => 'application/json'], json_encode($result)), ]); diff --git a/tests/Keycloak/KeycloakUserSessionTest.php b/tests/Authenticator/UserSessionTest.php similarity index 62% rename from tests/Keycloak/KeycloakUserSessionTest.php rename to tests/Authenticator/UserSessionTest.php index adb98348d7f7eb0fee50a8354681cff57d2f4027..3b9d049d20ac585477fa40a3ddf23ffdedd07a65 100644 --- a/tests/Keycloak/KeycloakUserSessionTest.php +++ b/tests/Authenticator/UserSessionTest.php @@ -2,26 +2,26 @@ declare(strict_types=1); -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; +namespace Dbp\Relay\AuthBundle\Tests\Authenticator; -use Dbp\Relay\AuthBundle\Service\KeycloakUserSession; +use Dbp\Relay\AuthBundle\Service\OIDCUserSession; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; -class KeycloakUserSessionTest extends TestCase +class UserSessionTest extends TestCase { public function testIsServiceAccountToken() { - $this->assertTrue(KeycloakUserSession::isServiceAccountToken(['scope' => 'foo bar'])); - $this->assertFalse(KeycloakUserSession::isServiceAccountToken(['scope' => 'openid foo bar'])); - $this->assertFalse(KeycloakUserSession::isServiceAccountToken(['scope' => 'openid'])); - $this->assertFalse(KeycloakUserSession::isServiceAccountToken(['scope' => 'foo openid bar'])); - $this->assertFalse(KeycloakUserSession::isServiceAccountToken(['scope' => 'foo bar openid'])); + $this->assertTrue(OIDCUserSession::isServiceAccountToken(['scope' => 'foo bar'])); + $this->assertFalse(OIDCUserSession::isServiceAccountToken(['scope' => 'openid foo bar'])); + $this->assertFalse(OIDCUserSession::isServiceAccountToken(['scope' => 'openid'])); + $this->assertFalse(OIDCUserSession::isServiceAccountToken(['scope' => 'foo openid bar'])); + $this->assertFalse(OIDCUserSession::isServiceAccountToken(['scope' => 'foo bar openid'])); } public function testGetLoggingId() { - $session = new KeycloakUserSession(new ParameterBag()); + $session = new OIDCUserSession(new ParameterBag()); $session->setSessionToken([]); $this->assertSame('unknown-unknown', $session->getSessionLoggingId()); @@ -31,7 +31,7 @@ class KeycloakUserSessionTest extends TestCase public function testGetUserRoles() { - $session = new KeycloakUserSession(new ParameterBag()); + $session = new OIDCUserSession(new ParameterBag()); $session->setSessionToken([]); $this->assertSame([], $session->getUserRoles()); $session->setSessionToken(['scope' => 'foo bar quux-buz a_b']); @@ -42,7 +42,7 @@ class KeycloakUserSessionTest extends TestCase public function testGetSessionCacheKey() { - $session = new KeycloakUserSession(new ParameterBag()); + $session = new OIDCUserSession(new ParameterBag()); $session->setSessionToken(['scope' => 'foo']); $old = $session->getSessionCacheKey(); $session->setSessionToken(['scope' => 'bar']); @@ -52,7 +52,7 @@ class KeycloakUserSessionTest extends TestCase public function testGetSessionTTL() { - $session = new KeycloakUserSession(new ParameterBag()); + $session = new OIDCUserSession(new ParameterBag()); $session->setSessionToken([]); $this->assertSame(-1, $session->getSessionTTL()); diff --git a/tests/DummyUserProvider.php b/tests/DummyUserProvider.php index 4f21cac20a5d803ff8981c79a82925f7d71d7e43..41f5b2fd56fd0597ef1fbbc1670af6a403e30d0d 100644 --- a/tests/DummyUserProvider.php +++ b/tests/DummyUserProvider.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Dbp\Relay\AuthBundle\Tests; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProviderInterface; +use Dbp\Relay\AuthBundle\Authenticator\BearerUserProviderInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\User\UserInterface; -class DummyUserProvider implements KeycloakBearerUserProviderInterface +class DummyUserProvider implements BearerUserProviderInterface { private $user; private $token; diff --git a/tests/Keycloak/KeycloakBearerAuthenticatorTest.php b/tests/Keycloak/KeycloakBearerAuthenticatorTest.php deleted file mode 100644 index 589d76abbdf3075a6ae7ae5437c6ac9cd409bdb5..0000000000000000000000000000000000000000 --- a/tests/Keycloak/KeycloakBearerAuthenticatorTest.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; - -use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerAuthenticator; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUser; -use Dbp\Relay\AuthBundle\Tests\DummyUserProvider; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; - -class KeycloakBearerAuthenticatorTest extends ApiTestCase -{ - public function testAuthenticateNoHeader() - { - $user = new KeycloakBearerUser('foo', ['role']); - $provider = new DummyUserProvider($user, 'nope'); - $auth = new KeycloakBearerAuthenticator($provider); - - $req = new Request(); - $this->expectException(BadCredentialsException::class); - $auth->authenticate($req); - } - - public function testSupports() - { - $user = new KeycloakBearerUser('foo', ['role']); - $provider = new DummyUserProvider($user, 'bar'); - $auth = new KeycloakBearerAuthenticator($provider); - - $this->assertFalse($auth->supports(new Request())); - - $r = new Request(); - $r->headers->set('Authorization', 'foobar'); - $this->assertTrue($auth->supports($r)); - } -} diff --git a/tests/Keycloak/KeycloakBearerUserProviderTest.php b/tests/Keycloak/KeycloakBearerUserProviderTest.php deleted file mode 100644 index 1579c2ecb09d4467002c72140e6321cb3f91c7dc..0000000000000000000000000000000000000000 --- a/tests/Keycloak/KeycloakBearerUserProviderTest.php +++ /dev/null @@ -1,30 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; - -use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; -use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider; -use Dbp\Relay\AuthBundle\Tests\DummyUserSession; - -class KeycloakBearerUserProviderTest extends ApiTestCase -{ - public function testWithIdentifier() - { - $udprov = new DummyUserSession('foo', ['role']); - $prov = new KeycloakBearerUserProvider($udprov); - $user = $prov->loadUserByValidatedToken([]); - $this->assertSame('foo', $user->getUserIdentifier()); - $this->assertSame(['role'], $user->getRoles()); - } - - public function testWithoutIdentifier() - { - $udprov = new DummyUserSession(null, ['role']); - $prov = new KeycloakBearerUserProvider($udprov); - $user = $prov->loadUserByValidatedToken([]); - $this->assertSame('', $user->getUserIdentifier()); - $this->assertSame(['role'], $user->getRoles()); - } -} diff --git a/tests/Keycloak/KeycloakTest.php b/tests/Keycloak/KeycloakTest.php deleted file mode 100644 index 325be286e4a9a954b5c52b2ff31b2f23c86818f3..0000000000000000000000000000000000000000 --- a/tests/Keycloak/KeycloakTest.php +++ /dev/null @@ -1,21 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Dbp\Relay\AuthBundle\Tests\Keycloak; - -use Dbp\Relay\AuthBundle\Keycloak\Keycloak; -use PHPUnit\Framework\TestCase; - -class KeycloakTest extends TestCase -{ - public function testURLs() - { - $provider = new Keycloak('http://foo.bar', 'somerealm'); - - $this->assertSame('http://foo.bar/realms/somerealm', $provider->getBaseUrlWithRealm()); - $this->assertSame('http://foo.bar/realms/somerealm/protocol/openid-connect/auth', $provider->getBaseAuthorizationUrl()); - $this->assertSame('http://foo.bar/realms/somerealm/protocol/openid-connect/token/introspect', $provider->getTokenIntrospectionUrl()); - $this->assertSame('http://foo.bar/realms/somerealm/protocol/openid-connect/token', $provider->getBaseAccessTokenUrl([])); - } -} diff --git a/tests/OIDC/OIDProviderConfigTest.php b/tests/OIDC/OIDProviderConfigTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9730d93423504dfb14e88667fb47541cde0452b0 --- /dev/null +++ b/tests/OIDC/OIDProviderConfigTest.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\AuthBundle\Tests\OIDC; + +use Dbp\Relay\AuthBundle\OIDC\OIDProviderConfig; +use PHPUnit\Framework\TestCase; + +class OIDProviderConfigTest extends TestCase +{ + public function testConfig() + { + $data = [ + 'issuer' => 'issuer', + 'jwks_uri' => 'jwks_uri', + 'introspection_endpoint_auth_signing_alg_values_supported' => ['RS256'], + 'introspection_endpoint' => 'introspection_endpoint', + ]; + $oid = OIDProviderConfig::fromString(json_encode($data)); + $oid->getIntrospectionEndpointSigningAlgorithms(); + $oid->getIntrospectionEndpoint(); + $oid->getJwksUri(); + $this->assertSame('issuer', $oid->getIssuer()); + $this->assertSame(['RS256'], $oid->getIntrospectionEndpointSigningAlgorithms()); + $this->assertSame('introspection_endpoint', $oid->getIntrospectionEndpoint()); + $this->assertSame('jwks_uri', $oid->getJwksUri()); + } +}