flownative / openidconnect-client
An Open ID Connect (OIDC) client implementation for Neos Flow applications
Package info
github.com/flownative/flow-openidconnect-client
Type:neos-package
pkg:composer/flownative/openidconnect-client
Fund package maintenance!
Requires
- php: 8.3.* || 8.4.* || 8.5.*
- doctrine/orm: ^2.10
- flownative/oauth2-client: ^5.0
- guzzlehttp/guzzle: ^7.9
- lcobucci/jwt: ^5.6
- neos/flow: ~8.3.13 || ~8.4.0 || ^9.0
- phpseclib/phpseclib: ^3.0.52
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v6.1.1
- v6.1.0
- v6.0.1
- v6.0.0
- v5.2.2
- v5.2.1
- v5.2.0
- v5.1.0
- v5.1.0-beta1
- v5.0.3
- v5.0.2
- v5.0.1
- v5.0.0
- v5.0.0-beta.1
- v4.0.0
- v3.5.0
- v3.5.0-beta.1
- v3.4.6
- v3.4.5
- v3.4.4
- v3.4.3
- v3.4.2
- v3.4.1
- v3.4.0
- v3.3.5
- v3.3.4
- v3.3.3
- v3.3.2
- v3.3.1
- v3.3.0
- v3.2.3
- v3.2.2
- v3.2.1
- v3.2.0
- v3.1.0
- v3.0.1
- v3.0.0
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.1
- v2.2.0
- v2.1.0
- v2.0.0
- v2.0.0-beta.1
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2.0
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.0
- 0.1.2
- 0.1.1
- 0.1.0
- 0.0.6
- 0.0.5
- 0.0.4
- 0.0.3
- 0.0.2
- 0.0.1
- dev-bugfix/reserved-authorization-parameters
- dev-docs/upgrade-6.1
- dev-feature/authorization-parameters
- dev-feature/access-token-of-the-service
- dev-bugfix/scope-contains
- dev-flow-9
- dev-task/raise-minimum-flow-version
- dev-phpseclib-3.0
- dev-master
This package is auto-updated.
Last update: 2026-10-12 12:21:17 UTC
README
OpenID Connect Client for Flow Framework
This Flow package provides an OpenID Connect (OIDC) client SDK. OpenID Connect is an authentication layer built on top of OAuth 2.0. While OAuth is intended to be used for authorization, OpenID Connect is responsible for authentication – that is, verifying the identity of a human or machine user, commonly called "entity".
OIDC provides profile information about an authenticated user through an identity token, which is encoded as a secure JSON Web Token (JWT). JWTs are easy to handle in client- and server-side applications. The data contained in the ID token is usually signed and can optionally be encrypted.
Upgrading from version 5? Read the migration guide. Upgrading from version 6.0? Read the upgrade notes for 6.1.
Feature Overview
This plugin acts as a Flow authentication provider. It allows you to authenticate and authorize users using a browser and machine users (for example, other applications) communicating with your application via an API.
A few feature highlights of this package:
- drop-in replacement for other authentication methods for Flow applications or Neos websites
- OIDC auto-discovery support for minimal configuration
- support for multiple OIDC services (servers) within one application
- integration into Flow's session management based on JWT cookies, or sign-in to persisted accounts with an ordinary Flow session
- signing out at the identity provider (RP-Initiated Logout)
- mapping of Flow user roles from claims
- automatic JWT signature verification
- authentication via bearer access token
- easy access to ID tokens through the Flow account model
- command line support for testing
Terms and Background
Before deploying OpenID Connect for your application, you should get familiar with the concepts. As a quick reminder, here are some terms you should know about.
Authentication vs. Authorization
Authentication is the process of confirming the identity of a person or other entity. A user will need to proof her identity – for example by providing a username and password.
Authorization refers to the process of verifying what actions entities are allowed to perform or which information they may access. In this cases it's not about the identity, but only about the permissions.
More often than not, you will want to combine these concepts for your application – but it's important to know the difference.
Identity Provider
An Identity Provider is handling the authentication and authorization process for you. Popular identity providers are Google, Facebook, Microsoft, paid services like Auth0, or dedicated setups like gluu. Identity providers may implement methods like username / password authentication or advanced methods, like multi-factor authentication.
Identity Token
The ID Token is provided as part of a JSON Web Token (JWT). It contains the identity data of the authenticated entity. A JWT consists of a header, body and signature.
Claims
The ID Token provides information about an entity (for example, a user). The different bits of information could be a name, a URL pointing to a profile picture, or an email address. These information bits are called "claims". Because they are signed, as part of the JWT, you can trust them without having to specifically ask a central API.
Bearer Access Token
The access token gives the holder access to a specific service or other resource. That means, whoever owns this access token is permitted to access the resources for which the token was issued.
Access tokens usually have a limited lifetime and are issued for a specific scope.
Audience
The audience is the application or service which you want to protect
with OpenId Connect. It is the intended recipient of the token and
usually identified by an address, like
https://my-application.example.com. However, like with XML namespaces,
that address does not really have to exist and is just used as an
identifier.
Requirements
In order to use this plugin you need:
- an OIDC Identity Provider which provides auto discovery
- an application (such as Neos) based on Flow 8.3 (8.3.13 or later), Flow 8.4 or Flow 9, running on PHP 8.3 or later
Installation
The Flownative OpenID Connect plugin is installed via Composer:
composer require flownative/openidconnect-client
Usage Scenarios
Here are a few examples for using this plugin:
Authenticate web users
For example, use this plugin to authenticate and authorize users for the Neos backend.
What you need is:
- configure the discovery URI for your identity provider
- configure Flow to use this plugin as an authentication provider
- decide whether the login is kept in a JWT cookie, which this plugin manages with its own HTTP middleware, or in a Flow session of a persisted account (see "Session Mode with Persisted Accounts")
- configure Flow and this plugin to set user roles
You will use the Authorization Code Grant for this type of application.
Note: A shortcut for this setup is the Flownative.OpenidConnect.Neos package.
Authenticate applications
For example, use this plugin to authenticate and authorize your other third party's services accessing an API provided by your application.
- configure the discovery URI for your identity provider
- configure Flow to use this plugin as a (second) authentication provider
- configure Flow and this plugin to set user roles
- protect your API methods and evaluate further permissions obtained from the identity token
You will use the Client Credentials Grant for this type of application.
OpenID Connect Discovery
Identity providers usually expose OIDC discovery documents at a certain URL (for example, https://id.example.com/.well-known/openid-configuration). The Flownative OIDC client plugin uses discovery to configure authorization, token and user info endpoints, the JWKs location, supported scopes and more.
Configure the discovery endpoint as follows:
Flownative: OpenIdConnect: Client: services: myService: options: discoveryUri: 'https://id.example.com/.well-known/openid-configuration'
You can check if discovery is working by running the following command from a terminal:
./flow oidc:discover myService
+---------------------------------------+-----------------------------------------------------------+
| Option | Value |
+---------------------------------------+-----------------------------------------------------------+
| issuer | https://id.example.com/ |
| authorization_endpoint | https://id.example.com/authorize |
| token_endpoint | https://id.example.com/oauth/token |
| userinfo_endpoint | https://id.example.com/userinfo |
| mfa_challenge_endpoint | https://id.example.com/mfa/challenge |
| jwks_uri | https://id.example.com/.well-known/jwks.json |
| registration_endpoint | https://id.example.com/oidc/register |
| revocation_endpoint | https://id.example.com/oauth/revoke |
| scopes_supported | array ( |
| | 0 => 'openid', |
| | 1 => 'profile', |
| | 2 => 'offline_access', |
| | 3 => 'name', |
| | 4 => 'given_name', |
| | 5 => 'family_name', |
| | 6 => 'nickname', |
| | 7 => 'email', |
| | 8 => 'email_verified', |
| | 9 => 'picture', |
| | 10 => 'created_at', |
| | 11 => 'identities', |
| | 12 => 'phone', |
| | 13 => 'address', |
| | ) |
| response_types_supported | array ( |
| | 0 => 'code', |
| | 1 => 'token', |
| | 2 => 'id_token', |
| | 3 => 'code token', |
| | 4 => 'code id_token', |
| | 5 => 'token id_token', |
| | 6 => 'code token id_token', |
| | ) |
| code_challenge_methods_supported | array ( |
| | 0 => 'S256', |
| | 1 => 'plain', |
| | ) |
| response_modes_supported | array ( |
| | 0 => 'query', |
| | 1 => 'fragment', |
| | 2 => 'form_post', |
…
Requests to the Identity Provider
The client of this package sends all its requests to the identity
provider, for discovery, keys, codes and tokens, with time limits, so
that an identity provider which doesn't answer can't keep a request or
a worker waiting. The defaults are 5 seconds for the connection and 10
seconds for the whole answer. Only the command ./flow oidc:discover
waits without a limit.
Flownative: OpenIdConnect: Client: httpClient: connectTimeout: 5 timeout: 10
The client comes from HttpClientFactoryInterface. Replace its
implementation in Objects.yaml to send the requests through a proxy,
or in your tests, so that they never reach a real identity provider:
# Configuration/Testing/Objects.yaml Flownative\OpenIdConnect\Client\HttpClientFactoryInterface: className: Acme\App\Tests\Functional\FakeIdentityProviderHttpClientFactory
A factory for tests can return a Guzzle client with a MockHandler,
or one whose handler answers like the identity provider of your tests.
Outside of tests, a factory of your own must keep the verification of TLS certificates switched on. This client retrieves the discovery document and the keys which decide who can sign in, so whoever could answer in place of the identity provider could sign in as anybody.
Authorization Code Grant
The Authorization Code Grant is used for authenticating users using a web browser. The typical application flow goes like this:
- a user tries to access a protected page (controller action)
- Flow checks if the user has a cookie containing a valid JWT
- no valid JWT, so redirect to the identity provider's login page
- user logs in and is redirected back to Flow
- an authorization code is passed to Flow and Flow uses that to obtain an access token behind the scenes
- a JWT is extracted from the access token and sent to the browser as a cookie
- during the following web requests, the browser sends the cookie and Flow recognizes the user as being authenticated
These steps describe the default mode, which keeps the login in a JWT cookie. In the session mode, an ordinary Flow session of a persisted account holds the login instead, see "Session Mode with Persisted Accounts".
The Flow application needs a client identifier and a client secret so it can request authorization codes from the identity provider.
Here's an example configuration which enables OIDC authentication for a Neos backend. Please note that this is a proof-of-concept. This integration needs further configuration and custom implementation to be production-ready:
Flownative: OpenIdConnect: Client: services: test: options: discoveryUri: 'https://id.example.com/.well-known/openid-configuration' clientId: 'abcdefghijklmnopqrstuvwxyz01234567890' clientSecret: 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5MA==' middleware: cookie: # Only for development without HTTPS, never in production: secure: false Neos: Flow: security: authentication: providers: # Re-use the Neos authentication provider so we automatically get the right # request patterns: 'Neos.Neos:Backend': label: 'Neos Backend (OIDC)' provider: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectProvider' providerOptions: audience: 'https://www.example.com/neos' roles: ['Neos.Neos:Administrator'] accountIdentifierTokenValueName: 'sub' serviceName: 'test' token: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectToken' entryPoint: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectEntryPoint' entryPointOptions: serviceName: 'test' scope: 'profile name' authenticationStrategy: atLeastOneToken
Without further programming you need to manually create a Neos user which has the same username as the one provided in the "sub" claim by the OIDC identity provider.
Additional Authorization Parameters
The entry point can send further parameters with the authorization request, for example "prompt", "login_hint" or "ui_locales" of OpenID Connect Core 1.0, section 3.1.2.1, or parameters of a specific identity provider, like "screen_hint" of Auth0, which opens the registration form instead of the login form:
entryPointOptions: serviceName: 'acme' authorizationParameters: screen_hint: 'signup'
A controller which starts the authorization itself passes them to
OpenIdConnectClient::startAuthorization(). Parameters which the
client sets itself, like "state", "nonce" or "redirect_uri", are
rejected.
Failed Logins
If the login fails when the browser returns from the identity provider, the entry point shows a short page with a link to try again, instead of starting another login, which the identity provider would probably reject again. To show the failure in the layout and language of your application, let the entry point redirect to a page of it:
entryPointOptions: serviceName: 'acme' loginFailedRedirectUri: '/login?failed=1'
The option takes a path of the site or an absolute http(s) address. The page must be reachable without a login: if it is protected by this entry point, the login starts again, the identity provider sends the browser straight back, and the login goes round in circles. Why the login failed is written to the security log.
Binding the Login to the Browser
Before the entry point redirects the browser to the identity provider, it stores a random secret in a cookie named "_Host-flownative_oidc_nonce…". The identity provider receives a hash of this secret as "nonce" and copies it into the identity token. When the browser returns, the token is only accepted if the browser has the matching cookie. A login which was started in another browser is therefore rejected. The cookie expires after one hour and is removed after a successful login.
The identity provider must return the nonce in the identity token, as OpenID Connect requires. If it doesn't, every login fails and the security log contains "contains no nonce".
If a login is rejected when the browser returns, the entry point doesn't start another login, because the identity provider would usually send the user back right away and the login would be rejected again. Instead, it answers with status 403 and a short page with a link to try again. If "loginFailedRedirectUri" is configured, it redirects the browser to that page with status 303 instead, see "Failed Logins".
If you start an authorization yourself, pass a new nonce to
OpenIdConnectClient::startAuthorization() and set its cookie on the
response which redirects the browser. This response must not be
cached, because the cookie contains the secret:
// $this->middlewareSettings is injected with // #[Flow\InjectConfiguration(path: 'middleware', package: 'Flownative.OpenIdConnect.Client')] $cookieSettings = CookieSettings::fromMiddlewareSettings($this->middlewareSettings); $nonce = Nonce::generate(); $uri = $client->startAuthorization($returnToUri, 'profile email', $nonce); $this->response->setCookie($nonce->createCookie($cookieSettings)); $this->response->setHttpHeader('Cache-Control', 'no-store'); $this->redirectToUri($uri);
JWT Cookie
After a successful login, the middleware stores the identity token in a cookie, so that the browser sends it with the following requests. By default, the cookie is only sent over HTTPS ("secure") and cannot be read by JavaScript ("httpOnly").
With "secure" enabled, the cookie is named "__Host-flownative_oidc_jwt". Browsers only accept a cookie with this prefix if the site itself sets it over HTTPS, so that another subdomain can't plant a cookie with a login of its own. A name configured with "jwtCookieName" or "cookie.name" is used as it is, so consider giving it the same prefix. Flow's session cookie can get the prefix as well, through the settings "Neos.Flow.session.name" and "Neos.Flow.session.cookie".
The middleware renews the cookie with every response to a logged-in user. These responses are marked as private, so that shared caches like proxies or CDNs don't store them. For the same reason, the middleware removes the headers "CDN-Cache-Control" and "Surrogate-Control" from these responses.
Requests with a bearer token in the "Authorization" header neither set nor remove the cookie, because the client manages the token itself.
Only set "httpOnly" to false if your frontend needs to read the token from the cookie. Every script running on your pages, including injected ones, can then read the token as well.
Refreshing expired identity tokens
The entry point requests the scope "offline_access" in addition to "openid" and the configured scope. With this scope, identity providers like Auth0 or Microsoft Entra ID issue a refresh token. The refresh token is stored in the user's session and is used to refresh an expired identity token without an interactive login.
After a login, the session gets a new identifier, so that a session which was known before can't reach the refresh token. The refresh token is bound to the identity tokens which were issued last for this session, and only these identity tokens are refreshed. Usually this is a single identity token. If a browser sends several requests in parallel right after the identity token expired, each of these requests may refresh it, and all identity tokens they receive stay refreshable, whichever of them the browser keeps. A refreshed identity token must have the same issuer and subject as the expired one. Requests which a browser sent with a previous identity token while another request refreshed it receive the refreshed identity token from the session, for up to ten minutes after the refresh. If the identity provider rotates refresh tokens, the new refresh token replaces the old one in the session. Refresh tokens which earlier versions of this package stored are not used anymore, so users log in once more when their identity token expires after an update.
Identity tokens which a client sends in the "Authorization" header are not refreshed, because the client would never receive the new token. Such clients must refresh their tokens themselves.
Some identity providers treat this scope differently. Google rejects it with an "invalid_scope" error. Keycloak issues an offline token which does not expire with the SSO session. If you don't need refresh tokens, or your identity provider does not support the scope, disable it in the entry point options:
entryPointOptions: serviceName: 'test' scope: 'profile name' requestRefreshToken: false
Note: Check the Flownative.OpenidConnect.Neos package for a working implementation.
Session Mode with Persisted Accounts
By default, the provider keeps the login in a JWT cookie and authenticates a transient account on every request. Applications which keep their own accounts can use the session mode instead: the identity provider vouches for the user once, and from then on an ordinary Flow session holds the login of a persisted account.
That has a few consequences which are often wanted:
- the login can be ended on the server, for example for all sessions of an account, because Flow tags each session with its account
- roles belong to the account, not to the configuration or the claims
- the session decides how long the login lasts, not the identity token, so no refresh tokens are needed
To use it, configure the OpenIdConnectSessionToken and an account
resolver. The resolver decides which persisted account an identity
signs in to. The PersistedAccountResolver of this package finds the
active account whose identifier is the claim configured in
accountIdentifierTokenValueName ("sub" by default). With
lookupProviderName, it looks for accounts of another authentication
provider, for example those which can also sign in with a password.
Neos: Flow: security: authentication: providers: 'Acme.App:OpenIdConnect': provider: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectProvider' providerOptions: serviceName: 'acme' accountResolver: 'Flownative\OpenIdConnect\Client\Authentication\PersistedAccountResolver' lookupProviderName: 'Acme.App:Login' token: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectSessionToken' entryPoint: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectEntryPoint' entryPointOptions: serviceName: 'acme' scope: 'openid email profile' requestRefreshToken: false
Set requestRefreshToken of the entry point to false. The session mode
doesn't use refresh tokens and discards one if the identity provider
issues it anyway, but the identity provider would still keep a
long-lived credential for nothing. The provider logs a warning if it
receives a refresh token in session mode.
The options roles, rolesFromClaims and addRolesFromExistingAccount
can't be used in session mode, because they would change the roles of
the persisted account.
The PersistedAccountResolver only looks at the account identifier, so
it must be unique across all issuers the provider accepts. "sub" is only
unique per issuer, and "email" not even that across tenants. If you
accept several issuers or a multi-tenant issuer with "{tenantid}",
write a resolver of your own which takes the "iss" claim into account.
An application which admits identities in its own way implements
AccountResolverInterface. It receives the identity token only after
it passed all checks, and returns the account or null:
final class AccountResolver implements AccountResolverInterface { public function resolve(ValidatedIdentityToken $validatedIdentityToken, string $authenticationProviderName): ?Account { // For example: find the person linked to this identity, or link // it on the first sign-in through a verified email address } }
The browser returns from the identity provider with a GET request, and
Flow only persists objects in such a request which were allowed
explicitly. The provider does that for an existing account which the
resolver returns. A resolver which creates an account, or changes
other objects, must add them to their repository and call
allowObject() of the persistence manager itself.
While somebody is signed in, the session token ignores a return from the identity provider, so that a link with the return parameters of another authorization can't sign them out. To sign in with another account, sign out first.
When an application moves from the JWT cookie to the session mode, every user has to sign in once more.
Signing Out at the Identity Provider
Ending the login in the application leaves the user signed in at the identity provider, so the next click on "Sign in" would sign them in again without asking. With RP-Initiated Logout, the application sends the browser to the identity provider after its own logout, and the identity provider ends its login as well and sends the browser back.
OpenIdConnectClient::buildEndSessionUri() returns that address. It
takes the "end_session_endpoint" from the discovery document. If the
identity provider doesn't publish one, configure it for the service:
Flownative: OpenIdConnect: Client: services: acme: options: endSessionEndpoint: 'https://id.example.com/oidc/logout'
Without either, the method returns null, and the application only ends its own login. Auth0, for example, only publishes the endpoint if the tenant setting "RP-Initiated Logout End Session Endpoint Discovery" is enabled.
The identity provider expects the identity token of the login as a
hint. IdentityTokenOfLogin finds it, in the JWT mode as well as in
the session mode. Take it before the logout, because Flow destroys the
session on logout:
public function logoutAction(): void { $client = $this->openIdConnectClientFactory->create('acme'); $endSessionUri = $client->buildEndSessionUri( $this->identityTokenOfLogin->find('Acme.App:OpenIdConnect'), new Uri($this->uriBuilder->setCreateAbsoluteUri(true)->uriFor('signedOut')) ); $this->authenticationManager->logout(); if ($endSessionUri !== null) { $this->redirectToUri($endSessionUri); } $this->redirect('signedOut'); }
Call the logout action with a POST request from a form, so that Flow checks its CSRF token. Otherwise a link or an image on another site can sign the user out, and with RP-Initiated Logout also at the identity provider, which may end the login in all applications of the tenant.
The post logout redirect URI must be registered for the client at the identity provider. The hint puts the identity token, with its claims like the email address, into the URL, and so into browser histories and logs. Leave it out if your identity provider doesn't need it. Microsoft Entra ID, for example, asks the user before it signs out without one.
The method also accepts a "state", which the identity provider passes back with the redirect. If you use it to recognise the return, make it unguessable and verify it without the session, for example with a cookie, because the session ends with the logout.
Client Credentials Grant
Client Credentials Grant is a bit simpler than Authorization Code Grant, but can only be used for trusted parties. Because you use long-living client credentials directly (instead of going that extra step of trading an access token for an authorization code), you cannot use this type of grant in a browser, because the credentials would not be safe there.
Here's an example consisting of two parts: An application providing an API and a second one consuming that API.
The URI used as the "audience" string is only a URI by convention. In fact, it can be any other string, but must be recognized by your identity provider.
The following configuration is used in the Flow application using the API:
Flownative: OpenIdConnect: Client: services: test: options: discoveryUri: 'https://id.example.com/.well-known/openid-configuration' clientId: 'abcdefghijklmnopqrstuvwxyz01234567890' clientSecret: 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5MA==' additionalParameters: audience: 'http://yourapp.localbeach.net/api/v1'
Somewhere in your application you might have a service class which wraps communication with the API. It may look like this (some code omitted for brevity):
class BillingService { public function sendAuthenticatedRequest(string $relativeUri, string $method = 'GET', array $bodyFields = []): ResponseInterface { $openIdConnectClient = new OpenIdConnectClient('test'); // Client id, secret and the audience come from the configuration of the service $accessToken = $openIdConnectClient->getClientCredentialsAccessToken(); $httpClient = new Client(['allow_redirects' => false]); return $httpClient->request( $method, trim($this->apiBaseUri, '/') . '/' . $relativeUri, [ 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $accessToken->getToken() ], 'body' => ($bodyFields !== [] ? \GuzzleHttp\json_encode($bodyFields) : '') ] ); } }
The important bit is that your code uses the OpenID Connect Client to retrieve an access token and then sends that as part of an authorization header to the API.
getClientCredentialsAccessToken($scope = '', $additionalParameters = [])
replaces getAccessToken(), which is deprecated since version 6.1. It
uses the service of the client and takes the client id and secret,
and the "additionalParameters", from the configuration of the service. Parameters
which you pass as second argument replace configured parameters of the
same name. If the client id or the secret is empty, it throws a
ConfigurationException.
You can check the configuration from a terminal. The following command requests an access token with the scope "profile name" and prints it:
./flow oidc:getAccessToken test
The application providing the API needs the following configuration, likely using a different client id and secret than the consuming app:
Flownative: OpenIdConnect: Client: services: test: options: discoveryUri: 'https://id.example.com/.well-known/openid-configuration' clientId: 'abcdefghijklmnopqrstuvwxyz01234567890' clientSecret: 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5MA=='
A base controller in your application providing the API may look like this:
abstract class AbstractApiController extends ActionController { #[Flow\Inject] protected Context $securityContext; protected ?IdentityToken $identityToken = null; protected function initializeAction(): void { parent::initializeAction(); // The provider has validated the token and keeps the JWT as credentials source of the transient account $jwt = $this->securityContext->getAccount()?->getCredentialsSource(); if (is_string($jwt)) { $this->identityToken = IdentityToken::fromJwt($jwt); } } }
To check the scopes which were granted to the client, use
IdentityToken::scopeContains():
if ($this->identityToken === null || !$this->identityToken->scopeContains('billing:read')) { $this->throwStatus(403); }
The method reads the "scope" claim, whose identifiers are separated by whitespace, as OAuth 2.0 defines it. It also reads the "scp" claim, which some identity providers like Microsoft Entra ID use instead, as a string or as a list.
Roles from Identity Token
Instead of specifying the Flow authentication roles directly, the provider can extract the roles from the identity token values. The roles provided by the token must have the same identifier which is used in Flow's policy configuration.
Given that the identity token provides a claim called "https://flownative.com/roles", you may configure the provider as follows:
…
security:
authentication:
providers:
'Flownative.OpenIdConnect.Client:OidcProvider':
label: 'OpenID Connect'
provider: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectProvider'
providerOptions:
rolesFromClaims:
- 'https://flownative.com/roles'
…
When a user logs in and her identity token has a value "https://flownative.com/roles" containing an array of Flow role identifiers, the OpenID Connect provider will automatically assign these roles to the transient account.
Roles can be mapped in case their values don't match the required Flow role pattern (<Package-Key>:<Role>)
or if multiple roles should be translated to a single Flow role:
…
providerOptions:
rolesFromClaims:
-
name: 'https://flownative.com/roles'
mapping:
'role1': 'Some.Package:SomeRole1'
'role2': 'Some.Package:SomeOtherRole'
'role3': 'Some.Package:SomeRole'
…
You may specify multiple claim names which are all considered for compiling a list of roles.
Check logs for hints if things are not working as expected.
Roles from an Existing Account
As a third option, roles can be used from an existing account whose account identifier matches a given claim of the identity token. Or put differently: if there's an account with the same username which is provided by the identity token, roles of that (persisted) account can be used.
Anyone who can present the identifier of an existing account receives its roles. Therefore, use a claim which is controlled by the identity provider and never changes for a user, ideally "sub". Claims like "email" or "preferred_username" can often be changed by the users themselves.
Given that an account (for example, a Neos user account) exists using the subject of the identity provider as its account identifier, you may configure the provider as follows:
…
security:
authentication:
providers:
'Flownative.OpenIdConnect.Client:OidcProvider':
label: 'OpenID Connect'
provider: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectProvider'
providerOptions:
accountIdentifierTokenValueName: 'sub'
addRolesFromExistingAccount: true
…
When a user logs in, the OpenID Connect provider will automatically assign any roles which are assigned to a Flow account with the same account identifier. The identifiers must match exactly, apart from upper and lower case.
If you use "email" as account identifier, the provider only accepts tokens whose "email_verified" claim is true. Only disable this check with the "requireVerifiedEmail" option if your identity provider verifies all email addresses itself but doesn't send the claim:
…
providerOptions:
accountIdentifierTokenValueName: 'email'
requireVerifiedEmail: false
…
Microsoft Entra ID is not such an identity provider. Its "email" claim is neither verified nor fixed, so use the "oid" or "sub" claim as account identifier instead.
The check only applies to the "email" claim. If you use a custom claim containing an email address, for example one added by an Auth0 action, make sure that only verified addresses are written into it.
You may mix "rolesFromClaims" with "addRolesFromExistingAccount". In that case roles from claims and existing accounts will be merged.
Again, check logs for hints if things are not working as expected.
Token Validation
The authentication provider only accepts a token if all of the following checks pass:
- the signature is valid and was created with a key of the identity provider
- the token was issued by the issuer of the configured service ("iss")
- the token was issued for the audience of your application ("aud")
- the token was issued to your client ("azp"), see "Authorized Party" below
- the token is not expired ("exp") and already valid ("nbf", "iat")
- the claim used as account identifier is present, and confirmed by "email_verified" if it is the "email" claim
Tokens which fail a check are rejected and the reason is written to the security log.
Issuer
The expected issuer is taken from the discovery document of the service. If you don't use discovery, configure it explicitly:
Flownative: OpenIdConnect: Client: services: myService: options: issuer: 'https://id.example.com/' jwksUri: 'https://id.example.com/.well-known/jwks.json'
Some identity providers issue tokens with a different issuer than the one published in their discovery document. Examples are version 1 access tokens of Microsoft Entra ID, or a Keycloak server whose discovery document is retrieved through an internal address. In that case, set the expected issuer in the provider options. It takes precedence over the issuer of the service and may be a list:
…
providerOptions:
issuer:
- 'https://login.microsoftonline.com/{tenantid}/v2.0'
- 'https://sts.windows.net/{tenantid}/'
…
Multi-tenant applications of Microsoft Entra ID use an issuer containing the placeholder "{tenantid}". It is replaced by the "tid" claim of each token, so tokens of all tenants are accepted as long as they are issued for your application. Users of any tenant can then log in, so restrict access through roles, and don't use a claim like "email" as account identifier, because it is not unique across tenants.
Audience
By default, a token must contain the client id of the service in its "aud" claim. This is what identity providers put into identity tokens issued for your application.
Access tokens for an API usually carry the identifier of that API instead. In that case, configure the expected audience explicitly. You may also specify a list, and a token must contain at least one of them. Keycloak only adds an audience to access tokens if the client has an audience mapper.
…
security:
authentication:
providers:
'Flownative.OpenIdConnect.Client:OidcProvider':
label: 'OpenID Connect'
provider: 'Flownative\OpenIdConnect\Client\Authentication\OpenIdConnectProvider'
providerOptions:
audience: 'https://www.example.com/my-application'
…
Authorized Party
An identity token may name the client it was issued to in the "azp" claim. For every token whose "aud" claim contains the client id of the service, the provider checks it as OpenID Connect asks for: if the token has several audiences, "azp" must be present, and if "azp" is present, it must be the client id.
Access tokens for an API, accepted through the "audience" option, are usually obtained by other clients, and "azp" then names the client which called the API. As long as their audience doesn't contain the client id of the service, "azp" is not checked for them.
Some identity providers put the client id itself into the audience of access tokens, for example Microsoft Entra ID if the API is the same app registration as the client and has no Application ID URI of its own. Access tokens which other clients obtained for such an API are then rejected. Give the API an identifier of its own, for example an Application ID URI like "api://…", and configure that as "audience".
Clock Leeway
The clocks of your application and the identity provider may differ slightly. When checking the time claims of a token, the provider allows a difference of 60 seconds by default. You can change it with the "leeway" option:
…
providerOptions:
leeway: 30
…
Key Rotation and Revocation
The key used by the identity provider for signing JWTs should be rotated regularly. This plugin retrieves valid keys from the JWKs endpoint which is configured through the discovery endpoint, and caches them.
The JWKs may contain multiple public keys. That way, existing and not yet expired JWTs are still valid.
If a token names a key identifier ("kid") which the cached keys don't contain, and claims to come from the expected issuer, the plugin retrieves the keys again, at most once per minute. If they can't be retrieved, the token is not accepted, but it doesn't count as wrong credentials either. Keys can therefore be rotated without further action. Tokens without a key identifier don't trigger this, so identity providers which don't send one may need the cache to be flushed after a rotation.
If you revoked a key, flush the cache, so that tokens signed with this key are rejected right away:
./flow flow:cache:flushone Flownative_OpenIdConnect_Client_JWKs
Validating Tokens in Your Own Code
If your application receives an identity token in another way, for example in a controller of its own, validate it with the same checks the provider uses:
$client = $this->openIdConnectClientFactory->create('myService'); $options = $client->getOptions(); try { $validatedIdentityToken = $this->identityTokenValidator->validate( IdentityToken::fromJwt($jwt), $client, new IdentityTokenRequirements( issuers: [$options['issuer']], audiences: [$options['clientId']], authorizedParty: $options['clientId'], nonce: $expectedNonce, ) ); } catch (IdentityTokenRejectedException $exception) { // The message says why, for example "its audience … contains none of …" $this->throwStatus(401); } catch (ConnectionException|ServiceException $exception) { // The key set of the identity provider could not be retrieved $this->throwStatus(503); } $accountIdentifier = $validatedIdentityToken->accountIdentifier;
IdentityTokenRequirements needs at least one issuer and one audience,
otherwise its constructor throws an InvalidArgumentException. The
authorized party is a required argument: pass the client id to check
"azp" like the provider does, or null to not check it at all. The
other arguments are optional. By default, the claim "sub" identifies
the account, an "email" claim used instead must be verified, the
leeway is 60 seconds, and the nonce is not checked.
The validator takes the keys from the client, and reloads them for a token which names an unknown key, as described above.
The expiration time is checked last. An ExpiredIdentityTokenException,
a subclass of IdentityTokenRejectedException, therefore means that
the token passed all other checks.
More about OpenID Connect
See also:
https://openid.net/specs/openid-connect-basic-1_0.html https://connect2id.com/learn/openid-connect
Credits and Support
This library was developed by Robert Lemke / Flownative. Feel free to suggest new features, report bugs or provide bug fixes in our Github project.
If you'd like us to develop a new feature or need help implementing OIDC in your project, please get in touch with Robert.