Skip to content

test(auth): finish the P1 matrix and pin 11 behaviours the specs never asserted - #33

Merged
camreeves merged 6 commits into
masterfrom
PPT-2536-saml-conditions
Aug 6, 2026
Merged

test(auth): finish the P1 matrix and pin 11 behaviours the specs never asserted#33
camreeves merged 6 commits into
masterfrom
PPT-2536-saml-conditions

Conversation

@camreeves

@camreeves camreeves commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Spec-only — no source changes. Closes matrix rows ID-04, RF-10, PK-05, PK-06, OI-04, OI-05, OI-06, OI-09, OI-11, MT-03, IR-04, AU-07.

Every one of these was a row where nobody had ever written down what auth.cr actually does. In eight cases it turned out to differ from what the RFC, the discovery document, or my own reading of the code said. Nothing here is exploitable — several are safer than the spec requires — but each is something an integrator would otherwise discover in production.

I was wrong about three of them before testing, which is the argument for the specs existing.

Enforced and now locked down

ID-04 — SAML assertion Conditions. Every check in crystal-saml is guarded by a skip if not configured clause (validate_audience returns true when sp_entity_id is nil, and so on), so which ones are live depends entirely on what build_saml passes. Now asserted: NotOnOrAfter, NotBefore, Audience (another SP), Destination (another deployment's ACS) — each with a valid-window control, without which a build that refused everything would pass all five. Relevant to UCLA, which is SAML/Shibboleth.

MT-03 / IR-04 — tenant boundaries. One process serves eight authorities. Cross-tenant introspection answers {"active": false} with no scope/client_id/exp and no trace of the foreign user or client in the body.

AU-07 — the deny path. DELETE /auth/authorize is session-gated and bounces to login rather than emitting access_denied. That distinction matters: an unauthenticated deny reaching the client would let anyone fabricate a user's refusal.

OI-06 — userinfo. GET and POST return identical bodies, and sub matches the ID token's sub.

Divergences, pinned rather than fixed

Each is deliberate, with the reason it wasn't fixed here.

Row What actually happens Why it's pinned
MT-03 The session cookie is not authority-boundnew_session stores uid/exp/iat and one process-wide secret, so an A-session validates at B A browser never sends it cross-host, and the token minted from it carries aud = the user's authority, so it's refused at B. The tenant boundary is on the token, not the session — asserted end-to-end. Anyone adding a session-only surface inherits this
RF-10 The scope parameter is ignored on refresh, both directions — oauth.cr never forwards it to Authly.access_token Errs safe. Making narrowing work means forwarding it, and validate_scope! only checks the client registration, not the granted scope — so forwarding without a subset check would create an escalation. See below
PK-05 / OI-09 plain is accepted although discovery advertises S256 only Not exploitable across clients (the method is baked into the code at authorize time), but a capability document is a promise
PK-06 An unknown method mints a code that can never be redeemed → 401 unauthorized_client Fails closed. The cost is diagnosability: that error sends an integrator to their client registration
OI-04 nonce is silently dropped — no authorize param, no field on the code, never emitted Any RP library that sends one (most do) will reject our ID token. Safe direction; no PlaceOS client sends one
OI-05 A token outliving its user gives 404 {"error":""}, not the unknown subject 401 the code intends — User.find raises, escaping the rescue JWT::Error RFC 6750 §3.1 wants 401. The fix is a semantic call (is a userless token authenticated?) that needs the guest-scope path considered too
OI-11 Issued tokens carry no kid while the JWKS publishes one ⚠️ Latent blocker on key rotation. Works only because there is exactly one key; the moment the JWKS holds two, kid-less tokens are ambiguous and strict RPs reject them. Fix this before any rotation
ID-04 The declared <saml:Issuer> is never validated (idp_entity_id is never passed), and there is no one-time use — an assertion replays until NotOnOrAfter Issuer: needs a column + a value for every strat, i.e. a migration. Replay: Ruby behaved the same and Shibboleth issues short windows — the accepted SAML bearer model

The near-miss worth reading

I went looking for a privilege escalation in RF-10 and did not find one, but only by accident. validate_scope! checks a requested scope with Authly.clients.allowed_scopes?, which asks "may this client ever hold this scope" — the client registration — not "was this scope granted to this token". Doorkeeper checked the latter (RefreshTokenRequest#validate_scope against refresh_token.scopes). It is safe today purely because the parameter never reaches the grant.

So: if anyone implements scope narrowing, the subset check has to land in the same commit. The spec says so at the point where someone would make that change.

Verification

  • 322 examples, 0 failures, 1 pending (the known AU-11 RFC-SHOULD half).
  • crystal tool format --check src spec clean.
  • The "does NOT" specs assert current behaviour deliberately and are named so they read as findings, not approvals.

Matrix state

Every P1 row is now closed except three, all blocked outside this repo: ID-13 (needs DEV_SSO_EMAIL/DEV_SSO_PASSWORD — the spec is written and waiting), CFG-03 and CFG-05 (the parked helm/compose changes).

P2 remaining: AU-12, TK-09, AZ-07, SC-09, SC-11, ID-10, ID-12, ID-14, AK-03, AK-05, SEC-06, plus AZ-08/AZ-09 which belong to rest-api and staff-api.

Also outside this diff: SC-06 is now automated in tasks/PPT-2536/config-parity/check.sh, and 17 P1 rows turned out to be already covered despite ❌/◐ marks — ID-03 (SAML signature enforcement) was marked "❌ deferred" while carrying five assertions including defect 1's own regression test. All re-marked with the covering example named.

🤖 Generated with Claude Code

camreeves and others added 2 commits August 6, 2026 21:36
A SAML assertion is a *bearer* credential — anyone holding the bytes can
present them. Everything that stops a captured assertion being reused lives
in `Conditions` and the surrounding envelope, and every one of those checks
in `crystal-saml` is guarded by a "skip if not configured" clause
(`validate_audience` returns true when `sp_entity_id` is nil,
`validate_destination` when the ACS URL is nil, `validate_issuer` when
`idp_entity_id` is nil). Which of them are live therefore depends entirely
on what `external_providers.cr#build_saml` passes, and nothing asserted
that. This pins it — both where it holds and where it does not.

Matters directly for UCLA, which is a SAML/Shibboleth deployment.

Enforced, now covered:
  * NotOnOrAfter in the past      -> refused
  * NotBefore in the future       -> refused
  * Audience naming another SP    -> refused (sp_entity_id comes from
                                     strat.issuer; blank would disable it)
  * Destination naming another    -> refused
    deployment's ACS
  * valid window                  -> admitted (the control — without it a
                                     build that refused everything would
                                     pass all five rejection cases)

Gaps, pinned so they stay deliberate rather than accidental:

  * **The declared Issuer is never validated.** `build_saml` passes
    sp_entity_id, idp_cert and idp_cert_fingerprint but no `idp_entity_id`,
    so `validate_issuer` short-circuits and `<saml:Issuer>` is decorative.
    Not currently exploitable — the signature must still verify against the
    strat's pinned cert — but it becomes load-bearing the moment a strat
    trusts more than one key or a cert is reused across IdPs. Fixing it
    needs an `idp_entity_id` column and a value for every existing strat:
    a migration, not a code change.

  * **No one-time use.** There is no assertion-ID cache, and the SAML
    callback deliberately skips the session-state check, so nothing binds a
    response to a request we issued. The same bytes replay until
    `NotOnOrAfter` — which is the only bound. Ruby behaved the same way and
    Shibboleth issues short windows, so this is the accepted SAML bearer
    model rather than a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rowed (RF-10)

RFC 6749 §6 says a refresh request's scope "MUST NOT include any scope not
originally granted", and Doorkeeper enforced exactly that
(`RefreshTokenRequest#validate_scope` checks the request against
`refresh_token.scopes`). Nothing here asserted what auth.cr does, and the
two obvious guesses are both wrong.

What it actually does: **the `scope` parameter is ignored on refresh.**
`oauth.cr`'s refresh branch calls `Authly.access_token(grant_type:,
client_id:, client_secret:, refresh_token:)` and never forwards `scope`, so
`Grant#@scope` is nil and `Grant#scope` falls through to the scope recovered
from the refresh token.

I went looking for a privilege escalation and did not find one. The reason
is worth writing down, because the near-miss is real: `validate_scope!`
checks the requested scope with `Authly.clients.allowed_scopes?`, which asks
"may this client ever hold this scope" — the CLIENT REGISTRATION — not "was
this scope granted to this token". If the parameter were forwarded, a client
registered for more than it was granted could widen its own token on
refresh. It is only safe because the parameter never arrives.

Pinned, not fixed. The current behaviour errs safe: a client can never gain
a scope it was not granted. Making narrowing work means forwarding the
parameter, which without a granted-scope subset check turns a safe
divergence into a real escalation. That is a change to make deliberately.

Five cases, on a client REGISTERED for `public users` whose user grants only
`public` — the gap is where widening would become visible:

  * asks `public users`, granted `public`  -> 200, token carries `public`
  * asks `users`,        granted `public`  -> 200, token carries `public`
  * asks `public`, granted `public users`  -> 200, token carries BOTH
                                              (the divergence)
  * asks exactly what was granted          -> 200, unchanged
  * asks for nothing                       -> 200, granted scope preserved
    (the behaviour the scope-recovery patch exists to protect — the
     2026-07-25 revert — asserted here so a future narrowing check cannot
     regress it into an empty scope)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves camreeves changed the title test(saml): pin what the assertion Conditions actually enforce (ID-04) test(auth): pin SAML assertion conditions and refresh scope semantics (ID-04, RF-10) Aug 6, 2026
camreeves and others added 3 commits August 6, 2026 21:56
…, PK-06, OI-09, OI-04)

Three capability questions where what the service advertises, what the RFC
requires and what the code does are all slightly different. None is
exploitable; all three are the kind of thing an integrator discovers the
hard way, so they are now written down as assertions.

**PK-05 / OI-09 — `plain` is accepted though only S256 is advertised.**
Discovery says `code_challenge_methods_supported: ["S256"]`;
`normalize_code_challenge` transforms S256 alone and passes every other
method through, so authly compares a `plain` challenge verbatim and the
exchange succeeds. RFC 7636 §7.2 tells a server that supports S256 to refuse
`plain`, and the reason bites here: with `plain` the challenge IS the
verifier, and the challenge travels in the authorize URL — nginx's log, the
browser history, any `Referer`. Not exploitable across clients (the method
is baked into the code at authorize time, so a legitimate client's code
cannot be downgraded), but a capability document is a promise, and this one
is not kept. Also asserts `plain` still checks the verifier, so the case
above cannot be read as "PKCE is ignored".

**PK-06 — an unknown method mints an unredeemable code.** `S512` is accepted
at authorize (302, code issued) and then refused at the exchange. It fails
CLOSED, which is what matters. What it costs is diagnosability: the error is
401 `unauthorized_client`, which tells an integrator their client may not
use this grant type when the real problem is one query parameter they chose.
RFC 7636 §4.4.1 would reject it at the authorize endpoint with
`invalid_request`. Same misdirection class as XO-03 and the undecodable-grant
500.

**OI-04 — `nonce` is silently dropped.** `/auth/authorize` has no `nonce`
parameter, `Authly::Code` has no field for it, and `Owner#id_token` never
emits it. OIDC Core §3.1.3.7 step 11 says an RP that sent a nonce MUST
verify it comes back, so any RP library that sends one — most do, even on
the code flow — will reject our ID token. Safe direction (login refused,
never wrongly accepted) and no PlaceOS client sends one, which is why it has
never surfaced. The spec asserts the missing claim alongside a well-formed
`sub`/`aud`, so it reads as "this claim is absent", not "the flow is broken".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3, IR-04)

One auth.cr process serves every authority (eight on dev), so every tenant
boundary is enforced in code rather than by deployment.
`bearer_credentials_spec.cr` covers the token half (AZ-06/MT-02). This
covers the two boundaries either side of it.

**MT-03 — the session cookie is NOT bound to an authority.** `new_session`
stores `uid`, `exp` and `iat` and nothing else; `session_user` resolves it
with a bare `User.find?(uid)`; and the cookie is encrypted with one
process-wide `COOKIE_SESSION_SECRET`. So a cookie minted at authority A
decrypts and validates at authority B, and `/auth/authority` reports
`session: true`.

Pinned rather than fixed, for two reasons. A browser never does this on its
own — cookies are host-scoped, so B never receives A's cookie; it needs an
attacker who already holds the cookie value, at which point they hold the
session anyway. And it yields no usable credential: the second spec drives
the whole authorize+exchange against authority B with a `localhost` user's
session and asserts the token comes back with `aud: localhost`, then that
`/auth/userinfo` at B refuses it 401.

So the tenant boundary lives on the TOKEN, not on the session. That is a
design fact worth recording — anyone adding a session-only surface to
auth.cr (an admin page, a form post) inherits it and needs its own check.
The `aud` assertion is the one that fails first if that stops being true.

**IR-04 — cross-tenant introspection answers `{"active": false}`.** RFC 7662
§2.2 wants exactly that: not an error, and not the token's metadata.
`introspection_revocation_spec` covers the cross-application case inside one
tenant; this is the same guard across authorities, where a leak would cross
an organisational boundary. Asserts the absence of `scope`, `client_id` and
`exp` from the response and that neither the foreign user id nor the foreign
client id appears anywhere in the body — plus a control proving a client can
still introspect its own token, without which an always-false build would
pass.

Adds `signin_at`, since `Spec.signin!` hardcodes `Host: localhost` and
therefore cannot sign a user in at any other authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sing kid (AU-07, OI-05, OI-06, OI-11)

**AU-07** — `DELETE /auth/authorize` (the deny path) is session-gated like
the grant path, and bounces to `/auth/login` rather than emitting
`access_denied`. That distinction is the point: an unauthenticated deny that
redirected to the client would let anyone who can reach the endpoint
fabricate a user's refusal, and the client would record it as a decision the
user made. Asserts the redirect goes to login and that nothing reached the
client.

**OI-06** — `/auth/userinfo` answers GET and POST identically, and its `sub`
matches the `sub` of the ID token from the same grant (OIDC Core §5.3.2).
`discovery_spec` proved both verbs are ROUTED; this proves they agree.

**OI-05** — a token that outlives its user returns `404 {"error":""}`, not
the `unknown subject` 401 the code appears to intend. `authorize!` calls
`User.find`, which RAISES `PgORM::Error::RecordNotFound` rather than
returning nil; that escapes the `rescue e : JWT::Error` around it and lands
on the base controller's 404 handler, so `userinfo`'s own guard is never
reached by this route (it still covers the guest-scope path, which skips the
user lookup).

Diagnosability, not security: RFC 6750 §3.1 and OIDC Core §5.3.3 want 401
`invalid_token`, and an RP reading 404 concludes the ENDPOINT is missing —
it may disable userinfo rather than refresh the token. The body says
`{"error":""}` because RecordNotFound carries no message. Pinned rather than
fixed because the fix is a semantic decision (is a userless token
authenticated at all?) that should be made with the guest-scope path in
view. Reachable in production by any deletion or tenant teardown inside the
2-hour access-token window.

**OI-11** — our tokens carry NO `kid` in the JOSE header. `Authly.jwt_encode`
is `JWT.encode(payload, key, alg)`, which emits `alg` and `typ` only, so a
token names no key while the JWKS publishes one with an RFC 7638 `kid`. It
works today only because there is exactly one key and libraries fall back to
it.

That makes it a latent blocker on key rotation: the moment the JWKS holds
two keys a kid-less token is ambiguous and strict RPs reject it, so this has
to be fixed BEFORE a rotation, not during one. Same class as the missing
`nonce` — fine for PlaceOS's own clients, a surprise for a conformant RP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves camreeves changed the title test(auth): pin SAML assertion conditions and refresh scope semantics (ID-04, RF-10) test(auth): finish the P1 matrix and pin 11 behaviours the specs never asserted Aug 6, 2026
…ount parity (AU-12, SC-09, TK-09)

**AU-12** — authorization codes carry the 10-minute Doorkeeper lifetime.
Asserted by decoding a real code and checking `exp - iat == 600`, not by
reading the constant back to itself. A cutover must not silently shorten or
lengthen the window a half-finished login has to complete in. The rejection
side already lives in `token_disclosure_spec.cr`.

**SC-09** — `Authority#internals["session_timeout"]` overrides
`SESSION_TIMEOUT_MINUTES`. A tenant setting a shorter window is making a
security decision, so the override being *read* is the row. The session's
`exp` is sealed inside the encrypted cookie and cannot be inspected from
outside, so this drives it with a negative window: the same signin shows
`session: true` under the default and `session: false` with the override in
place. The control and the assertion are in one example, so an ignored
override cannot pass.

**TK-09** — `/auth/token` and `/auth/oauth/token` behave identically. They
are stacked annotations on one method today and so cannot diverge, but that
is an implementation detail and the drop-in promise is behavioural: this
fails if anyone splits them to deprecate one or to hang a filter on one.
Compares the success envelope shape, `Cache-Control`, `Pragma`, token_type
and expires_in, asserts both tokens actually verify, and compares the
REJECTION — status, error code and `WWW-Authenticate` — so parity is not
proven on the happy path alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves
camreeves merged commit 32e99ce into master Aug 6, 2026
7 checks passed
@camreeves
camreeves deleted the PPT-2536-saml-conditions branch August 6, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant