mindtwo / laravel-identity
OpenID Connect identity layer for Laravel Passport
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- laravel/passport: ^13.0
- lcobucci/jwt: ^5.0
- league/oauth2-server: ^9.0
- nyholm/psr7: ^1.8
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- chiiya/laravel-code-style: ^3.3
- mockery/mockery: ^1.6
- orchestra/testbench: ^11.0
- phpseclib/phpseclib: ^4.0
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 14:09:21 UTC
README
Laravel Identity
The missing OIDC identity layer for Laravel Passport
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.
Instead of a view name you can pass a closure. It receives the same data and may return a view name, a
response, or any Responsable — so the screen can be an Inertia page, just like Passport::authorizationView():
Identity::endSessionView(fn (array $data) => Inertia::render('Auth/LogoutConfirm', [ 'clientName' => $data['client']->name, 'clientId' => (string) $data['client']->getKey(), 'idTokenHint' => request('id_token_hint'), 'postLogoutRedirectUri' => $data['request']->postLogoutRedirectUri, 'state' => $data['state'], ]));
The confirmation form POSTs those parameters back to route('identity.end_session.confirm'). Submit it as a
native form (including the CSRF token), not via Inertia's router.post(): the response is either a redirect
to the RP's external post_logout_redirect_uri or the front-channel logout page, neither of which an Inertia
XHR visit can follow.
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]
frontChannelLogoutLayout() accepts the same closure form as endSessionView(). If you return an Inertia
page from it, the Blade component is not available, so your page has to load the hidden iframeUrls iframes and
redirect to redirectUri itself.
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 |
To add a custom scope and the claims it grants, call Identity::registerScope() from
any service provider's register() or boot() — order relative to this package does
not matter, and the scope reaches Passport, discovery, and claim filtering automatically:
use Mindtwo\LaravelIdentity\Identity; // AppServiceProvider::boot() Identity::registerScope('org', [ 'https://issuer.example/department', 'https://issuer.example/team', ]);
The claim names must match the keys your ClaimProvider emits; namespace any
non-standard claims with a URI you own so they cannot collide with standard ones.
To replace a collaborator entirely, swap its container binding:
$this->app->extend(ScopeRegistrar::class, function ($registrar) { // return your own ScopeRegistrar implementation 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