Goal
I'm trying to setup OAuth2 in Symfony 6 with Keycloak and the libraries knpuniversity/oauth2-client-bundle and stevenmaguire/oauth2-keycloak. Everything is setup with docker compose (v2.2) and I'm using Traefik (v2.5) as proxy.
Problem
When trying to validate the user, after going to /connect/keycloak, succesfully getting redirected to https://keycloak.example.local/auth/realms/.../protocol/openid-connect/auth, logging in succesfully and redirected back to /connect/keycloak/check, I keep getting the error that the token verification failed.
IdentityProviderException
HTTP 500 Internal Server Error
invalid_token: Token verification failed

Further inspection of the underlying guzzle response shows a 401 statusCode with the WWW-Authenticate header showing the error:
"Bearer realm="redacted", error="invalid_token", error_description="Token verification failed"
Application
I've setup the following in my Symfony 6 (PHP 8.1) app:
config\packages\framework.yaml
session:
enabled: true
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
cookie_secure: auto
cookie_samesite: lax
config\packages\knpu_oauth2_client.yaml
knpu_oauth2_client:
http_client_options:
timeout: 0
proxy: 'http://keycloak:8080'
verify: false
clients:
keycloak:
type: keycloak
client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%'
redirect_route: '%env(OAUTH_KEYCLOAK_REDIRECT_ROUTE)%'
redirect_params: { }
auth_server_url: '%env(OAUTH_KEYCLOAK_URL)%'
realm: '%env(OAUTH_KEYCLOAK_REALM)%'
config\packages\security.yaml
...
providers:
oauth:
id: knpu.oauth2.user_provider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: oauth
custom_authenticator: App\Security\KeycloakAuthenticator
App\Controller\KeycloakController
namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class KeycloakController
*/
final class KeycloakController extends AbstractController
{
#[Route('/connect/keycloak', name: 'connect_keycloak_start')]
public function connectAction(
ClientRegistry $clientRegistry
): RedirectResponse {
return $clientRegistry->getClient('keycloak')->redirect(['email'], []);
}
#[Route('/connect/keycloak/check', name: 'connect_keycloak_check')]
public function connectCheckAction(
Request $request,
ClientRegistry $clientRegistry
) {
}
}
App\Security\KeycloakAuthenticator
namespace App\Security;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\Provider\KeycloakClient;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
/**
* Class KeycloakAuthenticator
*/
class KeycloakAuthenticator extends OAuth2Authenticator
{
private $clientRegistry;
private $entityManager;
private $router;
/**
* KeycloakAuthenticator constructor.
* @param ClientRegistry $clientRegistry
* @param EntityManagerInterface $em
* @param RouterInterface $router
*/
public function __construct(ClientRegistry $clientRegistry, EntityManagerInterface $em, RouterInterface $router)
{
$this->clientRegistry = $clientRegistry;
$this->entityManager = $em;
$this->router = $router;
}
/**
* @param Request $request
* @return bool|null
*/
public function supports(Request $request): ?bool
{
return $request->attributes->get('_route') === 'connect_keycloak_check';
}
/**
* @param Request $request
* @return Passport
*/
public function authenticate(Request $request): Passport
{
/** @var KeycloakClient $client */
$client = $this->clientRegistry->getClient('keycloak');
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {
return $client->fetchUserFromToken($accessToken);
})
);
}
/**
* @param Request $request
* @param TokenInterface $token
* @param string $firewallName
* @return Response|null
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->router->generate('admin'));
}
/**
* @param Request $request
* @param AuthenticationException $exception
* @return Response|null
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
}
Docker Compose
docker-compose.yml - app
phpfpm:
build:
context: docker/php
dockerfile: Dockerfile
args:
PHP_VERSION: ${PHP_VERSION}
container_name: app_phpfpm
user: ${UID}:${GID}
security_opt:
- no-new-privileges:true
restart: always
environment:
- PHP_OPCACHE_ENABLE=${OPCACHE_ENABLE}
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=${OPCACHE_VALIDATE_TIMESTAMPS}
- PHP_OPCACHE_PRELOAD_ENV=${APP_ENV}
volumes:
- .:/app
- ~/certs:/certs
- ./docker/php/conf/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini
- ./docker/php/conf/php.ini:/usr/local/etc/php/conf.d/php.ini
- ./docker/php/conf/www.conf:/usr/local/etc/php-fpm.d/www.${APP_DOMAIN}.conf
networks:
- proxy
- app-network
apache:
build:
context: docker/apache
dockerfile: Dockerfile
args:
APACHE_VERSION: ${APACHE_VERSION}
container_name: app_apache
security_opt:
- no-new-privileges:true
restart: always
volumes:
- .:/app
- apache_log:/var/log/apache2
labels:
- "traefik.enable=true"
- "traefik.http.routers.${APP_NAME}-apache-secure.entrypoints=websecure"
- "traefik.http.routers.${APP_NAME}-apache-secure.rule=Host(`${APP_DOMAIN}`, `www.${APP_DOMAIN}`)"
- "traefik.http.routers.${APP_NAME}-apache-secure.service=${APP_NAME}-apache-service"
- "traefik.http.routers.${APP_NAME}-apache-secure.tls=true"
- "traefik.http.routers.${APP_NAME}-apache-secure.middlewares=secure-headers@file"
- "traefik.http.services.${APP_NAME}-apache-service.loadbalancer.server.port=80"
- "traefik.docker.network=proxy"
networks:
- proxy
- app-network
docker-compose.yml - keycloak (16.1.0)
keycloak:
image: jboss/keycloak:${KEYCLOAK_VERSION}
container_name: keycloak
depends_on:
- keycloak-postgres
environment:
DB_VENDOR: postgres
DB_ADDR: keycloak_postgres
DB_PORT: 5432
DB_DATABASE: ${KEYCLOAK_STORAGE_POSTGRES_DATABASE}
DB_USER: ${KEYCLOAK_STORAGE_POSTGRES_USERNAME}
DB_PASSWORD: ${KEYCLOAK_STORAGE_POSTGRES_PASSWORD}
KEYCLOAK_USER: ${KEYCLOAK_USERNAME}
KEYCLOAK_PASSWORD: ${KEYCLOAK_PASSWORD}
KEYCLOAK_HOSTNAME: keycloak.${APP_DOMAIN}
PROXY_ADDRESS_FORWARDING: 'true'
KEYCLOAK_LOGLEVEL: DEBUG
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak-secure.entrypoints=websecure"
- "traefik.http.routers.keycloak-secure.rule=Host(`keycloak.${APP_DOMAIN}`, `www.keycloak.${APP_DOMAIN}`)"
- "traefik.http.routers.keycloak-secure.service=keycloak-service"
- "traefik.http.routers.keycloak-secure.tls=true"
- "traefik.http.services.keycloak-service.loadbalancer.passhostheader=true"
- "traefik.http.services.keycloak-service.loadbalancer.server.port=8080"
- "traefik.docker.network=proxy"
networks:
- proxy
- keycloak-network
docker-compose.yml - traefik (2.5)
traefik:
image: traefik:v${TRAEFIK_VERSION}
container_name: traefik
security_opt:
- no-new-privileges:true
ports:
- 80:80
- 443:443
- 8080:8080
restart: always
environment:
CF_API_EMAIL: ${CF_EMAIL}
CF_API_KEY: ${CF_API_KEY}
volumes:
- ./docker/traefik/config/traefik.yml:/traefik.yml:ro
- ./docker/traefik/config/dynamic:/dynamic:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/localtime:/etc/localtime:ro
- /usr/share/zoneinfo:/usr/share/zoneinfo:ro
- ~/certs:/certs
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik-secure.entrypoints=websecure"
- "traefik.http.routers.traefik-secure.rule=Host(`traefik.${APP_DOMAIN}`, `www.traefik.${APP_DOMAIN}`)"
- "traefik.http.routers.traefik-secure.service=api@internal"
- "traefik.http.routers.traefik-secure.tls=true"
- "traefik.docker.network=proxy"
traefik.yml
api:
dashboard: true
log:
format: json
level: DEBUG
entryPoints:
web:
address: :80
http:
redirections:
entryPoint:
to: websecure
websecure:
address: :443
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
watch: true
network: proxy
file:
directory: /dynamic
watch: true
dynamic/http.yml
http:
middlewares:
secure-headers:
headers:
frameDeny: true
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 63072000
customFrameOptionsValue: SAMEORIGIN
referrerPolicy: "strict-origin-when-cross-origin"
customRequestHeaders:
X-Forwarded-Proto: https
X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex,"
with proxy set as external network.
Keycloak
I've setup the client as follows and configured the Credentials with basic Client Id and Secret (no signed JWT).

When checking the events, only thing that seems different is that the first the IP address being used. The LOGIN action uses the client IP, whereas the CODE_TO_TOKEN action uses the IP of the docker container.

No idea if this is related to the token verification failing.
Also the sessions seems to get set correctly with the user ip.

Debug
After trying to debug the authentication progress, I noticed that the access_token that is returned after login, is the same as the one being used to get the users info. So it hasn't changed or anything (which I could image causing the error).
It's already been 3 days of (trying) to debug this but I'm just getting nowhere. Seems there are literally no similar cases to be found which could help me debug this further. I don't know if I'm missing something obvious, but I'm out of ideas now. Any bit of help is appreciated.