Symfony OAuth2 with Keycloak returns invalid_token: Token verification failed

Viewed 1004

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

image

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).

image

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. image

No idea if this is related to the token verification failing.

Also the sessions seems to get set correctly with the user ip. session started

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.

1 Answers
Related