test(auth): finish the P1 matrix and pin 11 behaviours the specs never asserted - #33
Merged
Conversation
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>
…, 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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 incrystal-samlis guarded by a skip if not configured clause (validate_audiencereturns true whensp_entity_idis nil, and so on), so which ones are live depends entirely on whatbuild_samlpasses. 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 noscope/client_id/expand no trace of the foreign user or client in the body.AU-07 — the deny path.
DELETE /auth/authorizeis session-gated and bounces to login rather than emittingaccess_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
submatches the ID token'ssub.Divergences, pinned rather than fixed
Each is deliberate, with the reason it wasn't fixed here.
new_sessionstores uid/exp/iat and one process-wide secret, so an A-session validates at Baud= 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 thisscopeparameter is ignored on refresh, both directions —oauth.crnever forwards it toAuthly.access_tokenvalidate_scope!only checks the client registration, not the granted scope — so forwarding without a subset check would create an escalation. See belowplainis accepted although discovery advertises S256 onlyunauthorized_clientnonceis silently dropped — no authorize param, no field on the code, never emitted404 {"error":""}, not theunknown subject401 the code intends —User.findraises, escaping therescue JWT::Errorkidwhile the JWKS publishes one<saml:Issuer>is never validated (idp_entity_idis never passed), and there is no one-time use — an assertion replays untilNotOnOrAfterThe 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 withAuthly.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_scopeagainstrefresh_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
crystal tool format --check src specclean.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