OAuth2 clients & PKCE
The server is a full OAuth2 identity provider built on
league/oauth2-server — not Passport. This guide covers the grant
flows it issues. OAuth code lives in src/Domain/OAuth/.
Grants
Enabled in config/iam.php under oauth.grants:
| Grant | Use | Default |
|---|---|---|
authorization_code (+ PKCE) |
Interactive apps & SPAs | on |
client_credentials |
Service-to-service | on |
refresh_token |
Long-lived sessions, encrypted & rotated | on |
PKCE (S256) is required for public clients (oauth.require_pkce). Auth-code TTL defaults to 10
minutes (oauth.auth_code_ttl), and the OAuth endpoints are rate-limited (oauth.rate_limit, default
60/min).
Authorization-code + PKCE flow
- Generate the PKCE pair in the client (
code_verifier, thencode_challenge = S256(verifier)). - Redirect to authorize
GET https://iam.example.com/oauth/authorize ?response_type=code&client_id=warehouse-spa &redirect_uri=https://app.example.com/callback &scope=openid%20profile&code_challenge=...&code_challenge_method=S256 - Exchange the code
curl -X POST https://iam.example.com/oauth/token \ -d grant_type=authorization_code -d client_id=warehouse-spa \ -d code=$CODE -d code_verifier=$VERIFIER \ -d redirect_uri=https://app.example.com/callback
Client-credentials (services)
For machine-to-machine callers with no user:
curl -X POST https://iam.example.com/oauth/token \
-d grant_type=client_credentials \
-d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d scope=warehouse.read
ClientAuthenticator validates the client; the issued access token is a JWT your services verify against
JWKS.
Refresh-token rotation
Refresh tokens are encrypted at rest (RefreshTokenCrypto) and rotated on use:
curl -X POST https://iam.example.com/oauth/token \
-d grant_type=refresh_token -d refresh_token=$REFRESH -d client_id=warehouse-spa
# → new access_token + new refresh_token; the old refresh token is invalidated
Client-secret rotation & expiry
A client secret is issued once at manifest apply and stored hashed. Rotate it — on a leak, on a
schedule, or before its expiry — without downtime: the previous secret stays valid for a grace
window so the app can roll over.
# Rotate → a NEW secret is returned once; the OLD one keeps working until the grace ends.
curl -X POST https://iam.example.com/api/iam/v1/applications/warehouse/rotate-secret \
-H "Idempotency-Key: $(uuidgen)"
# → { "data": { "client_id": "cli_warehouse", "client_secret": "NEW-…-ONCE", "grace_until": "…" } }
Rollover procedure (zero downtime): rotate → deploy the new secret to the app during the grace window
(iam.oauth.client_secret_grace, default 72h) → after the grace the old secret stops validating.
validateClient accepts either secret while the grace is active.
Scheduled expiry & alerts. Set IAM_OAUTH_CLIENT_SECRET_TTL (seconds) to give new secrets a lifetime;
GET /api/iam/v1/applications/{app}/client reports secret_status (ok · expiring · expired ·
revoked · public), the auth method (token_endpoint_auth_method / uses_private_key_jwt) and
secret_expires_at, which the console surfaces as rotation alerts. Expiry is soft (it
drives alerts; the secret keeps working so an un-rotated app never breaks unexpectedly) — the grace end
is the only hard cut-off, and only for the previous secret.
Revoke a client immediately (kills all its auth):
curl -X POST https://iam.example.com/api/iam/v1/applications/warehouse/revoke-client -H "Idempotency-Key: $(uuidgen)"
Rotation/revoke require iam:clients.manage; reading credential status requires iam:applications.read.
Automatic rotation (no admin, no downtime)
Opt a client into auto_rotate (with a rotate_interval_days) and the scheduler rotates it for you — no
manual step, no one tracking expiries. Because no human receives the new secret, the server keeps it
encrypted at rest so the app can self-fetch it during the grace window:
- Schedule the command (host):
$schedule->command('iam:rotate-due-secrets')->daily();— it rotates due
clients and clears pending ciphertexts whose grace has lapsed. - On rotation, the new secret is stored encrypted; the previous secret stays valid for the grace.
- The app fetches the new secret with its still-valid current secret and hot-swaps:
curl -X POST https://iam.example.com/oauth/client-secret \
-u "cli_warehouse:$CURRENT_SECRET"
# → { "rotated": true, "client_secret": "NEW-SECRET", "grace_until": "…" } (or { "rotated": false })
Only the legitimate client — the one holding a valid secret — can retrieve the rotated one (validateClient
is the gate; there is no user/PDP auth here, it’s client authentication). laravel-iam-client does this
fetch-and-swap automatically. The pickup is one-time: the new secret is returned once and then cleared,
so a leaked old secret can’t roll forward for the whole grace. The response carries Cache-Control: no-store.
The endpoint is opt-in — enable it with IAM_OAUTH_CLIENT_SELFFETCH=true on deployments that use
auto-rotation (off by default → 404). Auto-rotation is a hygiene control (bounded secret lifetime), not
incident response: on a suspected leak, revoke the client (revocation kills the self-fetch too) rather
than relying on rotation. For a shared secret you never want to rotate at all, use asymmetric
private_key_jwt instead — no shared secret to store, rotate, or leak.
Token signing & JWKS
Access tokens are signed with ES256 using rotating signing keys (iam_signing_keys). Consumers fetch
the public keys from the JWKS endpoint and verify offline — no introspection round-trip required for the
common path. See OAuth2 & OIDC architecture.
OAuth must remain league/oauth2-server, and the OIDC layer uses the MIT steverhoades base. AGPL code
(limosa-io) is forbidden in this codebase — a hard ecosystem rule.
A SPA or mobile client cannot keep a secret. With oauth.require_pkce on (the default) the server rejects a
public-client auth-code exchange without a valid code_verifier. Never embed a client secret in a public
client.
Next
- OIDC login — the identity layer on top of these tokens.
- Sessions & step-up — revocable sessions and AAL.
- OAuth2 & OIDC architecture — keys, JWKS, ES256 in depth.