# Legatus: security review

Reviewed 9 November 2026 (plan LG, slice LG4), against the code on `develop` after LG3. Plan LG's last slice asks for a fresh reader's
review of the client library every product will install: sign-in through `id`, the sessions Atrium can end, the API client, the entitlement
kept for when Atrium is away, the server's report and the test kit. A dated record: review again before the first product installs it,
and whenever the sign-in flow, the webhook contract or the client's retry rules change.

**Method.** Two adversarial reads, in parallel, by reviewers who hadn't written the code and were given its intent and its files, not an
opinion of it: one of sign-in and sessions (read against Atrium's provider), one of the client, the entitlement store, the report and the
fake (read against each endpoint Atrium serves). Each finding was checked against the code before it was accepted, and every accepted
finding has its test in `tests/Feature/Legatus/ReviewTest.php` (or `tests/Feature/Accounts/OidcTest.php` for Atrium's side), named for
it. The reviewers scored the paths as they found them: sign-in and sessions **7 / 10**, the client and the kit **7 / 10**.

Before the reviewers, the end-to-end check in a real browser had already found one fault, in Atrium: a password given at `id` on the way
back to a product returned through `/oauth/authorize` inside an Inertia XHR, which can't follow the redirect to the product's host, so
sign-in stalled on the form. `HandleInertiaRequests` now answers such a redirect with a full page visit.

**Verdict.** **Ready for the first product to install.** The core held from the start: PKCE, a `state` used once and bound to the host and
the organisation, the ID token checked with RS256 keys only (no `none`, no HMAC), its issuer, audience, nonce and expiry; key refetches
that can't storm; session fixation covered; webhook signatures compared in constant time with a five-minute window. No serious fault. Seven
moderate findings in each read, all fixed, and the minor ones too, except the two accepted below.

---

## Scoring scale

As the foundation's: 1–3 unsafe for production with personal data; 4–6 staging or a friendly pilot; 7–8 production-ready with documented
caveats; 9–10 enterprise-grade with formal compliance.

## What was found, and what was done

| # | Finding | Severity | Done |
|---|---|---|---|
| sign 1 | The recheck kept every session open on any refusal, so a suspended organisation's people stayed signed in for good | Moderate | A 403 for a good key (the organisation no longer holds the product) ends the session (`not_served`); a 401, the key itself, still keeps it |
| sign 2, call 4 | A success that wasn't JSON (a host's maintenance page) was a `TypeError` on every signed-in page | Moderate | Every success must be JSON with `data`, or it's `Unavailable`: no answer, so the session is kept and asked again in a minute |
| sign 3 | Thirty sign-in steps a minute from one address: a class behind one school address would be refused | Moderate | 300 a minute (`ATRIUM_SIGN_IN_PER_MINUTE`) |
| sign 4, call 5 | The recheck tried three times with ten seconds each: Atrium hanging cost a page half a minute, once a minute | Moderate | A page's call is tried once, three seconds (`Client::quick()`) |
| sign 5 | A session carried to another organisation's host (a cookie across subdomains) stayed signed in there | Moderate | Ended (`other_organisation`) |
| sign 6 | The sign-out address carried the ID token, with a name, phone and e-mail, into a shared computer's history and `id`'s logs | Moderate | Atrium also hands back `signout_hint`, the same signature, audience and account with no details, which the package keeps instead of the ID token |
| sign 7 | A session Atrium ended stayed signed in on any route without `atrium.session` | Moderate | The recheck joins the `web` group by itself (`atrium.recheck_group`) |
| call 1 | Atrium's "still running under this key" (409) read as a refusal, inviting a retry with a new key and a second text | Moderate | Waited for (up to 5 seconds, by `Retry-After`), then `Unavailable`, never `Refused` |
| call 3 | The entitlement's views were ordered by two clocks: a GET answered before a change and landing after its webhook replaced it; a 404 never cleared an old plan | Moderate | Atrium stamps each view with `as_of`, read before the entitlement, to the microsecond; the package keeps the newer in one conditional update; "holds nothing" is kept |
| call 6 | Several of the fake's answers (bulk sends, e-mail, jobs, a batch's file) weren't Atrium's shapes; a second `fake()` was ignored | Moderate | Corrected from Atrium's own code; one fake a test; the shape test covers more calls |
| sign 9 | A password given once lasted the whole session for `atrium.password` | Minor | `ATRIUM_PASSWORD_MINUTES` (15): older sends the person back with `max_age`, which Atrium now honours, saying "again" on its page |
| sign 10 | A throttled membership check said the person didn't belong | Minor | `unavailable` |
| sign 11 | Signing out by GET would let a link on any site sign someone out of `id` and every product | Minor | POST only (405 otherwise) |
| sign 12 | Two copies of one delivery at once both acted | Minor | Claimed before acting, let go if acting fails |
| sign 13 | A key set with no keys was cached a day | Minor | Never cached |
| sign 14 | The page to return to ignored the port; an ID token's `iat` wasn't required | Minor | Host and port; required |
| sign 15 | Sessions' own IDs (bearer secrets) sat in plain text in `legatus_session` | Minor | Encrypted with the app's key |
| sign 16 | A password reset at `id` left product sessions running | Minor | It ends them everywhere (`account.signed_out`, `everywhere`) |
| sign 8 | Step-up kept the old session record in the store | Minor | `regenerate(destroy: true)` |
| call minors | A text refused over the limit said only "422"; a voucher check was retried (a miss counted twice); a POST given a key wasn't retried; an empty figure or a long release had every report refused; `for('')` sent no organisation | Minor | The reason in the message; a check never retried; a key makes a POST safe; such figures dropped and the release cut to 60; refused |

## Accepted

- **A send's key is kept for good by Nuntius (call 2).** The same key later, with other words, answers with the first message and sends
  nothing. That's NS-8's design, so a product's outbox can retry after a long outage without texting twice. The package's documents say a
  key names one message (`fee-reminder-{invoice}-{date}`), never a kind of message.
- **Checking then redeeming a wrong code counts two misses (call minors).** Both are attempts; the docs say to redeem directly where a
  check adds nothing.

## What holds

PKCE S256 enforced on Atrium's side too; `state` keyed flows used once, bound to host and organisation; the ID token's algorithm taken from
the key, RS256 only; issuer, audience with `azp`, nonce with `hash_equals`, `sub`, `exp` and `iat`; refetch at most once in five minutes;
sign-in regenerating the session; the page to return to on this host only; webhook signatures in constant time, five minutes, two secrets
in rotation; the idempotency key made once per call and kept across its tries; codes never in an address; redirects never followed with the
key; the report's reads guarded for shared hosting.
