The relying party: logging in through any OIDC provider
Part 3 of the Be Your Own Identity Provider series. Part 2 covered the provider.
Part 2 was about being the identity provider. This one is about the other role in the same protocol: the relying party — the app that sends users off to a provider and gets back a trustworthy answer to “who is this”.
bambamboole/laravel-oidc-client does that against any spec-compliant provider. Your
own laravel-oidc instance, Keycloak, Auth0, Okta, Entra ID — the client doesn’t know
or care which, because everything it needs comes out of the discovery document.
Why it’s a separate package
Because the two roles have almost nothing in common at the dependency level. An app that only wants to consume an identity provider should not be installing an OAuth2 authorization server, a token store, TOTP, QR code generation and WebAuthn just to put a “Log in” button on a page.
Splitting them also kept me honest about the protocol boundary: the client talks to the server exclusively through discovery, JWKS and the standard endpoints. It has no special knowledge of my own provider, which is the only way to know the provider is actually spec-compliant rather than compatible-with-itself.
Setup is five environment variables
OIDC_RP_ENABLED=true
OIDC_RP_ISSUER=https://id.example.com
OIDC_RP_CLIENT_ID=...
OIDC_RP_CLIENT_SECRET=... # optional — omit for a public client
OIDC_RP_REDIRECT_URI=https://app.example.com/login/callback
That’s it, and it’s deliberately the issuer, not a list of endpoints. The package
fetches /.well-known/openid-configuration, caches it, and derives the authorization,
token, JWKS and end-session URLs from there. The relying party stays off until
OIDC_RP_ENABLED=true, so installing it doesn’t hijack your login route by surprise.
The flow, and what’s checked
GET /login → store state + nonce + PKCE verifier in session
→ redirect to the provider's authorize endpoint
← /login/callback?code=…&state=…
→ verify state, POST the code + code_verifier to the token endpoint
← id_token + access_token
→ validate the id_token, resolve the local user, log into the guard
Every request uses PKCE (S256), a one-time state and a one-time nonce, and the
callback context is pulled from the session exactly once — so a replayed callback finds
nothing and fails.
The id_token is then validated properly, which is the part worth being pedantic
about: RS256 signature against the provider’s JWKS, plus iss, aud, azp, nonce,
sub, and exp/nbf/iat with configurable leeway. Skipping any one of those turns
“we use OIDC” into “we trust whatever JSON showed up”.
One detail I’m happy with: if the token’s kid isn’t in the cached JWKS, the client
fetches the key set once more before giving up. That’s all it takes for provider key
rotation — the overlap window from part 2 — to work without redeploying a single client.
The one seam
What the package can’t know is how a sub maps to a row in your database:
OidcClient::resolveUsersUsing(function (array $claims) {
return User::firstOrCreate(
['oidc_sub' => $claims['sub']],
['email' => $claims['email'], 'name' => $claims['name'] ?? null],
);
});
Without it, the default resolves the guard provider by sub — fine when the provider
and the app share user ids (self-SSO against your own instance), not fine otherwise.
Provisioning policy, claim mapping, what happens to an unknown user: all yours, in one
closure.
Logout is symmetric. POST /logout ends the local session and forwards to the
provider’s end-session endpoint with an id_token_hint. And in the other direction,
an opt-in back-channel logout endpoint accepts logout tokens pushed by the
provider and tears down the matching local session — immediately with a server-side
session driver, or on the next request via enforcement middleware. That’s how logging
out of one app can actually log you out of the others, instead of nominally.
From a session to an API token
The piece I built last is the one I use most: ApiTokenBroker.
$token = app(ApiTokenBroker::class)->accessToken(['tenant' => 'acme']);
That trades the session’s login token — via the RFC 8693 token exchange from part 2 — for a short-lived access token scoped to a specific audience. The result is cached in the session per audience and parameter set, and the login token is transparently refreshed first if it’s within 30 seconds of expiring.
The property that matters: your app code never handles a long-lived credential for the
downstream API, and the token it does get is only accepted by the audience it was
minted for. That’s exactly the shape an MCP server wants from part 1 — and it’s also
the answer to a question I left open in
Part 13 of the Lattice series, where remote components mint
short-lived, audience-scoped browser tokens from a issueBrowserToken() stub with a
comment saying call your real authorization server here. This is that authorization
server, and this is the call.
One sharp edge, documented because I hit it: the provider rotates refresh tokens on
use, and Laravel’s default session driver writes the whole session back without
merging. Two concurrent requests that both refresh can have the loser overwrite the
winner’s tokens with revoked ones. Guard broker routes with session locking
(->block()) and it goes away.
Next up, the last part: the auth UI — eight screens bound to Lattice pages, and the
composer require that ships React into your app without an npm package.