The provider: turning a Laravel app into an identity provider
Part 2 of the Be Your Own Identity Provider series. Part 1 covered the why.
Laravel already has an OAuth2 authorization server: Passport. So the obvious question when I started was whether an OIDC package needed to be anything more than a thin shim on top of it.
It does. But the interesting part is which parts.
What Passport gives you, and what it doesn’t
Passport implements OAuth2 properly — clients, the authorization code grant, refresh tokens, scopes, token storage. That’s the hard, boring, security-critical half, and reimplementing it would have been a great way to discover which RFC footnotes I’d never read.
What it doesn’t have is everything from the identity half of part 1:
- no
id_token— Passport issues access tokens, and an access token is not a statement about a user - no discovery document, so no client can configure itself from your issuer URL
- no JWKS endpoint, so nobody can verify your signatures without out-of-band setup
- no userinfo, no end-session endpoint, no back-channel logout
- no OIDC scopes and claims, no
noncehandling, nomax_age
None of those are things you bolt on from the outside, because most of them have to be woven into the authorization request itself. Which leads to the one decision the whole package is built on.
Taking over the routes
On registration, the package calls Passport::ignoreRoutes() and registers the entire
/oauth/* surface itself, from a single config map — config('oidc.handlers'), one
entry per endpoint:
| Endpoint | Route |
|---|---|
| Discovery | GET /.well-known/openid-configuration |
| JWKS | GET /.well-known/jwks.json |
| Authorize | GET /oauth/authorize |
| Token | POST /oauth/token |
| UserInfo | GET|POST /oauth/userinfo |
| End session | GET|POST /oauth/logout |
| Introspection | POST /oauth/introspect |
| Revocation | POST /oauth/revoke |
Owning the routes is what makes the rest possible: the authorize controller can handle
nonce, max_age and the id_token response type; the access-token entity is swapped
for one that knows about OIDC; the token endpoint returns an id_token alongside the
access token. Each entry can also be pointed elsewhere or switched off — disable
userinfo and it disappears from discovery too, so the document never advertises
something that isn’t there.
Two consequences worth knowing before you install it. Passport’s optional JSON management routes (client CRUD, personal access tokens) are not registered — register them yourself if you use them. And:
PKCE is required on every authorization request. Not just for public clients — for
confidential ones as well, per OAuth 2.1 §4.1.1/§7.6. A request without a
code_challenge is rejected with invalid_request. This will annoy exactly one
integration you own, and then it will never be a problem again.
Tokens that resource servers can read
Access tokens are structured JWTs per RFC 9068:
"typ": "at+jwt" in the header, a kid matching the JWKS endpoint, and the usual
iss / aud / sub / client_id / scope claims. A resource server can verify and
authorize a request against the JWKS alone, without calling back to the provider.
That is also where the MCP thread from part 1 gets picked up. The package implements
RFC 8693 token exchange, which trades one
token for another scoped to a different audience — the shape you want when a session
in one app needs to call an API (or an MCP server) that must only accept tokens minted
for it. There’s a CheckAudience middleware on the resource-server side to enforce
the other end of that.
Signing keys live in environment variables by default — OIDC_PRIVATE_KEY /
OIDC_PUBLIC_KEY, generated and rotated by php artisan oidc:rotate-keys, with the
previous public key kept in JWKS during the overlap so rotation doesn’t invalidate
tokens in flight. No key files on disk, and a SigningKeyStore seam if you’d rather
keep them somewhere else.
The second layer: an auth engine, without views
The protocol layer above is usable on its own if you already have authentication. But
/oauth/authorize has to put something in front of a user who isn’t logged in yet,
and that’s where most “just add OIDC” projects quietly turn into “rewrite your auth”.
So the package ships a complete auth engine — login, registration, password reset,
email verification, password confirmation, and multi-factor (TOTP, recovery codes,
passkeys) — with all the logic inside the package and two kinds of seam for your app
to fill. All of it runs through a dedicated identity session guard, shared with the
authorization flow, so the provider and the engine can’t disagree about who is logged in.
The first seam is views. Every screen renders through a typed contract with a single
respond() method:
interface LoginView
{
public function respond(LoginPrompt $prompt, Request $request): Responsable|Response;
}
Eight of those cover every auth surface, consent included. The package deliberately
ships no default implementations — an unbound contract throws
MissingAuthViewException rather than rendering some Blade view I picked for you. The
prompt carries the page-specific data; what it looks like is entirely yours.
The second seam is actions: the handful of decisions that are genuinely
application-specific, like how a user gets created on registration. Plus a post-login
pipeline with one decision hook — require MFA, deny, or add claims — which is also
where acr and amr come from.
I’ll leave the engine there; MFA and the post-login pipeline deserve their own writeup rather than a paragraph.
Next up: the other side of the protocol — the relying party, and why logging in through an OIDC provider is a separate package on purpose.