# CL-P · The OIDC proof (plan CL, CL-7)

**Status:** run 7 November 2026, at the Architect's word. **Verdict: go, with option B.** Throwaway: it runs on branch `proof/oidc-throwaway`, in its own folder and database, and is never merged. Only this record and the verdict stay.

## What it must answer

Go means every line below passes with one option. No-go on an option means it's dropped; no-go on both means Clavis's shape changes before mockups.

| # | Question | Passes when |
|---|---|---|
| 1 | Does it install and run on Laravel 13 and PHP 8.5, beside Fortify and Sanctum? | `composer require` resolves, migrations run, the suite's sign-in tests still pass |
| 2 | Can Atrium's schema stand behind it? | The singular `user` table, and the token's `sub` is the account's typed public ID, never the row's number |
| 3 | Does it speak OIDC? | Discovery, JWKS, a signed `id_token` a plain client verifies, with `nonce`, and `userinfo` |
| 4 | Return addresses per server and per tenant | One client per `server`. Its allowed addresses come from the database at the moment of the request: the instance's host and its custom domains. Anything else is refused before sign-in |
| 5 | PKCE | Required. A code redeemed without the verifier fails |
| 6 | A phone-only account | An account with no e-mail signs in with a code at `id` and arrives at the product |
| 7 | Strength asked by the product (CL-5) | A product sends `acr_values`; `id` asks for the password only then, and the token says what was proved in `acr` and `amr` |
| 8 | Two domains | Two products on different hosts each sign the same person in through `id`; a second sign-in at the other product doesn't ask again |
| 9 | Signing out (CL-8) | RP-initiated logout ends the session at `id`, and the product's session with it |

## How

- Option A: `laravel/passport` 13.10 with `jeremy379/laravel-openid-connect` 3.3.
- Option B: `laravel/passport` alone, with Atrium's own layer for the `id_token`, discovery and JWKS, on the `lcobucci/jwt` that `league/oauth2-server` already brings.
- The provider at `id.localhost:8105`. Three plain-PHP stand-ins that verify the token themselves: Campus at `jaeda.localhost:8106`, a Campus school's own domain at `portal.sais.localhost:8107`, and Loop at `alumni.localhost:8108`.
- The phone code is written to the log, not sent: Nuntius is proven separately.

## Verdict

**Go, with option B: Passport, and a thin OIDC layer of Atrium's own.** Drop `jeremy379/laravel-openid-connect`. CL-7 stands as decided, and Clavis's shape doesn't change.

| # | Question | A: Passport + jeremy379 | B: Passport + our layer |
|---|---|---|---|
| 1 | Laravel 13, PHP 8.5, beside Fortify and Sanctum | ✓ | ✓ |
| 2 | Atrium's schema; `sub` is the typed ID | ✗ The `id_token`'s is, but Passport's access token names the row number | ✓ Both tokens say `acc_…` |
| 3 | Discovery, keys, a signed `id_token` with `nonce` | ✓ Only once a key ID is set by hand; out of the box a standard verifier refuses its tokens | ✓ The key ID follows the key, so rotation needs nothing |
| 4 | Return addresses per server and per tenant, live | ✓ | ✓ |
| 5 | PKCE required | ✓ | ✓ |
| 6 | A phone-only account | ✓ | ✓ |
| 7 | Strength asked by the product, said in the token | ✗ No `acr`, `amr` or `auth_time`; adding them means subclassing its two core classes | ✓ |
| 8 | Two domains, one sign-in | ✓ | ✓ |
| 9 | Signing out at `id` | ✓ | ✓ |

**Shown in the browser**, as well as by the same five tests run against each option:
- Esi, with no e-mail, signed in to Campus by code. Campus checked the token itself against `id`'s public key.
- She then reached the school's own domain and Loop with no second sign-in.
- Loop was refused when it tried to send her back to Campus's address, before any sign-in.
- Kofi signed in by code and got `acr: code`. A staff page sent him back to `id` for his password alone, and the new token said `acr: password`, `amr: [sms, pwd]`.
- Signing out at any product ended the session at `id`.
- The session cookie at `id` is `__Host-` prefixed, Secure, HttpOnly and SameSite.

**Why B over A.** A is about 1,360 lines and does the easy half. What Clavis needs beyond that, the proof of strength and the account's ID in both tokens, means overriding its core. B is about 220 lines on Passport's own hooks, each of them read and tested.

**What Clavis must do, found here:**
- **Adding Passport moves the lock file.** `composer require -W` also took Laravel from 13.38 to 13.40, so the dependency step is its own commit, with the full suite.
- **Load only Passport's routes that are used.** Its route file also brings the device flow and JSON client and token management. The proof took the whole file; Clavis should register what it needs.
- **No `userinfo` endpoint, for now.** Passport's guard needs its own `HasApiTokens` trait, which clashes with Sanctum's on `User`. The `id_token` carries what products need, and if `userinfo` is ever wanted it can check the token with Passport's resource server, without the trait.
- **Atrium's `User` assumes an e-mail.** The column, the unique index and the model's e-mail mutator all fail for a phone-only account. This is CL-1's work, as expected.
- **The tenant gate must let someone with no organisation reach sign-in.** The proof opened `passport.*`, `openid.*` and `proof.*` in `ResolveTenant`.
- **PKCE is enforced by a guard of ours.** League requires it only of public clients, and products are confidential.
- **What a sign-in proved is kept per code** in a row of its own (`oidc_auth_context`), read once by the token endpoint. A refresh issues no new `id_token`.
- **Passport's client IDs are its own UUIDs.** One client per `server`, linked by `server_id`.

**Not proven here.** Back-channel sign-out to products uses the signed webhooks Atrium already has, and wasn't re-run. Neither were Turnstile, Google, Microsoft or passkeys (CL-6, CL-10), real SMS, or HTTPS hosts.

**Where it is.** Branch `proof/oidc-throwaway`, committed locally and never pushed, in `/Users/reji/dev/Tutelora/atrium-oidc-proof` with database `atrium_proof`. The code to carry into Clavis is `app/Proof/Oidc*.php`, `Jwks.php` and `AccountAccessToken.php`, rewritten to the house rules.
