Mit [irgendwas] einloggen: Der native OIDC-Login kommt in Symfony 8.2
Symfony 8.2, die nächste Minor-Version von Symfony, bringt von Haus aus einen nativen OIDC-Authenticator für den Authorization Code Flow mit. Hier erfährst du, was er kann, warum er standardmäßig sicher ist und was ich bei der Entwicklung gelernt habe.
Bevor wir tief in dieses neue Feature eintauchen, ein paar Worte zu OIDC. OpenID Connect ist eine Identitätsschicht auf Basis von OAuth 2.0: Während OAuth 2.0 den Zugriff auf eine Ressource über ein Access Token gewährt, sagt dir OIDC, wer der Benutzer ist.
„Log in with Google“, „Log in with your company account“, „Log in with Keycloak“. Jede Webanwendung braucht irgendwann einen dieser Buttons, und die Symfony-Community bietet seit über einem Jahrzehnt Lösungen dafür. HWIOAuthBundle treibt Social- und Enterprise-Logins seit den Tagen von Symfony 2 an. KnpUOAuth2ClientBundle hat das gesamte Ökosystem von league/oauth2-client-Providern in das Security-System gebracht. Und drenso/symfony-oidc widmet sich speziell OIDC. Tausende von Anwendungen verbinden ihre Benutzer heute über diese Bundles, und ich möchte den Autoren danken, bevor wir weitermachen: Sie haben den Weg geebnet.
Ab Symfony 8.2 ist der OIDC-Baustein auch Teil des Frameworks selbst: ein Authenticator, den du wie jeden anderen konfigurierst, mit Sicherheits-Defaults direkt aus der Spezifikation und gepflegt an der Seite der Security-Komponente.
Entstehung
Symfony unterstützt OIDC seit Version 6.3, aber nur in einem einzigen Kontext: wenn die Symfony-Anwendung als API dient. In diesem Setup ist die Anwendung ein Resource Server. Sie spricht nie mit dem Identity Provider im Namen eines Benutzers: Ein Client, eine Single-Page Application (SPA) oder eine mobile App fordert ein Token direkt beim Provider an und übergibt es bei jedem Request über einen Authorization-Header. Der Authenticator access_token und seine drei Token Handler validieren dieses Token, entweder lokal über die JWK-Schlüssel des Providers oder durch Aufruf seines UserInfo-Endpoints, und mappen es auf einen Benutzer. Symfony prüft ein Token, das es nicht angefordert hat – und das ist alles, was eine API braucht.
Was fehlte, war der andere Kontext, die traditionelle Full-Stack-Webanwendung: serverseitig gerenderte Seiten, ein Session-Cookie, Benutzer, die sich über ihren Browser anmelden. Hier übergibt niemand ein Token an die Anwendung. Der Feature-Request war auf GitHub seit Juli 2023 offen, und der Bedarf ist überall: ein Backoffice hinter dem Keycloak des Unternehmens, ein SaaS-Angebot mit Enterprise-Login über Microsoft Entra ID, ein internes Tool hinter Authentik. Jedes Mal derselbe Flow, jedes Mal dieselben Prüfungen.
Ich habe mir also die Zeit genommen, ihn zu schreiben, mit einem einzigen Ziel vor Augen: Die Logik, die du mit wenigen Zeilen in der security.yaml erhältst, muss genau der Flow sein, den du von jemandem bekommen würdest, der die Spezifikationen von Anfang bis Ende gelesen hat. Schauen wir uns an, wie das aussieht.
Der Authorization Code Flow in 60 Sekunden
Zwei Parteien kommunizieren miteinander: deine Symfony-Anwendung als Relying Party und der Identity Provider. Der Flow läuft wie folgt ab:
Der Benutzer fordert eine geschützte Seite an. Die Anwendung generiert ein state, ein nonce und einen PKCE-verifier, bindet sie an die Session und leitet den Browser zum Authorization Endpoint des Providers weiter.
Der Benutzer authentifiziert sich beim Provider, der den Browser mit einem Authorization Code zur Callback-URL der Anwendung zurückleitet.
Die Anwendung prüft den state und tauscht dann den Code direkt über TLS mit dem Token Endpoint des Providers gegen Tokens aus, unter Vorlage ihrer Client Credentials und des PKCE-verifiers.
Der Provider antwortet mit einem ID Token und einem Access Token. Die Anwendung validiert das ID Token, seine Signatur und seine Claims, lädt den Benutzer und startet die Session.
Drei Redirects und ein Back-Channel-Aufruf. Die Herausforderung war nie der Happy Path. Es ist alles drum herum: welche Claims zu prüfen sind, was mit dem nonce zu tun ist, wie sichergestellt wird, dass der empfangene Code der angeforderte ist, wie man einem Discovery-Dokument nicht blind vertraut. Das ist der Teil, den das Framework jetzt für dich übernimmt.
5 Minuten bis zum ersten Login
Der Authenticator nutzt die Komponente HttpClient für den Austausch mit dem Provider und web-token/jwt-library zur Validierung des ID Tokens:
composer require symfony/http-client web-token/jwt-library
Deklariere anschließend einen User Provider oidc und den Authenticator oidc_login in deiner Firewall. Ein vertraulicher Client (confidential client), was eine klassische serverseitig gerenderte Anwendung ist, benötigt drei Werte: die Issuer-URL deines Providers sowie die deiner Anwendung zugewiesene client_id und das client_secret.
# config/packages/security.yaml
security:
providers:
oidc_users:
oidc: ~
firewalls:
main:
provider: oidc_users
oidc_login:
provider_uri: '%env(OIDC_PROVIDER_URI)%'
client_id: '%env(OIDC_CLIENT_ID)%'
client_secret: '%env(OIDC_CLIENT_SECRET)%'
scope: ['openid', 'profile', 'email']Beachte, was hier nicht steht: kein Authorization Endpoint, kein Token Endpoint, keine JWKS-URL. Der Authenticator ruft das Standarddokument .well-known/openid-configuration beim Issuer ab, entdeckt dort alles Erforderliche und speichert es für eine Stunde (konfigurierbar) im Cache.
Der Provider benötigt eine Route für den Redirect. Symfony deklariert diese für dich über einen Route Loader, genau wie bei den Logout-Routen, und das Rezept von symfony/security-bundle importiert sie automatisch:
# config/routes/security.yaml
_oidc_login_callbacks:
resource: security.authenticator.oidc_login.route_loader
type: serviceDer Callback liegt standardmäßig auf /oidc/callback, und mit der Option check_path kannst du ihn anpassen. Registriere die vollständige URL bei deinem Provider – fertig: Wenn oidc_login der einzige Authenticator der Firewall ist, der eine Authentifizierung starten kann, wird er auch zum Entry Point, sodass ein anonymer Request auf eine geschützte Seite direkt zum Provider umleitet.
Wenn deine Login-Seite mehrere Authentifizierungsmethoden anbietet, deklariert derselbe Route Loader eine Standard-Startroute oidclogin_start_<firewall_name> unter dem Pfad /oidc/start, die den Flow auf Anfrage startet:
<a href="{{ path('_oidc_login_start_main') }}">Log in with Keycloak</a>
Das ist die gesamte Konfiguration. Zeige mit OIDC_PROVIDER_URI auf einen Keycloak-Realm in Docker, und du hast einen funktionierenden „Log in with“-Button, bevor dein Kaffee kalt wird.
Standardmäßig sicher – und das ist kein Zufall
Das ist der Abschnitt, der mir am wichtigsten ist, denn hier unterscheiden sich eine Eigenimplementierung und die Lösung, die ich hier vorstelle. Jede der folgenden Prüfungen ist standardmäßig aktiviert. Einige können angepasst werden, keine kann aus Versehen deaktiviert werden.
State und Nonce. Beide werden bei jedem Versuch generiert, in der Session gespeichert und bei der Rückkehr überprüft. Das State schützt den Callback vor CSRF, das Nonce bindet das ID Token an den Request, der es angefordert hat.
PKCE, immer. Proof Key for Code Exchange wird bei jedem Authorization Request mit der Methode S256 angewendet. Der Authenticator sendet den Hash eines zufälligen verifiers und gibt diesen verifier erst beim Eintauschen des Authorization Codes preis, sodass ein abgefangener Code für Dritte unbrauchbar ist. Du kannst für Provider, die nichts anderes unterstützen, auf den Plain-Modus umstellen oder ihn für Provider deaktivieren, die den Parameter ablehnen. Ein öffentlicher Client kann PKCE nicht deaktivieren, da es die einzige Bindung zwischen Code und Client darstellt.
Claims des ID Tokens.sub (subject), iss (issuer), aud (audience), exp (expires at) und iat (issued at) sind verpflichtend und werden geprüft; nbf (not before) und azp (authorized party) werden geprüft, wenn sie vorhanden sind, und auth_time wird verpflichtend und geprüft, sobald du max_age definierst. Uhren können abweichen, daher bietet dir allowed_time_drift eine Toleranz in Sekunden (Standard ist 0).
Signatur des ID Tokens. Wird standardmäßig gegen die vom Provider auf seiner jwks_uri veröffentlichten Schlüssel geprüft. Nur RS256 wird von Haus aus akzeptiert – der einzige Algorithmus, den die Spezifikation für Provider vorschreibt. Liste die Algorithmen auf, die deiner nutzt, falls er mit einem anderen signiert. Es werden niemals HMAC-Algorithmen akzeptiert, wodurch ein öffentlicher Schlüssel nie fälschlicherweise als Shared Secret verwendet werden kann. Die Schlüssel werden gecacht, und ein mit einem unbekannten Schlüssel signiertes Token löst einen neuen Fetch aus, sodass eine Schlüsselrotation beim Provider deinerseits kein Eingreifen erfordert.
HTTPS vom Discovery bis zu jedem Endpoint.provider_uri muss eine HTTPS-URL sein, mit einer Ausnahme für Loopback-Hosts (localhost, 127.0.0.1, ::1) und reservierte Test-Domains (*.localhost), damit du lokal entwickeln kannst. Die im Discovery-Dokument angegebenen Endpoints müssen ebenfalls HTTPS nutzen, und der angegebene Issuer muss mit dem von dir konfigurierten übereinstimmen, damit ein manipuliertes oder falsch konfiguriertes Dokument den Flow nicht schwächen kann. Der Token Endpoint hat keine lokale Ausnahme: Hier wird dein Client Secret übertragen, weshalb normales HTTP selbst auf localhost verweigert wird.
Keine Privilege Escalation über Claims. Der integrierte User Provider oidc liest niemals Rollen vom Provider aus: Jeder Benutzer erhält ROLE_USER, und jegliche vom Provider gesendete roles- oder user_identifier-Claims werden verworfen. Das Vergeben von Rollen basierend auf einem Gruppen-Claim ist eine Entscheidung deiner Anwendung, die in deinem eigenen User Provider getroffen wird, wie wir unten sehen werden.
Nichts davon ist exotisch. Es ist das, was die OIDC Core Spezifikation von einer Relying Party verlangt. Der Vorteil ist, dass du dich nicht mehr selbst daran erinnern musst.
Über den Standardfall hinaus
Reale Deployments beschränken sich selten auf das Minimalbeispiel. Der Authenticator enthält daher die Optionen, die du in der Praxis wirklich benötigst.
Deine eigenen User. Der integrierte User Provider ist der schnellste Weg zum Start, aber die meisten Anwendungen haben ihre eigene User-Entität. Jeder User Provider, der AttributesBasedUserProviderInterface implementiert, erhält den Identifier sowie sämtliche Claims und entscheidet, was damit zu tun ist:
// src/Security/OidcUserProvider.php
public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface
{
// $identifier ist der Claim "sub", $attributes enthält alle Claims
$user = $this->users->findOneBy(['oidcSubject' => $identifier]) ?? new User($identifier);
$user->setEmail($attributes['email'] ?? null);
$user->setRoles(\in_array('admins', $attributes['groups'] ?? [], true) ? ['ROLE_ADMIN'] : []);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}Woher die Claims kommen. Einige Provider packen alle angeforderten Claims in das ID Token, andere bieten gar keinen UserInfo-Endpoint. user_data_source: id_token liest sie direkt aus dem validierten ID Token.
Welcher Claim den Benutzer identifiziert.sub ist der einzige Claim, für den OIDC garantiert, dass er stabil und eindeutig ist. Werden deine Benutzer über einen anderen Claim identifiziert, übernimmt das user_identifier_claim: email – mit einem Hinweis in der Dokumentation, den du vor dem Wechsel lesen solltest: Wer diesen Claim beim Provider kontrolliert, kontrolliert das entsprechende Konto in deiner Anwendung.
Wie sich die Anwendung am Token Endpoint authentifiziert.token_endpoint_auth_method akzeptiert client_secret_post (Standard), client_secret_basic oder none. Letztere Option deklariert einen öffentlichen Client, der kein Secret besitzt; PKCE und die Signaturprüfung werden dann zwingend erforderlich, andernfalls verweigert der Container die Kompilierung.
Anpassen des Authorization Requests.max_age fordert vom Provider eine frische Authentifizierung an und prüft den Claim auth_time, den er dann zurückgeben muss. authorization_params übermittelt alles Weitere, was dein Provider versteht: prompt, ui_locales, login_hint, acr_values, während die Parameter, von denen der Flow abhängt, unter der Kontrolle des Authenticators bleiben. OidcAuthorizationRequestEvent erlaubt es, diese pro Request statt einmalig für die Firewall festzulegen – zum Beispiel für ein ui_locales basierend auf der aktuellen Locale.
Eine frische Authentifizierung erzwingen.IS_AUTHENTICATED_RECENTLY, neu in 8.2, schützt sensible Aktionen hinter einem kürzlich erfolgten Login. oidc_login benötigt keine Extra-Konfiguration, um darauf zu reagieren: Eine Ablehnung leitet den Benutzer mit prompt=login zum Provider zurück, und der Authenticator liest den Claim auth_time – eine Anmeldung aus einer alten Provider-Session gilt also nicht als frisch.
Logout auch beim Provider.enable_end_session: true leitet beim Logout zum end_session_endpoint des Providers weiter, und post_logout_redirect_path gibt an, wo der Benutzer danach landen soll.
Provider-APIs aufrufen. Das ID Token und das Access Token werden als Attribute des Security Tokens gespeichert, sodass dir getAttribute('oidc_access_token') ein Bearer Token für provider-spezifische Dienste liefert, das bei Ablauf über den Refresh Token Grant erneuert wird.
Getestet mit echten Providern
Spezifikationen sind das eine, reale Provider das andere. Bevor der Pull Request gemergt wurde, wurde der Authenticator Ende-zu-Ende mit Keycloak, Authelia, Microsoft Entra ID, Gravitee Access Management, Authentik und Google getestet. Seitdem hat er die Konformitätssuite der OpenID Foundation für das Basis-Zertifizierungsprofil der Relying Party erfolgreich durchlaufen.
Das hat sich sofort bezahlt gemacht. Authentik gibt seinen Issuer mit einem abschließenden Slash an. Die Spezifikation verlangt, dass der konfigurierte Issuer und der im Discovery-Dokument angegebene strikt identisch sein müssen. Der Authenticator entfernt den abschließenden Slash aus der konfigurierten URL, um die Discovery-URL zu bauen – folglich konnte kein Konfigurationswert übereinstimmen. Der Fix ignoriert den trailing Slash bei genau diesem Vergleich, und nur dort: Der iss-Claim jedes ID Tokens wird weiterhin Zeichen für Zeichen gegen den vom Provider angegebenen Issuer geprüft. Ein kleines Detail, aber die Art von Detail, die man erst entdeckt, wenn man den Code gegen echte Provider ausführt.
Was das Code-Review verändert hat
Die Reviews haben das Ergebnis mindestens genauso geprägt wie der initiale Code. Die Signaturprüfung war ursprünglich als Folgeaufgabe geplant, da das ID Token direkt über TLS vom Token Endpoint kommt. Das Review hat sich jedoch nachdrücklich dafür eingesetzt, dass sie vor dem Release von 8.2 standardmäßig geprüft wird – und genau das ist nun der Fall.
Die Regel, dass ein Provider niemals Rollen über einen Claim vergeben darf, stammt aus einem Sicherheitsaudit des Branches. Die JWKS-Antwort ist in der Größe beschränkt und wird ausschließlich über HTTPS abgerufen.
Andere Entwurfsentscheidungen haben das Review unverändert überstanden: die Wiederverwendung des bestehenden OidcUser anstelle eines neuen Benutzermodells sowie die Deklaration der Callback-Route über einen Loader, anstatt dich zum Schreiben eines Controllers zu zwingen.
Ein großes Dankeschön an Florent Morselli (Spomky), dessen tiefes Wissen über OpenID-Spezifikationen eine funktionierende Implementierung in eine präzise verwandelt hat, an Nicolas Grekas, der jeden Pull Request der Serie gereviewt und gemergt hat, während er das JWKS-Handling verstärkte, sowie an Yonel Ceruto, Robin Chalas, Alexandre Daubois und Steffen Gransow für ihre Reviews und Ihr Feedback.
Wie geht es weiter?
Symfony 8.2 deckt den Authorization Code Flow für einen Provider pro Firewall ab, was den Anforderungen der überwiegenden Mehrheit der Anwendungen entspricht. Das Design lässt Raum für die Zukunft, und mehrere Themen stehen auf meiner Liste: mehrere Provider hinter derselben Firewall, hybride Responsetypen, response_mode=form_post, signierte Request-Objekte (signed request objects), die Handhabung von session_state für vom Provider initiierten Logout und Dynamic Client Registration. Wenn dich einer dieser Punkte heute blockiert, lass es uns über den GitHub Issue Tracker wissen.
Weiterführende Links
Die Symfony-Dokumentation bietet eine eigene Seite, How to Log in Users with OpenID Connect, die jede hier erwähnte Option sowie weitere Details abdeckt. Wenn du den gesamten Flow mit verschiedenen Providern in Aktion sehen möchtest, klone das Demo-Repository und folge der README.