How to Log in Users with OpenID Connect
OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0. It is what powers the "Log in with ..." buttons that delegate authentication to an identity provider such as Keycloak, Authentik, Auth0, Okta or Microsoft Entra ID.
The oidc_login authenticator implements the OIDC Authorization Code Flow,
the interactive flow a web application needs:
- A user opens a protected page and Symfony redirects them to the identity provider;
- The user authenticates there, and the provider redirects them back to your application with an authorization code;
- The authenticator exchanges that code for tokens, validates the ID token, reads the user's claims and starts a session.
Note
This authenticator is different from the oidc and oidc_user_info
token handlers of the access token authenticator.
Those validate a token that the client already holds, which is useful for
APIs; they don't redirect the browser and don't start a session.
8.2
The oidc_login authenticator was introduced in Symfony 8.2.
Installation
The authenticator talks to the provider with the HttpClient component, and validates the ID token with the web-token/jwt-library package. Install both first:
1
$ composer require symfony/http-client web-token/jwt-library
1) Configure the Authenticator
Enable oidc_login in the firewall. Every client needs three options: the
issuer URL of your provider, the client identifier that the provider issued to
your application, and how your application authenticates at the token endpoint
of the provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
# config/packages/security.yaml
security:
providers:
oidc_provider_name:
oidc: ~
firewalls:
main:
provider: oidc_provider_name
oidc_login:
provider_uri: '%env(OIDC_PROVIDER_URI)%'
client_id: '%env(OIDC_CLIENT_ID)%'
client_authentication:
client_secret_basic: '%env(OIDC_CLIENT_SECRET)%'
scope: ['openid', 'profile', 'email']
provider_uri is the issuer URL (e.g. https://accounts.example.com). The
authenticator fetches .well-known/openid-configuration from it to discover
the authorization, token and UserInfo endpoints, so you don't have to configure
them. The discovery document is cached for one hour by default (change it with
the discovery_cache_ttl option).
client_id identifies your application to the provider. The authenticator
sends it in the authorization request and checks the aud claim of the ID
token against it.
client_authentication defines how your application authenticates at the
token endpoint when it exchanges the authorization code for tokens.
client_secret_basic sends the client secret issued by the provider; the
other methods are explained in How to Log in Users with OpenID Connect.
The provider redirects users back to check_path, which is /oidc/callback
by default. Register the full URL of this path as a redirect URI in the provider
(e.g. https://example.com/oidc/callback).
2) Import the OIDC Routes
The callback path needs a route; otherwise, the router returns a 404 error before the firewall can handle the redirect of the provider. Symfony defines this route (and the route that starts the flow, explained in the next section) in a route loader that your application must import, as it does for the logout routes. If your project uses Symfony Flex, the SecurityBundle recipe already imports it. Otherwise, import it yourself:
1 2 3 4
# config/routes/security.yaml
_security_oidc_login:
resource: security.authenticator.oidc_login.route_loader
type: service
The loader names the routes after the firewall:
_oidc_login_callback_<firewallname> and _oidc_login_start_<firewallname>
(e.g. _oidc_login_callback_main). Use these names to generate their URLs.
If check_path or start_path is a route name instead of a path, the
loader doesn't define a route for it, so you must define it yourself. Two
firewalls can share the same callback path, but not the same start path,
because the start route is tied to the firewall whose flow it starts.
3) Start the Flow
When oidc_login is the only authenticator of the firewall that can start
the authentication, it also becomes the
entry point of the firewall: when an anonymous
user requests a protected page, Symfony redirects them to the authorization
endpoint of the provider. You don't need anything else to log in users.
If the firewall uses other authenticators that can start the authentication
(e.g. form_login), set the entry_point option of the firewall to the one
you want to use; otherwise, Symfony throws an exception when compiling the
container.
To display a "Log in with ..." button (e.g. in a login page that lists several
ways to log in), link to the _oidc_login_start_<firewallname> route imported
in the previous section. This route redirects to the provider. Its path is
/oidc/start by default, and you can change it with the start_path
option:
1
<a href="{{ path('_oidc_login_start_main') }}">Log in with Example</a>
Don't link to check_path, because it only handles the redirect coming back
from the provider and rejects any request that doesn't include an authorization
code.
Note
The authenticator stores the state, the nonce and the PKCE verifier
of the flow in the session, so it doesn't work in stateless firewalls or
when the session is disabled.
When the Login Fails
When the authenticator rejects the callback (e.g. because the state expired
or doesn't match, the provider returned an error instead of a code, or the
ID token isn't valid), Symfony redirects the user to failure_path. If this
option is not set, it uses login_path, which is /login by default.
Applications that only log in users with OIDC usually don't have that page,
so set one of these options to a page of your application to avoid a 404
error on failed logins:
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
failure_path: app_login_failed
Symfony stores the authentication exception in the session, so the controller
of that page can get it with the getLastAuthenticationError() method of
AuthenticationUtils,
as login form controllers do.
4) Load the User
After validating the ID token, the authenticator fetches the user claims from
the UserInfo endpoint of the provider. Then, the user provider of the firewall
loads the user using the sub claim as the identifier, and receives all the
claims as attributes. You can change both the source of the claims and the
identifier claim, as explained later in this section.
The built-in oidc user provider shown above creates an
OidcUser object from those
claims. It's a convenient way to get started, but it has two limitations:
- Claims can't grant roles or define the identity of the user: all users get
ROLE_USER, and therolesanduser_identifierclaims sent by the identity provider are ignored; - It can't load users by their identifier alone, so it doesn't work with user impersonation.
To map claims to your own roles or to load users from your own storage, create
a custom user provider. If it implements
AttributesBasedUserProviderInterface,
it receives the claims as the second argument of loadUserByIdentifier():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// src/Security/OidcUserProvider.php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\AttributesBasedUserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @implements AttributesBasedUserProviderInterface<User>
*/
class OidcUserProvider implements AttributesBasedUserProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
// $identifier is the "sub" claim by default; $attributes has all claims
public function loadUserByIdentifier(
string $identifier,
array $attributes = [],
): UserInterface {
$user = $this->entityManager->getRepository(User::class)
->findOneBy(['oidcSubject' => $identifier]) ?? new User($identifier);
$isAdmin = \in_array('admins', $attributes['groups'] ?? [], true);
$user->setEmail($attributes['email'] ?? null);
$user->setRoles($isAdmin ? ['ROLE_ADMIN'] : []);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
// ...
}
Use the scope option to request the claims you need. The authenticator
always requests the openid scope, because OIDC requires it; add scopes like
profile or email to get their related claims.
The authenticator stores the tokens returned by the provider as attributes of the security token, so you can use them to call the APIs of the provider:
1 2 3 4 5 6
$token = $security->getToken();
$accessToken = $token->getAttribute('oidc_access_token');
$idToken = $token->getAttribute('oidc_id_token');
$refreshToken = $token->getAttribute('oidc_refresh_token');
$expiresAt = $token->getAttribute('oidc_access_token_expires_at');
The last two attributes are null when the provider doesn't return them.
Providers only issue a refresh token when you request it (on most providers,
with the offline_access scope), and expires_in is optional in the token
response. Read How to Log in Users with OpenID Connect to learn how to renew the
access token using the refresh token.
Reading the Claims from the ID Token
By default, the claims are fetched from the UserInfo endpoint. However, some
providers include all the requested claims in the ID token, and some don't
provide a UserInfo endpoint at all. Set user_data_source to id_token to
read the claims from the validated ID token instead. In this case, the provider
doesn't need to announce a userinfo_endpoint:
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
user_data_source: id_token
In both cases, the ID token must include a valid sub claim. When the claims
come from the UserInfo endpoint, their sub claim must be the same as the one
of the ID token (OIDC Core 1.0, Section 5.3.2).
Identifying Users by Another Claim
By default, the user identifier is the sub claim, which is the only claim
that OIDC guarantees to be stable and unique for each user. If your application
identifies users with another claim (e.g. email), set it in the
user_identifier_claim option. This claim is read from the same source as
the other claims, and the authentication fails when it's missing, empty or not
a string:
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
user_identifier_claim: email
Warning
Anyone who can change the value of that claim in the provider can log in as the matching user of your application. Only use claims that the provider guarantees to be unique, verified and stable. For example, don't use an email address that users can change themselves or that the provider doesn't verify.
The built-in oidc user provider uses that value as the user identifier,
and custom user providers receive it as the first argument of
loadUserByIdentifier().
Customizing the Authorization Request
The authenticator manages the parameters required by the flow:
response_type, client_id, redirect_uri, scope, state,
nonce, max_age and the PKCE parameters. Symfony throws an exception if
you try to set any of them. Add any other parameter supported by your provider
in the authorization_params option:
1 2 3 4 5 6 7 8 9
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
authorization_params:
prompt: 'consent'
ui_locales: 'fr-FR'
OIDC Core 1.0, Section 3.1.2.1 defines the prompt, display,
ui_locales, acr_values and login_hint parameters for this request.
Providers can also support other custom parameters.
Computing the Parameters per Request
The authorization_params option defines static values. If some parameter
depends on the current request (e.g. a ui_locales value based on the current
locale or a login_hint value stored in the session), listen to the
OidcAuthorizationRequestEvent,
which is dispatched right before redirecting the user to the provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
// src/Security/OidcAuthorizationRequestListener.php
namespace App\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\OidcAuthorizationRequestEvent;
#[AsEventListener]
final class OidcAuthorizationRequestListener
{
public function __invoke(OidcAuthorizationRequestEvent $event): void
{
$request = $event->getRequest();
$event->setParam('ui_locales', $request->getLocale());
if ($email = $request->getSession()->get('login_email')) {
$event->setParam('login_hint', $email);
} else {
$event->removeParam('login_hint');
}
}
}
The event initially contains the parameters configured in
authorization_params. The setParam() and removeParam() methods
change a single parameter, so several listeners can change their own parameters
without overwriting the changes made by others. The setParams() method
replaces all parameters. If the same listener is used in several firewalls,
call getFirewallName() to know which firewall is starting the flow.
As with the authorization_params option, Symfony throws an exception if you
try to set any of the parameters managed by the authenticator.
Tip
Listeners registered with #[AsEventListener] listen to the global event
dispatcher, so they are called for all firewalls. To listen to the events of
a single firewall, register the listener in the event dispatcher of that
firewall, as explained in Security.
Requiring a Recent Authentication
The max_age option defines the maximum time, in seconds, since the user last
authenticated in the provider. If more time has passed, the provider must ask
the user to authenticate again. This is a separate option (instead of an item
of authorization_params) because the authenticator also checks it: the ID
token must include an auth_time claim, which is validated against this
value, using allowed_time_drift as the only tolerance:
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
max_age: 300
The security token also stores the authentication details reported by the provider:
- The
auth_timeclaim is used as the moment when the user authenticated (and it's never later than the moment when the security token is created). Without this claim, the moment of the login is used instead; - The
amrclaim is used as the authentication methods used by the provider (e.g.pwd,otp,mfa, etc. as defined in RFC 8176). Without this claim, the authentication method is recorded as unspecified.
This way, max_age and the IS_AUTHENTICATED_RECENTLY and
IS_AUTHENTICATED_VERY_RECENTLY attributes use the same moment as the last
time the user authenticated. The recent_authentication_lifetime and
very_recent_authentication_lifetime options of the firewall define for how
long those two attributes are granted.
Asking the Provider to Authenticate the User Again
When access is denied because the user doesn't have the
IS_AUTHENTICATED_RECENTLY or IS_AUTHENTICATED_VERY_RECENTLY attribute,
the authenticator starts a new authorization request. If oidc_login is the
entry point of the firewall, it's also its re-authentication entry point, so you
don't need to configure anything. Otherwise, set the
re_authentication_entry_point option of the firewall to the
security.authenticator.oidc_login.<firewallname> service.
This new request includes two parameters:
prompt=login, which asks the provider to prompt the user for their credentials again instead of reusing its existing session (OIDC Core 1.0, Section 3.1.2.1);id_token_hintwith the previous ID token, so the provider knows which user is re-authenticating instead of displaying an account selector.
Both parameters are applied after authorization_params and after the
OidcAuthorizationRequestEvent listeners. This prevents a configured
prompt=none value or a listener that removes the parameter from disabling
the re-authentication.
Tip
The specification only recommends providers to follow the prompt
parameter (it's a "SHOULD", not a "MUST"). If you need to enforce the
re-authentication, also configure the max_age option. Symfony verifies
this value, so if the provider ignores it, the auth_time check fails
instead of accepting the old authentication.
Using PKCE
The authenticator applies PKCE (RFC 7636) to all authorization requests,
using the S256 challenge method: it sends the hash of a random verifier
stored in the session, and it only sends the verifier itself when exchanging the
authorization code. This way, nobody else can exchange an intercepted code.
All modern providers should support PKCE. Use the pkce option only for
providers that reject the code_challenge parameter (set enabled to
false) or that only support the plain method:
1 2 3 4 5 6 7 8 9
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
pkce:
enabled: true
method: 'plain'
Public clients can't disable PKCE, because nothing else would tie the authorization code to them.
Authenticating at the Token Endpoint
When exchanging the authorization code for tokens, your application
authenticates at the token endpoint using the method configured in
client_authentication. This option must define exactly one of the
following keys:
client_secret_basic- Sends the client secret as HTTP Basic credentials. This is the method recommended by RFC 6749, Section 2.3.1, so use it when the provider supports it.
client_secret_post- Sends the client secret in the body of the token request. Use it only for providers that don't support any other method.
client_secret_jwtandprivate_key_jwt- Sign a JWT assertion instead of sending the secret (see How to Log in Users with OpenID Connect).
none-
Declares a public client: an application that can't keep a secret (e.g.
a single-page, mobile or native application). It relies on PKCE to protect
the code exchange, and it can't disable PKCE or the ID token signature
verification.
client_authentication: noneis the short form. id-
The id of a service that implements a custom authentication method (see
How to Log in Users with OpenID Connect). Any string other than
noneis the short form (e.g.client_authentication: app.tls_client_auth).
Use the method registered for your application in the provider. Providers list
the methods they support in the token_endpoint_auth_methods_supported entry
of their discovery document. For example, to send the secret in the body of the
request:
1 2 3 4 5 6 7 8
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
client_authentication:
client_secret_post: '%env(OIDC_CLIENT_SECRET)%'
To declare a public client, set client_authentication to none:
1 2 3 4 5 6 7 8
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
provider_uri: '%env(OIDC_PROVIDER_URI)%'
client_id: '%env(OIDC_CLIENT_ID)%'
client_authentication: none
Signing a JWT Assertion
Instead of sending the secret, the client can authenticate with a signed JWT
assertion (RFC 7523), using the private_key_jwt or client_secret_jwt
methods of OIDC Core 1.0, Section 9.
private_key_jwt signs the assertion with a private key of the client. The
provider only knows the public key (registered as the jwks of the client or
published at its jwks_uri), so it can't use it to authenticate as the
client:
1 2 3 4 5 6 7 8 9 10 11
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
client_authentication:
private_key_jwt:
key: '%env(OIDC_CLIENT_SIGNING_KEY)%'
algorithm: 'ES256'
lifetime: 60
key is the JSON-encoded JWK of the private key (you can also pass this value
directly as a string to private_key_jwt). If the client publishes several
keys, add a kid to the JWK so the provider knows which key verifies the
signature. algorithm defaults to RS256 and accepts the RS*, PS*
and ES* algorithms (FAPI 2.0 requires PS256 or ES256).
client_secret_jwt signs the assertion with an HMAC that uses the client
secret as the key, so the secret is never sent:
1 2 3 4 5 6 7 8
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
client_authentication:
client_secret_jwt: '%env(OIDC_CLIENT_SECRET)%'
The string value sets only the secret. To also set algorithm and
lifetime, use the secret, algorithm and lifetime keys.
algorithm defaults to HS256 and also accepts HS384 and HS512.
As required by RFC 7518, Section 3.2, the secret must be at least as long as
the output of the algorithm: 32 bytes for HS256, 48 for HS384 and 64 for
HS512.
Tip
Prefer private_key_jwt over client_secret_jwt. With
client_secret_jwt, the provider also knows the secret, so it could sign
assertions in the name of the client.
In both methods, lifetime defines for how many seconds the assertion is
valid. It defaults to 60. Keep it short: each assertion is created for a
single request and sent immediately, and providers that don't track the jti
claim would accept a stolen assertion during all that time. The algorithm
must be one of those listed by your provider in the
token_endpoint_auth_signing_alg_values_supported entry of its discovery
document.
Using Your Own Authentication Method
To use a client authentication method not provided by Symfony (e.g. the
tls_client_auth method of RFC 8705, Section 2), create a service that
implements
ClientAuthenticationInterface.
Its authenticate() method receives the HttpClient options of the token
request and returns them with the client authentication added. Its
getMethod() method returns the name of the method as defined in
RFC 7591, Section 2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// src/Security/TlsClientAuthentication.php
namespace App\Security;
use Symfony\Component\Security\Http\OAuth2\ClientAuthentication\ClientAuthenticationInterface;
final class TlsClientAuthentication implements ClientAuthenticationInterface
{
public function __construct(
private string $certificatePath,
private string $privateKeyPath,
) {
}
public function authenticate(
string $clientId,
string $tokenEndpoint,
array $options,
): array {
// the TLS client certificate authenticates the client, which must
// also include its ID in the request body (RFC 8705, Section 2)
$options['body']['client_id'] = $clientId;
$options['local_cert'] = $this->certificatePath;
$options['local_pk'] = $this->privateKeyPath;
return $options;
}
public function getMethod(): string
{
return 'tls_client_auth';
}
}
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
client_authentication: app.tls_client_auth
If getMethod() returns none, the rules of public clients also apply to
this service.
Verifying the ID Token Signature
The authenticator verifies the signature of the ID token by default. It gets
the signing keys from the jwks_uri announced by the provider in its
discovery document (this URL must use HTTPS, like all the other endpoints). The
keys are cached for the lifetime advertised by the provider in that response
(up to 30 days), or for discovery_cache_ttl seconds if the provider doesn't
advertise any lifetime. If an ID token is signed with a key that isn't in the
cache, the authenticator fetches the keys again, so you don't have to do
anything when the provider rotates its keys.
By default, only the RS256 algorithm is accepted, because it's the only one
that OIDC providers are required to support. If your provider uses other
algorithms, list them in the algorithms option:
1 2 3 4 5 6 7 8
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
id_token_signature:
algorithms: ['ES256']
The accepted algorithms are RS256, RS384, RS512, ES256,
ES384, ES512, PS256, PS384 and PS512. Your provider lists
the algorithms it uses in the id_token_signing_alg_values_supported entry
of its discovery document. HMAC algorithms are not accepted, to prevent using
a public key of the provider as a shared secret.
By default, only the keys that the provider explicitly designates for signing
are used. Set id_token_signature.enforce_key_usage_verification to false
to also accept keys that don't define any usage. Keys that are restricted to
encryption are always rejected. This option works the same as in the
OIDC access token handler.
Warning
If you set id_token_signature.required to false, the ID token is
decoded without verifying its signature. OIDC Core 1.0, Section 3.1.3.7
only allows this because the token comes directly from the token endpoint
over TLS. This makes the ID token only as trustworthy as the TLS
verification of the HTTP client used in that request. Never disable it
when the HTTP client uses verify_peer: false or verify_host: false,
or when the requests go through a proxy that terminates TLS. Public
clients can't disable this verification.
Configuring the HTTP Client
The authenticator makes all its requests to the provider (discovery document,
JWKS, token endpoint and UserInfo endpoint) with the http_client service.
Some providers require a User-Agent header that identifies your application
(and use it for quotas and diagnostics), and you might also want to define your
own timeout or retry strategy. Use the http_client option to set the id of
the HTTP client used in all those requests:
1 2 3 4 5 6 7
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
http_client: oidc.client
oidc.client can be any service that implements
HttpClientInterface. Usually, it's a
scoped client (see HTTP Client):
1 2 3 4 5 6 7 8 9
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
oidc.client:
scope: 'https://accounts\.example\.com'
headers: { 'User-Agent': 'AcmeApp/1.0 (+https://example.com)' }
timeout: 5
retry_failed: { max_retries: 2 }
Warning
The scope of the scoped client must match all the endpoints, not only
the issuer. The requests are sent to the URLs announced by the provider in
its discovery document, which are often on other hosts. For example, the
issuer of Google is accounts.google.com, but it uses
oauth2.googleapis.com for its token endpoint, www.googleapis.com
for its JWKS and openidconnect.googleapis.com for its UserInfo
endpoint. The options of a scoped client only apply to the URLs that match
its scope, and Symfony doesn't warn you about it. If the scope only matches
the issuer, the options apply only to the discovery request, and the other
three requests use the default options.
If the provider uses several hosts for its endpoints, create the client with withOptions() instead, so its options apply to all requests, whatever their host:
1 2 3 4 5 6 7 8
# config/services.yaml
services:
oidc.client:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: ['@http_client', 'withOptions']
arguments:
- headers: { 'User-Agent': 'AcmeApp/1.0 (+https://example.com)' }
timeout: 5
Warning
Never add the client credentials to this HTTP client (with auth_basic
or with a custom header). They would also be sent in the discovery, JWKS
and UserInfo requests, which don't need them. In addition, auth_basic
encodes the credentials in base64 as they are, whereas
client_secret_basic first applies form URL encoding to them, as
required by RFC 6749, Section 2.3.1. The client_authentication
option only sends the credentials to the token endpoint, and in the format
required by the specification.
Note
These four requests are always made with max_redirects set to 0,
whatever the configuration of the HTTP client. Otherwise, a redirect
returned by the provider could send the client credentials, the
authorization code or the access token to any other URL.
Renewing the Access Token
Access tokens are short-lived (they usually expire after a few minutes). To
keep calling the APIs of the provider on behalf of the logged-in user, Symfony
can renew the access token with the refresh token grant defined in
RFC 6749, Section 6.
Providers only issue a refresh token when you request it, so add the scope
required by your provider (offline_access on most providers) to the
scope option.
Renewing the Token on Demand
The OidcTokenRefresher
class renews the tokens of logged-in users. Symfony registers it as the
security.authenticator.oidc_login.token_refresher.<firewallname> service
for each firewall that uses oidc_login (even if the automatic renewal
explained later is disabled). This service doesn't have an autowiring alias, so
inject it using its id:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// src/Service/ProviderApiClient.php
namespace App\Service;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Security\Http\Authenticator\Oidc\OidcTokenRefresher;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ProviderApiClient
{
public function __construct(
#[Autowire(
service: 'security.authenticator.oidc_login.token_refresher.main',
)]
private OidcTokenRefresher $refresher,
private Security $security,
private HttpClientInterface $client,
) {
}
public function fetchProfile(): array
{
$token = $this->security->getToken();
if (null === $token) {
throw new \LogicException('The API requires a logged-in user.');
}
$this->refresher->refreshIfNeeded($token);
return $this->client->request('GET', 'https://api.example.com/me', [
'auth_bearer' => $token->getAttribute('oidc_access_token'),
])->toArray();
}
}
The refreshIfNeeded() method renews the tokens only if the access token
expires within the configured leeway, and returns true if it renewed them.
It does nothing if the security token doesn't include a refresh token or if the
expiration time of the access token is unknown. The refresh() method always
renews the tokens, whatever their expiration time.
Both methods replace the attributes of the security token with the new values.
If the provider returns a new ID token, it's validated before storing anything:
its signature (if the firewall verifies signatures), its iss, aud,
exp, iat and nbf claims, and its sub claim, which must be the
same as the one of the login. If the ID token is rejected, the security token
doesn't change. If the provider returns a new refresh token, it replaces the
previous one; otherwise, the previous refresh token is kept.
When the provider returns the invalid_grant OAuth 2.0 error, both methods
throw an
OidcInvalidGrantException.
This means that the refresh token can no longer be used, so catch this
exception to make the user log in again. Any other error (the provider is not
available, it returns a 5xx error, the request times out, etc.) throws an
AuthenticationException,
so you can try again later.
Renewing the Token Automatically
Enable the refresh_access_token option to renew the access token
automatically when it's about to expire. This option is disabled by default
because it sends a request to the token endpoint while handling the request of
the user, and because it can log out the user:
1 2 3 4 5 6 7 8 9 10
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
scope: ['openid', 'profile', 'offline_access']
refresh_access_token:
enabled: true
leeway: 30
When this option is enabled, a listener runs right after the firewall loads the
security token from the session, so the current request and the rest of the
session use the renewed tokens. If the provider returns an invalid_grant
error, the security token is removed and the user is logged out. Any other
error logs a warning and keeps the current tokens, so the renewal is tried again
in the next request. This way, if the provider is temporarily unavailable, your
users are not logged out.
Warning
The automatic renewal requires the expires_in value in the token
response. If the provider doesn't return it, the access token is never
renewed automatically, because its expiration time is unknown.
Warning
Providers that rotate refresh tokens expect each refresh token to be used only once. If your session handler doesn't lock the session, two concurrent requests of the same user could use the same refresh token twice, and the provider would then revoke all the tokens of that user. The default session handler locks the session, so it doesn't have this problem.
Logging Out
Logging out works as usual. By default, it only ends the session in your application: the user is still logged in to the identity provider, so the next time they open a protected page, they are logged in again without being asked for their credentials.
Logging Out of the Provider
To also log out the user from the provider, enable RP-Initiated Logout with
the enable_end_session option. The authenticator then redirects the user to
the end_session_endpoint announced by the provider in its discovery
document, sending the ID token as id_token_hint. After closing its own
session, the provider redirects the user back to post_logout_redirect_path,
which is a path or route name of your application:
1 2 3 4 5 6 7 8
# config/packages/security.yaml
security:
firewalls:
main:
oidc_login:
# ...
enable_end_session: true
post_logout_redirect_path: /
Register the full URL of this post-logout path in the provider, as you did with
the check_path URL. Set post_logout_redirect_path to null to not
send any post_logout_redirect_uri; in this case, the provider decides where
to redirect the user.
If the provider can't be reached, or if it doesn't announce an
end_session_endpoint that uses HTTPS, the user is only logged out from your
application and a warning is logged.
Warning
With this option, logging out becomes a chain of redirects: first to your application, which closes its session; then to the provider, which closes its own session; and finally back to your application. Symfony UX Turbo intercepts clicks on the logout link and follows the redirects itself, which doesn't work with this chain: your application closes its session, but the redirect to the provider never happens. The user is still logged in to the provider, so they are logged in again when opening the next protected page.
Add the data-turbo="false" attribute to the logout link so Turbo
doesn't handle it:
1
<a href="{{ path('_logout_main') }}" data-turbo="false">Log out</a>
A logout form (see Security) needs the same attribute
on its <form> element.
Security Considerations
The authenticator implements all the checks required by the specifications. You can only disable PKCE and the ID token signature verification, and public clients can't disable either of them. These are the main checks:
provider_uriand all the endpoints announced in the discovery document must use HTTPS. For local development, loopback hosts (localhost,127.0.0.1,::1) and*.localhosthosts are also accepted;- The
issuerof the discovery document must matchprovider_uri; - All authorization requests include a
stateparameter, which is checked in the callback against the value stored in the session. This prevents login CSRF attacks; - The
issauthorization response parameter (RFC 9207) is checked against the issuer, and it's required when the provider announcesauthorization_response_iss_parameter_supported. This prevents mix-up attacks against clients registered with several providers; - All authorization requests include a
nonceparameter, which is checked against thenonceclaim of the ID token. This ties the ID token to the authorization request; - PKCE (RFC 7636) is applied with the
S256challenge method (unless thepkceoption changes it), so an intercepted authorization code can't be exchanged by anyone else; - The ID token signature is verified with the keys published by the provider
(unless
id_token_signature.requiredis disabled); - The parameters managed by the authenticator can't be overridden with
authorization_paramsor withOidcAuthorizationRequestEventlisteners. Theprompt=loginandid_token_hintparameters of a re-authentication are applied after both of them, so neither can disable the re-authentication; - The
iss,aud,exp,iatandsubclaims of the ID token are required and validated. Theazpandnbfclaims are also validated when the provider includes them.allowed_time_driftis the only tolerance applied to time claims; - When the claims are fetched from the UserInfo endpoint, their
subclaim must be the same as the one of the ID token; - The user identifier is the
subclaim. Ifuser_identifier_claimsets another claim, that claim must be present and not empty; - No redirects are followed in the discovery, JWKS, token and UserInfo requests;
- The built-in
oidcuser provider doesn't allow claims to grant roles.
Configuration Reference
provider_uri(required)-
The issuer URL of the OIDC provider, used for
.well-known/openid-configurationdiscovery. Must use HTTPS. client_id(required)- The client identifier issued by the provider.
client_authentication(required)-
How the application authenticates at the token endpoint:
client_secret_basicorclient_secret_post(with the client secret),client_secret_jwtorprivate_key_jwt(with an assertion signed by the client),none(for public clients) orid(with the id of a service that implementsClientAuthenticationInterface). Any string value other thannoneis considered a service id. client_authentication.client_secret_jwt.secret(required)-
The client secret used as the key of the HMAC that signs the assertion.
Passing a string to
client_secret_jwtsets this value. client_authentication.client_secret_jwt.algorithm(default:HS256)-
The MAC algorithm used to sign the assertion:
HS256,HS384orHS512. client_authentication.client_secret_jwt.lifetime(default:60)- For how many seconds the assertion is valid.
client_authentication.private_key_jwt.key(required)-
The JSON-encoded JWK of the private key used to sign the assertion.
Passing a string to
private_key_jwtsets this value. client_authentication.private_key_jwt.algorithm(default:RS256)-
The algorithm used to sign the assertion:
RS256,RS384,RS512,ES256,ES384,ES512,PS256,PS384orPS512. client_authentication.private_key_jwt.lifetime(default:60)- For how many seconds the assertion is valid.
http_client(default:http_client)- The id of the HTTP client used in all requests made to the provider (discovery document, JWKS, token endpoint and UserInfo endpoint).
scope(default:['openid'])- The scopes of the authorization request, as a list or as a space-separated string.
user_data_source(default:userinfo)-
Where to read the user claims from:
userinfofetches them from the UserInfo endpoint andid_tokenreads them from the validated ID token. user_identifier_claim(default:sub)-
The claim that contains the user identifier.
subis the only claim that OIDC guarantees to be stable and unique for each user. check_path(default:/oidc/callback)- The path where the provider redirects users after authenticating them. You can also use a route name; in that case, no route is defined for it.
start_path(default:/oidc/start)- The path of the route that starts the flow by redirecting to the provider. You can also use a route name; in that case, no route is defined for it.
enable_end_session(default:false)-
Whether logging out also redirects the user to the
end_session_endpointof the provider (RP-Initiated Logout). post_logout_redirect_path(default:/)-
The path or route name of your application where the provider redirects
users after RP-Initiated Logout. It's sent as
post_logout_redirect_uri; set it tonullto not send it. It's ignored unlessenable_end_sessionistrue. refresh_access_token.enabled(default:false)- Whether to renew the access token automatically, using the refresh token, before it expires.
refresh_access_token.leeway(default:30)- How many seconds before its expiration the access token is renewed.
id_token_signature.required(default:true)- Whether to verify the signature of the ID token using the keys published by the provider.
id_token_signature.algorithms(default:['RS256'])- The signature algorithms accepted for the ID token.
id_token_signature.enforce_key_usage_verification(default:true)- Whether to only use the keys that the provider designates for signing to verify the ID token.
pkce.enabled(default:true)- Whether to protect the authorization code with PKCE.
pkce.method(default:S256)-
The PKCE code challenge method:
S256orplain. max_age(no default value)-
The maximum time, in seconds, since the user last authenticated in the
provider. When set, it's sent as the
max_ageauthorization parameter and checked against theauth_timeclaim of the ID token. authorization_params(default:[])-
Additional parameters of the authorization request, such as
promptorlogin_hint. discovery_cache_ttl(default:3600)- For how many seconds the discovery document is cached. It's also used for the keys of the provider when it doesn't advertise their lifetime.
allowed_time_drift(default:0)- The clock skew, in seconds, allowed when validating the time claims of the ID token.
This authenticator also accepts the options shared by all firewall
authenticators: success_handler and failure_handler (to replace the
default handlers with your own services), and login_path,
default_target_path, always_use_default_target_path,
target_path_parameter, use_referer, failure_path and
failure_path_parameter (to configure the default handlers), as explained in
Security Configuration Reference (SecurityBundle).