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