nl.idaas/openid-server
A Laravel/PHP package for building an OpenID Connect provider, with authorization, discovery, JWKS, token issuance, and user authentication integration for adding SSO and OIDC support to your applications.
First, confirm the direction of the integration. This package makes your Laravel app the OpenID Provider (the thing users log in to). If you actually need to log users in via someone else's IdP, you want league/oauth2-client or Socialite — this package will not help you.
Assuming you are building the IdP:
1. Check the dependency graph before writing code. The package is untouched since Jan 2022 and pins an older league/oauth2-server / lcobucci/jwt line. Run a dry install in a scratch branch:
composer require nl.idaas/openid-server --dry-run
composer why-not nl.idaas/openid-server
If you also run laravel/passport, expect a lcobucci/jwt version conflict. Resolve that first; everything else is wasted effort until Composer is green.
2. Generate keys. OIDC signing is RSA; you need a keypair plus 32 bytes of symmetric material for encryption:
openssl genrsa -out storage/oidc/private.key 2048
openssl rsa -in storage/oidc/private.key -pubout -out storage/oidc/public.key
php -r 'echo base64_encode(random_bytes(32));' # encryption key
Put paths in config/openid.php (you'll write this file — the package does not ship a Laravel service provider, config stub, or migrations).
3. Where to look first, in this order:
src/Repositories/*Interface.php — these are the contracts you must satisfy. Read every method before designing tables; they dictate the minimum columns.example/ directory in the repo (a Slim app). It is the de-facto documentation and shows the request/response wiring end-to-end.4. First use case: one client, one flow. Don't attempt all flows at once. Seed a single confidential client, implement ClientRepository and IdentityRepository over your users table, and get the authorization code + PKCE flow working against a throwaway second app on localhost:8001. That exercises the authorization endpoint, consent screen, token endpoint, id_token signing, and userinfo in one loop.
Register these routes (each with its own controller — they have very different middleware needs):
GET /.well-known/openid-configuration
GET /oauth/jwks
GET /oauth/authorize
POST /oauth/token
GET /oauth/userinfo
Repository-over-Eloquent is the whole job. The package is deliberately storage-agnostic, so you write ~6 repository classes backed by models. A workable schema:
// oauth_clients
identifier, name, secret, redirect_uri (text), grant_types (json),
scopes (json), confidential (bool), allow_implicit (bool)
// oauth_auth_codes / oauth_access_tokens / oauth_refresh_tokens
// follow the interface's requirements: id, expiry, user_id, client_id, scopes, revoked
Bind them in a dedicated provider and keep them singleton so the server instance is reused per request.
Build the PSR-7 request from the Laravel request. The library speaks PSR-7, not Laravel. Use symfony/psr-http-message-bridge + nyholm/psr7 (Passport does the same thing — you can lift its RequestBridge pattern almost verbatim):
$psrRequest = (new PsrHttpFactory($psr17, $psr17, $psr17, $psr17))
->createRequest($request);
Identity mapping. Your IdentityEntity implementation's getIdentifier() becomes the sub claim. Keep it an opaque, immutable, never-reassigned value — a users.id cast to string is fine; an email or username is not. If the client requests pairwise subject type, derive a per-client hash instead.
Claims strategy. Don't dump the whole user model into the token. Model claims per scope (profile, email, phone, address) and intersect with what the client is allowed to see and what the user consented to. The claims request parameter lets clients ask for specific claims — honour it as a filter over an allowlist, never as an instruction to emit arbitrary attributes.
Consent as a first-class table. Store (user_id, client_id, scopes, expires_at) and skip the consent screen on subsequent logins. This is what makes the IdP feel invisible in day-to-day use. Handle prompt=none explicitly: no session → redirect back to the client's redirect_uri with error=login_required, never a 500 or a login page.
Discovery must be generated, not hardcoded. Build /.well-known/openid-configuration from route() calls so issuer, authorization_endpoint, jwks_uri, token_endpoint can never drift from reality. Emit only the grant_types_supported, response_types_supported, scopes_supported and claims_supported you actually implement — clients trust this document and will pick a flow based on it.
Your Laravel session is the SSO session. That's the payoff of hosting the IdP in the same app: Auth::check() at /oauth/authorize is the entire "is the user logged in" question. Protect the route with the same auth middleware as the rest of the app so cookie/session config is shared.
Testing pattern. Two layers:
iss, aud, sub, exp, nonce, at_hash with firebase/php-jwt and the public key.league/oauth2-client, and drive the full redirect dance with Pest/Dusk. This catches the issuer/redirect-URI mismatches that unit tests never see.Machine-to-machine. If you need client_credentials for service calls, register it as a distinct grant type on the client record and return a token without an id_token. Keep it out of the human login path so auth middleware never gets involved.
AGPL-3.0 is the biggest one. This is network copyleft: if you run a modified version as a public-facing service, you're obliged to offer the corresponding source to users. Fine for internal tooling or an open-source product; a legal review item for a commercial SaaS. Decide this before you build on it, not after.
Zero dependents means zero second opinions. Issue answers, Stack Overflow posts, and blog walkthroughs don't exist. Budget real hours for reading src/ and the example app. Treat every class name above as "verify in the source" rather than gospel.
Issuer exactness. The iss claim, the issuer field in discovery, and the base URL clients use must match byte-for-byte — trailing slash, scheme, and port included. Behind a load balancer, configure TrustProxies and force https in AppServiceProvider, otherwise you'll emit http:// issuers that strict clients reject with an opaque error.
kid and key rotation. The JWT header's kid must correspond to an entry in your JWKS. Publish the old key alongside the new one for at least one token lifetime during rotation, or every client breaks at once. Cache the parsed private key — re-reading and parsing a PEM on every token request is measurably slow.
Catch OAuthServerException, always. The library signals errors by throwing, and each exception renders itself as a proper {"error": "...", "error_description": "..."} response. Don't let it fall through to Laravel's handler as a 500. Wrap endpoints and return $e->generateHttpResponse($psrResponse). Log getHint() — the public error_description is intentionally vague.
Redirect URI matching is exact-string. No trailing-slash tolerance, no wildcards. Validate the incoming redirect_uri against the registered value before anything else, and never echo a client-supplied URI into a redirect without that check — that's a live open-redirect.
Don't touch $_SESSION or session() inside the server config. Everything the library needs must arrive on the PSR-7 request. Mixing in Laravel session state mid-flow produces non-reproducible bugs.
Clock skew. Set a small leeway (30–60s) for iat/nbf validation, especially if the token issuer and clients sit on different hosts. Conversely, if you see iat in the future in your own tokens, check the container's clock.
PKCE for public clients. Browser/mobile clients must use S256. Confirm the pinned library version actually enforces it for public clients rather than merely accepting it — older league/oauth2-server releases were lax here.
CORS scoping. jwks, discovery and userinfo need CORS headers for browser-based relying parties; the token endpoint does not (it's server-to-server, and adding CORS there invites confusion). Don't blanket-apply HandleCors.
Debugging toolkit. Decode id_tokens at jwt.io with a throwaway keypair only. mitmproxy or oauth.tools' flow debugger will save hours versus reading logs. Log the full authorize request (client_id, redirect_uri, scope, response_type, nonce, state, code_challenge) — nearly every integration bug is visible in that one line.
Extension points worth knowing. Custom grant/response types are added by implementing the corresponding interfaces and registering them with the server; custom claim sets let you inject acr/amr for step-up auth. Before you go there, though: if you only need "log in with my app" SSO and a couple of claims, a thin OIDC layer over laravel/passport may cost less than owning this dependency, especially given the AGPL and the 2022 freeze.
How can I help you explore Laravel packages today?