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.
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
- 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
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 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.
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.
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.
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.
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.
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.
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
The Flownative OpenID Connect plugin is installed via Composer:
composer require flownative/openidconnect-client
Here are a few examples for using this plugin:
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
- configure the HTTP chain to manage JWT session cookies
- 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.
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 the HTTP chain to manage JWT session cookies
- 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.
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', |
…
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
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.
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. Logins which are in progress while you update this package end on this page once.
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);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.
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: falseNote: Check the Flownative.OpenidConnect.Neos package for a working implementation.
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');
$accessToken = $openIdConnectClient->getAccessToken(
'test',
$this->clientId,
$this->clientSecret,
'',
Authorization::GRANT_CLIENT_CREDENTIALS,
$this->additionalParameters
);
$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.
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
* @var Context
*/
protected $securityContext;
/**
* @var IdentityToken|null
*/
protected $identityToken;
/**
* @return void
*/
public function initializeAction()
{
parent::initializeAction();
$account = $this->securityContext->getAccount();
$identityToken = $account->getCredentialsSource();
if ($identityToken instanceof IdentityToken) {
$this->identityToken = $identityToken;
}
}
}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.
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.
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 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.
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.
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'
…
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
…
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.
The JWKs may contain multiple public keys. That way, existing and not yet expired JWTs are still valid.
This plugin supports multiple keys and therefore keys can be rotated without further action. However, if you rotate keys multiple times in a short time frame or if you revoked an existing key, you should flush the respective cache (or all caches):
./flow flow:cache:flushone Flownative_OpenIdConnect_Client_JWKs
See also:
https://openid.net/specs/openid-connect-basic-1_0.html https://connect2id.com/learn/openid-connect
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.