From 3faa7dd5ea373fedc6fb635e18118aa67b2c2d3d Mon Sep 17 00:00:00 2001 From: Christoph Reiter <reiter.christoph@gmail.com> Date: Tue, 12 Oct 2021 15:49:36 +0200 Subject: [PATCH] Switch to the OIDC discover protocol for the provider config The goal is to support every OIDC server that implements the discover protocol (Keycloak for example). This allows us to fetch all the required information at runtime without the user having to keep the settings in sync with the used server. The config and public keys are cached for one hour. While in theory this works with non-keycloak it isn't tested yet, and we still need keycloak specific settings for the API docs auth because we only support keycloak with our frontend web components which we inject into the openapi docs. Fixes #3 --- README.md | 29 +-- .../BearerAuthenticator.php} | 12 +- .../BearerUser.php} | 4 +- .../BearerUserProvider.php} | 37 ++-- .../BearerUserProviderInterface.php} | 4 +- src/Authenticator/LocalTokenValidator.php | 87 +++++++++ src/Authenticator/RemoteTokenValidator.php | 40 ++++ .../TokenValidationException.php | 2 +- .../TokenValidatorBase.php} | 4 +- src/DependencyInjection/Configuration.php | 47 +++-- .../DbpRelayAuthExtension.php | 21 ++- src/Keycloak/Keycloak.php | 66 ------- src/Keycloak/KeycloakLocalTokenValidator.php | 146 --------------- src/Keycloak/KeycloakRemoteTokenValidator.php | 87 --------- src/OIDC/OIDError.php | 9 + src/OIDC/OIDProvider.php | 174 ++++++++++++++++++ src/OIDC/OIDProviderConfig.php | 54 ++++++ src/Resources/config/services.yaml | 20 +- ...oakUserSession.php => OIDCUserSession.php} | 2 +- .../Authenticator/BearerAuthenticatorTest.php | 75 ++++++++ .../Authenticator/BearerUserProviderTest.php | 47 +++++ .../BearerUserTest.php} | 12 +- .../LocalTokenValidatorTest.php} | 47 +++-- .../RemoteTokenValidatorTest.php} | 29 ++- .../UserSessionTest.php} | 24 +-- tests/DummyUserProvider.php | 4 +- .../KeycloakBearerAuthenticatorTest.php | 39 ---- .../KeycloakBearerUserProviderTest.php | 30 --- tests/Keycloak/KeycloakTest.php | 21 --- tests/OIDC/OIDProviderConfigTest.php | 29 +++ 30 files changed, 680 insertions(+), 522 deletions(-) rename src/{Keycloak/KeycloakBearerAuthenticator.php => Authenticator/BearerAuthenticator.php} (80%) rename src/{Keycloak/KeycloakBearerUser.php => Authenticator/BearerUser.php} (90%) rename src/{Keycloak/KeycloakBearerUserProvider.php => Authenticator/BearerUserProvider.php} (59%) rename src/{Keycloak/KeycloakBearerUserProviderInterface.php => Authenticator/BearerUserProviderInterface.php} (73%) create mode 100644 src/Authenticator/LocalTokenValidator.php create mode 100644 src/Authenticator/RemoteTokenValidator.php rename src/{Keycloak => Authenticator}/TokenValidationException.php (65%) rename src/{Keycloak/KeycloakTokenValidatorBase.php => Authenticator/TokenValidatorBase.php} (91%) delete mode 100644 src/Keycloak/Keycloak.php delete mode 100644 src/Keycloak/KeycloakLocalTokenValidator.php delete mode 100644 src/Keycloak/KeycloakRemoteTokenValidator.php create mode 100644 src/OIDC/OIDError.php create mode 100644 src/OIDC/OIDProvider.php create mode 100644 src/OIDC/OIDProviderConfig.php rename src/Service/{KeycloakUserSession.php => OIDCUserSession.php} (98%) create mode 100644 tests/Authenticator/BearerAuthenticatorTest.php create mode 100644 tests/Authenticator/BearerUserProviderTest.php rename tests/{Keycloak/KeycloakBearerUserTest.php => Authenticator/BearerUserTest.php} (59%) rename tests/{Keycloak/KeycloakLocalTokenValidatorTest.php => Authenticator/LocalTokenValidatorTest.php} (82%) rename tests/{Keycloak/KeycloakRemoteTokenValidatorTest.php => Authenticator/RemoteTokenValidatorTest.php} (73%) rename tests/{Keycloak/KeycloakUserSessionTest.php => Authenticator/UserSessionTest.php} (62%) delete mode 100644 tests/Keycloak/KeycloakBearerAuthenticatorTest.php delete mode 100644 tests/Keycloak/KeycloakBearerUserProviderTest.php delete mode 100644 tests/Keycloak/KeycloakTest.php create mode 100644 tests/OIDC/OIDProviderConfigTest.php diff --git a/README.md b/README.md index 8754fab..29abaec 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 141e657..138606a 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 846324c..b22a036 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 c8b2fcd..e2b85ca 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 b0bbbd2..80108f2 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 0000000..d6625b5 --- /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 0000000..2646bac --- /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 4f2530f..fe3311a 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 9b7153e..89609e5 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 ba387bb..1ddc1f6 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 a501167..0b6e058 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 1d6762b..0000000 --- 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 1d3e535..0000000 --- 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 c416673..0000000 --- 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 0000000..59e6099 --- /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 0000000..052b1a0 --- /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 0000000..8f1602f --- /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 0b40a65..9537277 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 2a15ef7..e47fe16 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 0000000..f54a3c3 --- /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 0000000..f57e1de --- /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 3719941..55b3e2f 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 64a1bd2..4b899da 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 b8d10d6..037bc82 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 adb9834..3b9d049 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 4f21cac..41f5b2f 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 589d76a..0000000 --- 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 1579c2e..0000000 --- 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 325be28..0000000 --- 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 0000000..9730d93 --- /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()); + } +} -- GitLab