Skip to content
Snippets Groups Projects
Commit 3faa7dd5 authored by Reiter, Christoph's avatar Reiter, Christoph :snake:
Browse files

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
parent 6652d9ba
Branches
Tags
1 merge request!14Switch to the OIDC discover protocol for the provider config
Pipeline #57274 passed
Showing
with 584 additions and 237 deletions
...@@ -9,24 +9,25 @@ created via `./bin/console config:dump-reference DbpRelayAuthBundle | sed '/^$/d ...@@ -9,24 +9,25 @@ created via `./bin/console config:dump-reference DbpRelayAuthBundle | sed '/^$/d
```yaml ```yaml
# Default configuration for "DbpRelayAuthBundle" # Default configuration for "DbpRelayAuthBundle"
dbp_relay_auth: dbp_relay_auth:
# The Keycloak server URL # The base URL for the OIDC server (in case of Keycloak fort the specific realm)
server_url: ~ # Example: 'https://keycloak.example.com/auth' server_url: ~ # Example: 'https://keycloak.example.com/auth/realms/my-realm'
# The Keycloak Realm # If set only tokens which contain this audience are accepted (optional)
realm: ~ # Example: myrealm required_audience: ~ # Example: my-api
# The ID for the keycloak client (authorization code flow) used for API docs or similar # How much the system time of the API server and the Keycloak server
frontend_client_id: ~ # Example: client-docs # 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 # 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 # be only checked locally and not send to the keycloak server
remote_validation: false remote_validation: false
# The ID of the client (client credentials flow) used for remote token validation # The ID of the client (client credentials flow) used for remote token validation
# (optional) # (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) # The client secret for the client referenced by client_id (optional)
remote_validation_client_secret: ~ # Example: mysecret remote_validation_secret: ~ # Example: mysecret
# If set only tokens which contain this audience are accepted (optional) # The Keycloak server base URL
required_audience: ~ # Example: my-api frontend_keycloak_server: ~ # Example: 'https://keycloak.example.com/auth'
# How much the system time of the API server and the Keycloak server # The keycloak realm
# can be out of sync (in seconds). Used for local token validation. frontend_keycloak_realm: ~ # Example: client-docs
local_validation_leeway: 120 # 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
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerAwareTrait;
...@@ -17,13 +17,13 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; ...@@ -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\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class KeycloakBearerAuthenticator extends AbstractAuthenticator implements LoggerAwareInterface class BearerAuthenticator extends AbstractAuthenticator implements LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
private $userProvider; private $userProvider;
public function __construct(KeycloakBearerUserProviderInterface $userProvider) public function __construct(BearerUserProviderInterface $userProvider)
{ {
$this->userProvider = $userProvider; $this->userProvider = $userProvider;
} }
...@@ -52,8 +52,10 @@ class KeycloakBearerAuthenticator extends AbstractAuthenticator implements Logge ...@@ -52,8 +52,10 @@ class KeycloakBearerAuthenticator extends AbstractAuthenticator implements Logge
$token = trim(preg_replace('/^(?:\s+)?Bearer\s/', '', $auth)); $token = trim(preg_replace('/^(?:\s+)?Bearer\s/', '', $auth));
return new SelfValidatingPassport(new UserBadge($token, function ($token) { $user = $this->userProvider->loadUserByToken($token);
return $this->userProvider->loadUserByToken($token);
return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), function ($token) use ($user) {
return $user;
})); }));
} }
} }
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
class KeycloakBearerUser implements UserInterface class BearerUser implements UserInterface
{ {
/** /**
* @var string[] * @var string[]
......
...@@ -2,28 +2,28 @@ ...@@ -2,28 +2,28 @@
declare(strict_types=1); 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 Dbp\Relay\CoreBundle\API\UserSessionInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait; 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; use Symfony\Component\Security\Core\User\UserInterface;
class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, LoggerAwareInterface class BearerUserProvider implements BearerUserProviderInterface, LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
private $config; private $config;
private $certCachePool;
private $personCachePool;
private $userSession; private $userSession;
private $oidProvider;
public function __construct(UserSessionInterface $userSession) public function __construct(UserSessionInterface $userSession, OIDProvider $oidProvider)
{ {
$this->userSession = $userSession; $this->userSession = $userSession;
$this->config = []; $this->config = [];
$this->oidProvider = $oidProvider;
} }
public function setConfig(array $config) public function setConfig(array $config)
...@@ -31,37 +31,30 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, ...@@ -31,37 +31,30 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface,
$this->config = $config; $this->config = $config;
} }
public function setCertCache(?CacheItemPoolInterface $cachePool)
{
$this->certCachePool = $cachePool;
}
public function loadUserByToken(string $accessToken): UserInterface public function loadUserByToken(string $accessToken): UserInterface
{ {
$config = $this->config; $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']) { if (!$config['remote_validation']) {
$leeway = $config['local_validation_leeway']; $leeway = $config['local_validation_leeway'];
$validator = new KeycloakLocalTokenValidator($keycloak, $this->certCachePool, $leeway); $validator = new LocalTokenValidator($this->oidProvider, $leeway);
} else { } else {
$validator = new KeycloakRemoteTokenValidator($keycloak); $validator = new RemoteTokenValidator($this->oidProvider);
} }
if ($this->logger !== null) {
$validator->setLogger($this->logger); $validator->setLogger($this->logger);
}
try { try {
$jwt = $validator->validate($accessToken); $jwt = $validator->validate($accessToken);
} catch (TokenValidationException $e) { } catch (TokenValidationException $e) {
throw new AccessDeniedException('Invalid token'); throw new AuthenticationException('Invalid token');
} }
if (($config['required_audience'] ?? '') !== '') { if (($config['required_audience'] ?? '') !== '') {
try { try {
$validator::checkAudience($jwt, $config['required_audience']); $validator::checkAudience($jwt, $config['required_audience']);
} catch (TokenValidationException $e) { } catch (TokenValidationException $e) {
throw new AccessDeniedException('Invalid token audience'); throw new AuthenticationException('Invalid token audience');
} }
} }
...@@ -75,7 +68,7 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface, ...@@ -75,7 +68,7 @@ class KeycloakBearerUserProvider implements KeycloakBearerUserProviderInterface,
$identifier = $session->getUserIdentifier(); $identifier = $session->getUserIdentifier();
$userRoles = $session->getUserRoles(); $userRoles = $session->getUserRoles();
return new KeycloakBearerUser( return new BearerUser(
$identifier, $identifier,
$userRoles $userRoles
); );
......
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
interface KeycloakBearerUserProviderInterface interface BearerUserProviderInterface
{ {
public function loadUserByToken(string $accessToken): UserInterface; public function loadUserByToken(string $accessToken): UserInterface;
......
...@@ -2,98 +2,29 @@ ...@@ -2,98 +2,29 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
use Dbp\Relay\AuthBundle\Helpers\Tools; use Dbp\Relay\AuthBundle\OIDC\OIDError;
use GuzzleHttp\Client; use Dbp\Relay\AuthBundle\OIDC\OIDProvider;
use GuzzleHttp\HandlerStack;
use Jose\Component\Core\JWKSet; use Jose\Component\Core\JWKSet;
use Jose\Easy\Load; use Jose\Easy\Load;
use Jose\Easy\Validate; 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 class LocalTokenValidator extends TokenValidatorBase
{ {
private $keycloak; private $oidProvider;
private $cachePool;
private $clientHandler;
private $leewaySeconds; private $leewaySeconds;
/* The duration the public keycloak cert is cached */ public function __construct(OIDProvider $oidProvider, int $leewaySeconds)
private const CERT_CACHE_TTL_SECONDS = 3600;
public function __construct(Keycloak $keycloak, ?CacheItemPoolInterface $cachePool, int $leewaySeconds)
{ {
$this->keycloak = $keycloak; $this->oidProvider = $oidProvider;
$this->cachePool = $cachePool;
$this->leewaySeconds = $leewaySeconds; $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. * Validates the token locally using the public JWK of the OIDC server.
* *
* @throws TokenValidationException * This is faster because everything can be cached, but tokens/sessions revoked on the OIDC server
*/
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. * will still be considered valid as long as they are not expired.
* *
* @return array the token * @return array the token
...@@ -102,8 +33,18 @@ class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase ...@@ -102,8 +33,18 @@ class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase
*/ */
public function validate(string $accessToken): array public function validate(string $accessToken): array
{ {
$jwks = $this->fetchJWKs(); try {
$issuer = $this->keycloak->getBaseUrlWithRealm(); $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: // Checks not needed/used here:
// * sub(): This is the keycloak user ID by default, nothing we know beforehand // * sub(): This is the keycloak user ID by default, nothing we know beforehand
...@@ -113,7 +54,7 @@ class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase ...@@ -113,7 +54,7 @@ class KeycloakLocalTokenValidator extends KeycloakTokenValidatorBase
$keySet = JWKSet::createFromKeyData($jwks); $keySet = JWKSet::createFromKeyData($jwks);
$validate = Load::jws($accessToken); $validate = Load::jws($accessToken);
$validate = $validate $validate = $validate
->algs(['RS256', 'RS512']) ->algs($algs)
->keyset($keySet) ->keyset($keySet)
->exp($this->leewaySeconds) ->exp($this->leewaySeconds)
->iat($this->leewaySeconds) ->iat($this->leewaySeconds)
......
<?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;
}
}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
class TokenValidationException extends \Exception class TokenValidationException extends \Exception
{ {
......
...@@ -2,12 +2,12 @@ ...@@ -2,12 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Keycloak; namespace Dbp\Relay\AuthBundle\Authenticator;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerAwareTrait;
abstract class KeycloakTokenValidatorBase implements LoggerAwareInterface abstract class TokenValidatorBase implements LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
......
...@@ -14,42 +14,51 @@ class Configuration implements ConfigurationInterface ...@@ -14,42 +14,51 @@ class Configuration implements ConfigurationInterface
$treeBuilder = new TreeBuilder('dbp_relay_auth'); $treeBuilder = new TreeBuilder('dbp_relay_auth');
$treeBuilder->getRootNode() $treeBuilder->getRootNode()
->children() ->children()
// Note: "<server_url>/.well-known/openid-configuration" has to exist
->scalarNode('server_url') ->scalarNode('server_url')
->info('The Keycloak server URL') ->info('The base URL for the OIDC server (in case of Keycloak fort the specific realm)')
->example('https://keycloak.example.com/auth') ->example('https://keycloak.example.com/auth/realms/my-realm')
->end() ->end()
->scalarNode('realm')
->info('The Keycloak Realm') // Settings for token validation
->example('myrealm') ->scalarNode('required_audience')
->info('If set only tokens which contain this audience are accepted (optional)')
->example('my-api')
->end() ->end()
// API docs ->integerNode('local_validation_leeway')
->scalarNode('frontend_client_id') ->defaultValue(120)
->info('The ID for the keycloak client (authorization code flow) used for API docs or similar') ->min(0)
->example('client-docs') ->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() ->end()
// Remote validation // Remote validation
->booleanNode('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") ->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) ->example(false)
->defaultFalse() ->defaultFalse()
->end() ->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)") ->info("The ID of the client (client credentials flow) used for remote token validation\n(optional)")
->example('client-token-check') ->example('client-token-check')
->end() ->end()
->scalarNode('remote_validation_client_secret') ->scalarNode('remote_validation_secret')
->info('The client secret for the client referenced by client_id (optional)') ->info('The client secret for the client referenced by client_id (optional)')
->example('mysecret') ->example('mysecret')
->end() ->end()
// Settings for token validation
->scalarNode('required_audience') // API docs. This is still Keycloak specific because we only have a keycloak
->info('If set only tokens which contain this audience are accepted (optional)') // web component right now.
->example('my-api') ->scalarNode('frontend_keycloak_server')
->info('The Keycloak server base URL')
->example('https://keycloak.example.com/auth')
->end() ->end()
->integerNode('local_validation_leeway') ->scalarNode('frontend_keycloak_realm')
->defaultValue(120) ->info('The keycloak realm')
->min(0) ->example('client-docs')
->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()
->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()
->end(); ->end();
......
...@@ -4,6 +4,8 @@ declare(strict_types=1); ...@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\DependencyInjection; 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\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
...@@ -21,22 +23,25 @@ class DbpRelayAuthExtension extends ConfigurableExtension implements PrependExte ...@@ -21,22 +23,25 @@ class DbpRelayAuthExtension extends ConfigurableExtension implements PrependExte
); );
$loader->load('services.yaml'); $loader->load('services.yaml');
$certCacheDef = $container->register('dbp_api.cache.keycloak.keycloak_cert', FilesystemAdapter::class); $cacheDef = $container->register('dbp_api.cache.auth.oid_provider', FilesystemAdapter::class);
$certCacheDef->setArguments(['core-keycloak-cert', 60, '%kernel.cache_dir%/dbp/keycloak-keycloak-cert']); $cacheDef->setArguments(['core-keycloak-cert', 60, '%kernel.cache_dir%/dbp/auth-oid-provider']);
$certCacheDef->addTag('cache.pool'); $cacheDef->addTag('cache.pool');
$definition = $container->getDefinition('Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider'); $definition = $container->getDefinition(BearerUserProvider::class);
$definition->addMethodCall('setConfig', [$mergedConfig]); $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) public function prepend(ContainerBuilder $container)
{ {
$config = $container->getExtensionConfig($this->getAlias())[0]; $config = $container->getExtensionConfig($this->getAlias())[0];
$this->extendArrayParameter($container, 'dbp_api.twig_globals', [ $this->extendArrayParameter($container, 'dbp_api.twig_globals', [
'keycloak_server_url' => $config['server_url'] ?? '', 'keycloak_server_url' => $config['frontend_keycloak_server'] ?? '',
'keycloak_realm' => $config['realm'] ?? '', 'keycloak_realm' => $config['frontend_keycloak_realm'] ?? '',
'keycloak_frontend_client_id' => $config['frontend_client_id'] ?? '', 'keycloak_frontend_client_id' => $config['frontend_keycloak_client_id'] ?? '',
]); ]);
} }
......
<?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());
}
}
<?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;
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\OIDC;
class OIDError extends \Exception
{
}
<?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;
}
}
<?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'] ?? [];
}
}
services: services:
Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerAuthenticator: Dbp\Relay\AuthBundle\Authenticator\BearerAuthenticator:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider: Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Dbp\Relay\AuthBundle\Service\KeycloakUserSession: Dbp\Relay\AuthBundle\Service\OIDCUserSession:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProviderInterface: Dbp\Relay\AuthBundle\OIDC\OIDProvider:
'@Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider' autowire: true
autoconfigure: true
Dbp\Relay\CoreBundle\API\UserSessionInterface: Dbp\Relay\AuthBundle\Authenticator\BearerUserProviderInterface:
'@Dbp\Relay\AuthBundle\Service\KeycloakUserSession' '@Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider'
Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface: 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'
...@@ -7,7 +7,7 @@ namespace Dbp\Relay\AuthBundle\Service; ...@@ -7,7 +7,7 @@ namespace Dbp\Relay\AuthBundle\Service;
use Dbp\Relay\CoreBundle\API\UserSessionInterface; use Dbp\Relay\CoreBundle\API\UserSessionInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class KeycloakUserSession implements UserSessionInterface class OIDCUserSession implements UserSessionInterface
{ {
/** /**
* @var ?array * @var ?array
......
<?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()));
}
}
...@@ -2,18 +2,21 @@ ...@@ -2,18 +2,21 @@
declare(strict_types=1); declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Tests\Keycloak; namespace Dbp\Relay\AuthBundle\Tests\Authenticator;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
use Dbp\Relay\AuthBundle\Keycloak\KeycloakBearerUserProvider; use Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider;
use Dbp\Relay\AuthBundle\OIDC\OIDProvider;
use Dbp\Relay\AuthBundle\Tests\DummyUserSession; use Dbp\Relay\AuthBundle\Tests\DummyUserSession;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class KeycloakBearerUserProviderTest extends ApiTestCase class BearerUserProviderTest extends ApiTestCase
{ {
public function testWithIdentifier() public function testWithIdentifier()
{ {
$oid = new OIDProvider();
$udprov = new DummyUserSession('foo', ['role']); $udprov = new DummyUserSession('foo', ['role']);
$prov = new KeycloakBearerUserProvider($udprov); $prov = new BearerUserProvider($udprov, $oid);
$user = $prov->loadUserByValidatedToken([]); $user = $prov->loadUserByValidatedToken([]);
$this->assertSame('foo', $user->getUserIdentifier()); $this->assertSame('foo', $user->getUserIdentifier());
$this->assertSame(['role'], $user->getRoles()); $this->assertSame(['role'], $user->getRoles());
...@@ -21,10 +24,24 @@ class KeycloakBearerUserProviderTest extends ApiTestCase ...@@ -21,10 +24,24 @@ class KeycloakBearerUserProviderTest extends ApiTestCase
public function testWithoutIdentifier() public function testWithoutIdentifier()
{ {
$oid = new OIDProvider();
$udprov = new DummyUserSession(null, ['role']); $udprov = new DummyUserSession(null, ['role']);
$prov = new KeycloakBearerUserProvider($udprov); $prov = new BearerUserProvider($udprov, $oid);
$user = $prov->loadUserByValidatedToken([]); $user = $prov->loadUserByValidatedToken([]);
$this->assertSame('', $user->getUserIdentifier()); $this->assertSame('', $user->getUserIdentifier());
$this->assertSame(['role'], $user->getRoles()); $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');
}
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment