One Laravel Broker for OIDC and SAML: Normalizing Two Protocols to One Identity Contract

Jul 18 / 1 min read

Language Mismatch Disclaimer: Please be aware that the language of this article may not match the language settings of your browser or device.
Do you want to read articles in English instead ?

If you build software for large organizations long enough, you stop assuming there is one identity provider. There are usually at least two, and they rarely speak the same protocol. There is a modern cloud provider that speaks OIDC, and there is an older one that speaks SAML, kept alive by some business unit that has very good reasons not to migrate and will not be moved by your architecture diagram.

The instinct is to teach each application both protocols. Resist it. Every app that learns OIDC and SAML has to duplicate token validation, claim mapping, session handling, and logout, and each copy drifts. What you want instead is one Laravel broker that both protocols flow through, and a single internal identity contract that everything downstream depends on. I have built exactly this for a global pharma content platform as its Solutions Architect; the case study covers the engagement. This post is the reusable pattern underneath it, written so you can build your own.

The core idea: normalize to one internal claim contract

The whole design rests on one decision. Downstream applications should never learn how a user authenticated. Whether the login came in over OIDC or SAML is the broker's problem, and only the broker's problem. Everything past the broker consumes one shape.

So the first thing to define is that shape. Not a SAML assertion, not an OIDC ID token, but your own internal contract that both get mapped onto.

final readonly class Identity
{
    public function __construct(
        public string $subject,        // stable internal user id
        public string $email,
        public string $displayName,
        /** @var array<int, string> */
        public array $roles,
        public string $sourceProtocol, // 'oidc' | 'saml', for audit only
        public string $issuer,
    ) {}
}

Note sourceProtocol is on the object, but it is labelled for audit only. Application code is not allowed to branch on it. The moment a controller writes if ($identity->sourceProtocol === 'saml'), the abstraction has leaked and you are back to two systems. Keep that field for the audit log and nothing else.

Two adapters behind one interface

Each protocol gets an adapter, and both adapters return the same Identity. The interface is deliberately small: start a login, and finish one.

interface ProtocolAdapter
{
    // Where to send the browser to begin authentication.
    public function redirectTo(AuthRequest $request): RedirectResponse;

    // Validate the provider's response and produce a raw, protocol-shaped result.
    public function handleCallback(Request $request): ProviderResult;
}

The OIDC adapter does the authorization-code exchange, validates the ID token signature against the provider's JWKS, checks iss, aud, exp, and the nonce, and hands back the claims. The SAML adapter validates the signed assertion against the identity provider's certificate, checks conditions and audience restriction, guards against replay, and hands back the attribute statement. Two completely different mechanisms, but they resolve to the same next step.

I lean on mature libraries here rather than hand-rolling protocol code. Socialite-style providers cover the OIDC exchange; a dedicated SAML package handles assertion parsing and signature validation. Writing your own SAML verification is how you ship a vulnerability. The adapter's job is not to reimplement the protocol. It is to wrap a trustworthy implementation and produce a ProviderResult.

The claim mapper is where the real work lives

Different providers describe the same human differently. OIDC gives you sub, email, name, and maybe a groups claim. SAML gives you a nameID plus a bag of attributes with URN keys that vary by provider. The claim mapper is the single place that reconciles this, and it is per-provider configuration rather than code.

final class ClaimMapper
{
    /** @param array<string, mixed> $config provider-specific mapping */
    public function __construct(private array $config) {}

    public function toIdentity(ProviderResult $result): Identity
    {
        $claims = $result->claims();

        return new Identity(
            subject: $this->resolveSubject($claims),
            email: (string) data_get($claims, $this->config['email']),
            displayName: (string) data_get($claims, $this->config['name']),
            roles: $this->mapRoles((array) data_get($claims, $this->config['groups'], [])),
            sourceProtocol: $result->protocol(),
            issuer: $result->issuer(),
        );
    }

    /** Provider group names in, internal roles out. */
    private function mapRoles(array $providerGroups): array
    {
        return array_values(array_unique(array_filter(array_map(
            fn (string $group): ?string => $this->config['role_map'][$group] ?? null,
            $providerGroups,
        ))));
    }
}

Keeping the mapping in configuration is what turns onboarding a new provider or a new application into a config change rather than an engineering project. You describe where this provider keeps the email, which attribute holds groups, and how its group names translate to your internal roles. The subject resolution deserves care: providers hand you their own identifier, and you want a stable internal subject that survives a user moving between providers, so resolve to your own user record rather than trusting the raw external id as a primary key.

Session at the edge, JWT for service calls

Once you have an Identity, there are two very different things you might do with it, and conflating them is a common mistake.

For the interactive browser edge, issue a normal server-side session. This is not the boring choice, it is the correct one. A pure stateless-token model at the browser makes logout and revocation genuinely hard: you cannot un-issue a token that has already been minted, so you end up building denylists and short expiries to paper over it. A server-side session gives you immediate revocation. Kill the session record and the user is out on their next request, across every app that shares the broker.

// After the broker resolves an Identity for an interactive login:
$user = $this->users->upsertFromIdentity($identity);

Auth::login($user);                       // server-side session at the edge
$request->session()->regenerate();        // fixation guard
$this->audit->record('login', $identity); // one audit surface

For service-to-service and API calls, where holding server-side session state is the wrong shape, mint a short-lived signed JWT instead. The internal service that receives the call validates the signature and the expiry and trusts the claims without a round trip back to the broker. The key word is short-lived. Because these tokens expire quickly, the revocation gap stays small, and the immediate-revocation guarantee still lives where it matters, at the session edge.

$token = JWT::encode([
    'sub'   => $identity->subject,
    'roles' => $identity->roles,
    'iss'   => 'auth-broker',
    'aud'   => 'internal-services',
    'iat'   => now()->timestamp,
    'exp'   => now()->addMinutes(5)->timestamp,
], $this->signingKey, 'RS256');

So the model is: session for the human at the edge where you need immediate revocation, stateless JWT for machines where you need statelessness, and both carry the same internal claims because both came from the same Identity.

Revocation and audit, in one place

Two properties fall out of this design almost for free, and they are the ones a security review actually asks about.

Revocation lives at the edge. Because interactive access is a session and not an un-recallable token, disabling a user is one operation with immediate effect everywhere. You are not chasing tokens across services or waiting for expiry. The short JWT lifetime bounds the service-call exposure to minutes.

Audit lives in one place. Every authentication, whatever protocol carried it, passes through the broker, so there is exactly one code path that records who signed in, from which issuer, over which protocol, and when. When someone asks you to prove who can authenticate and how, you have a single, concrete answer instead of stitching together logs from every application. In a regulated environment that single surface is not a nicety, it is the entire compliance argument.

What you get

Put together, the shape is small. One broker. Two adapters behind one interface. A configuration-driven claim mapper that both adapters feed. One internal Identity contract that everything downstream consumes. Sessions at the browser edge for immediate revocation, short-lived JWTs for stateless service calls, and a single audit path.

The payoff is that onboarding a new application to full single sign-on stops being protocol work and becomes configuration, because the token validation, claim mapping, revocation, and audit already exist in one place. Adding a third identity provider later is a new adapter and a mapping entry, not a rewrite. And when the business unit that refused to leave SAML finally does migrate, you delete an adapter and change nothing else, because no downstream app ever knew the difference. That last property is the real reason to normalize both protocols to one contract, and it is why I keep reaching for this pattern whenever the phrase "we have more than one identity provider" comes up.