laravel-identity maintained by mindtwo
Table of Contents
- Features
- Requirements
- Installation
- Quick Start
- Endpoints
- Logout
- Configuration
- Subject Types
- Signing Keys and Rotation
- First-Party Clients
- Events
- Extension Points
- Token Introspection
- Testing
- License
Features
Laravel Identity is an OpenID Connect (OIDC) identity layer for Laravel Passport. It turns a Passport OAuth2 server into a spec-compliant OpenID Provider (OP) by adding ID tokens, a UserInfo endpoint, discovery, JWKS, token introspection, and both front-channel and RP-initiated logout.
Implemented specifications:
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- OAuth 2.0 Token Introspection (RFC 7662)
- OpenID Connect Front-Channel Logout 1.0
- OpenID Connect RP-Initiated Logout 1.0
Requirements
- PHP 8.4+
- Laravel 13 / Passport 13
Installation
composer require mindtwo/laravel-identity
Run Passport's installer first (if you have not already), then publish this package's migrations and run
them. The migrations alter Passport's oauth_clients table.
php artisan passport:install
php artisan vendor:publish --tag=identity-migrations
php artisan migrate
Publish the config if you need to change defaults:
php artisan vendor:publish --tag=identity-config
Quick start
1. Add OIDC metadata to your Client model
Extend Passport's client and apply the HasOidcMetadata trait, then register it with Passport:
use Mindtwo\LaravelIdentity\Concerns\HasOidcMetadata;
use Laravel\Passport\Client as PassportClient;
class Client extends PassportClient
{
use HasOidcMetadata;
}
// AppServiceProvider::boot()
use Laravel\Passport\Passport;
Passport::useClientModel(\App\Models\Client::class);
The trait reads OIDC settings from these (nullable) columns, added by the migration:
| Column | Purpose |
|---|---|
subject_type |
public (default) or pairwise — selects the sub resolver |
sector_identifier_uri |
Host used to compute pairwise sub (falls back to redirect host) |
id_token_signed_response_alg |
Per-client signing algorithm (e.g. RS512) |
id_token_lifetime |
Per-client ID token lifetime in seconds |
default_max_age |
Forces re-authentication when the session is older, even if the request omits max_age |
first_party |
First-party clients skip the consent screen |
frontchannel_logout_uri |
RP endpoint loaded in an iframe on logout |
frontchannel_logout_session_required |
Append iss/sid to the front-channel logout URI |
post_logout_redirect_uris |
JSON array of allowed post_logout_redirect_uri values |
2. Expose claims from your User model
Your authenticatable must implement Passport's OAuthenticatable. To emit standard OIDC claims,
register one or more ClaimProviders and tag them identity.claims:
use Mindtwo\LaravelIdentity\Contracts\ClaimProvider;
use Laravel\Passport\Contracts\OAuthenticatable;
class UserClaimProvider implements ClaimProvider
{
public function getClaims(OAuthenticatable $user, array $scopes): array
{
return array_filter([
'name' => $user->name,
'email' => $user->email,
'email_verified' => $user->hasVerifiedEmail(),
]);
}
/** Scopes this provider answers to. Return [] to always run. */
public function handles(): array
{
return ['profile', 'email'];
}
}
// AppServiceProvider::register()
$this->app->tag(UserClaimProvider::class, 'identity.claims');
Claims are filtered against the granted scopes (profile, email, address, phone, …) before they reach
the ID token and UserInfo response, so a provider can safely return everything it knows.
3. Provide auth_time (optional, recommended)
To support max_age re-authentication and an accurate auth_time claim, expose the moment the user authenticated:
public function getAuthTime(): \DateTimeInterface
{
return $this->last_login_at ?? now();
}
When the method is absent, auth_time defaults to the current request time.
Endpoints
All endpoints are registered automatically:
| Endpoint | Route name | Description |
|---|---|---|
GET /.well-known/openid-configuration |
identity.discovery |
Discovery document (cached) |
GET /.well-known/jwks.json |
identity.jwks |
Public signing keys (JWKS, ETag-cached) |
GET|POST /oauth/userinfo |
identity.userinfo |
UserInfo (requires openid scope) |
POST /oauth/introspect |
identity.introspect |
RFC 7662 introspection (client-authenticated) |
GET|POST /oauth/logout |
identity.end_session |
RP-Initiated Logout |
GET /oauth/logout/frontchannel |
identity.frontchannel_logout |
Front-channel logout iframe page |
The ID token is added to the standard Passport token response (/oauth/token) whenever the openid scope is granted.
Logout
RP-Initiated Logout
Send the user to identity.end_session with an id_token_hint (or client_id), optional
post_logout_redirect_uri (must be registered on the client) and state. The subject in id_token_hint is
matched against the current user through the same subject resolver used at issuance, so pairwise subjects work correctly.
Non-first-party clients are shown a confirmation screen. Register the view:
// AppServiceProvider::boot()
Identity::endSessionView('auth.logout-confirm');
// receives ['client' => Client, 'request' => LogoutRequest, 'state' => ?string]
First-party clients (see First-party clients) skip confirmation.
Front-Channel Logout
On logout the package renders a page with a hidden iframe per active session whose client registered
a frontchannel_logout_uri. Sessions are tracked in oidc_sessions, and each iframe carries the same sid
embedded in that session's ID token.
To brand the page, point Identity::frontChannelLogoutLayout at your own view and
embed the supplied Blade component — you get the iframe-loading and redirect logic for free, you
only style around it:
{{-- resources/views/layouts/logout.blade.php --}}
<x-app-layout>
<p>Signing you out…</p>
<x-identity::front-channel-logout :urls="$iframeUrls" :redirect="$redirectUri ?? '/'" />
</x-app-layout>
Identity::frontChannelLogoutLayout('layouts.logout');
// your view receives ['iframeUrls' => array, 'redirectUri' => ?string]
The <x-identity::front-channel-logout> component renders the hidden iframes and the JS that redirects once
they have loaded (or after a timeout). If you don't register a layout, the package renders a minimal
default page built from the same component.
Configuration
config/identity.php:
| Key | Default | Description |
|---|---|---|
issuer |
config('app.url') |
OP issuer identifier (IDENTITY_ISSUER) |
id_token_lifetime |
3600 |
Default ID token lifetime (seconds) |
discovery_cache_ttl |
3600 |
Discovery document cache TTL; 0 disables |
pairwise_salt |
null |
Secret for pairwise sub (IDENTITY_PAIRWISE_SALT); required for pairwise clients |
allow_cross_client_introspection |
false |
Allow a client to introspect tokens it did not own |
register_openid_scope |
true |
Auto-register OIDC scopes via Passport::tokensCan() |
keys |
[] |
Dedicated signing keys (see Signing keys) |
Static configuration (Identity)
Set in AppServiceProvider::boot():
use Mindtwo\LaravelIdentity\Identity;
use Mindtwo\LaravelIdentity\Jwt\Algorithm;
Identity::signingAlgorithm(Algorithm::ES256); // global default alg
Identity::endSessionView('auth.logout-confirm'); // RP logout screen
Identity::frontChannelLogoutLayout('layouts.logout'); // optional layout
Identity::firstPartyClientResolver(fn ($client) => $client->trusted);
Subject types
sub resolution is dispatched per client by ClientAwareSubjectResolver:
- public (default) —
subis the user's identifier. - pairwise —
subis a salted hash unique per sector, computed byPairwiseSubjectResolverfromsector_identifier_uri(or the redirect host) pluspairwise_salt. Setidentity.pairwise_saltwhen any client uses it.
Set a client's subject_type column to pairwise to opt in.
Signing keys and rotation
By default, the Passport RSA key signs ID tokens and backs every RS* algorithm (RS256/RS384/RS512),
selectable globally or per client.
For EC algorithms or key rotation, list dedicated keys in identity.keys (current/signing key first). When
non-empty, ConfigKeyResolver is used automatically and every listed key is published in the JWKS so previously
issued tokens keep verifying:
'keys' => [
['private' => file_get_contents(storage_path('oidc/current.key')),
'public' => file_get_contents(storage_path('oidc/current.pub')),
'algorithm' => 'ES256'],
['private' => file_get_contents(storage_path('oidc/previous.key')),
'public' => file_get_contents(storage_path('oidc/previous.pub')),
'algorithm' => 'RS256'], // retiring key, still published
],
First-party clients
A client is treated as first-party (and skips the consent screen and logout confirmation) when either:
- a resolver registered via
Identity::firstPartyClientResolver()returnstrue, or - the client model uses
HasOidcMetadataand itsfirst_partycolumn istrue.
Events
Listen via Laravel's event system:
| Event | Dispatched when |
|---|---|
AuthorizationRequestValidated |
An OIDC authorization request passes validation (carries the AuthRequestContext) |
IdTokenIssued |
An ID token is minted (carries the IdTokenContext and the encoded token) |
UserLoggedOut |
A user logs out (carries the initiating client, or null for local logout) |
For logout side effects that must complete before the redirect (e.g. revoking tokens), implement
LogoutEventListener and tag it identity.logout_listeners — these run synchronously, ahead of the dispatched event:
$this->app->tag(RevokeTokensOnLogout::class, 'identity.logout_listeners');
Extension points
Every collaborator is bound to a contract and can be swapped in the container. Respecting custom Passport
models, all client/user lookups go through Passport::clientModel() and the configured auth provider.
| Contract | Default | Responsibility |
|---|---|---|
KeyResolver |
PassportKeyResolver / ConfigKeyResolver |
Signing key material + JWKS |
SubjectIdentifierResolver |
ClientAwareSubjectResolver |
The sub claim |
ScopeRegistrar |
StandardScopeRegistrar |
Scope → claim mapping |
SessionIdResolver |
SidManager |
sid issuance and revocation |
DiscoveryDocumentBuilder |
DefaultDiscoveryBuilder |
The discovery document |
// Override any default, e.g. add custom scopes/claims:
$this->app->extend(ScopeRegistrar::class, function ($registrar) {
$registrar->register('roles', ['roles']);
return $registrar;
});
Token introspection
POST /oauth/introspect implements RFC 7662. Clients authenticate via client_secret_basic or
client_secret_post. By default a client may only introspect its own tokens; set allow_cross_client_introspection
to true to lift that restriction. Revoked or expired tokens return {"active": false}.
Testing
composer test # or: vendor/bin/phpunit
License
MIT
