Repository navigation
v2.22.0
Minor release that hardens the signed-token SSO of v2.21.0 after a review of the replay surface: the one-time jti is used up before the state check, the state expires, the environment lock cannot be half configured, the issuer reports what it signs and refuses, the SSO cache store is configurable and the button label can be translated. Read Upgrading to v2.22.0 first: three of the changes alter what a panel accepts or throws.
Security
- State fixation of the signed token is narrowed to a request that never arrives. An attacker who started the flow at panel B (state
Sin their own session) could get a signed-in user of panel A to open/{martis}/sso/issue/{audience}?state=Sby a top-level navigation. Panel A signed for that user withS, the callback failed the state check in the victim's browser, but thejtiwas recorded last, so the token stayed good for its lifetime and whoever read that URL (an access log, a proxy, an extension) could replay it in the attacker's browser and be signed in as the victim. The verifier now checks, in this order, the structure, the signature, the claims and times, the environment, the one-timejti, and only then the state: a validly signed, in-time token for this audience is used up by its first arrival whatever the state. The record does not wait for the callback's rate limit: the callback route's middleware (SpendSignedToken, which then applies the same rate limit through thethrottlealias) runs the verification up to and including thejtifirst, so a callback answered 429 has still spent the token. The replay window is closed for a callback that reaches the panel; it stays open when the victim's request never arrives (a tab closed mid-redirect, a content blocker, a proxy that drops it, an on-path party), which SSO, section 18 says with the log andttl_secondsadvice, and why neitherSec-Fetch-Site, PKCE nor a consumer-signed state would help. - The state expires after ten minutes. The state
redirect()keeps in the session carries its creation time and is refused (reasonstate) when older than 10 minutes, a class constant with no config key. It is still pulled once. - The environment lock cannot be half configured. A consumer with no
environmentrefuses a token that carries theenvclaim (reasonenvironment): the issuer is locked and the verifier would have ignored it.environment_claimis removed on both sides (the claim is alwaysenv): a block that still sets it throws, naming the key and saying it was removed in v2.22.0. With a configurable name a locked issuer and an unlocked verifier disagreed without any error. HEADon the callback of a signed-token provider is 405, answered before anything is read or recorded, so a link scanner that probes withHEADdoes not spend the token.
Added
- Issuer audit events.
Martis\Events\SsoTokenIssued(user, audience, request id) andMartis\Events\SsoIssueDenied(user or null, audience, reason, request id), dispatched by the issue route. A refusal also logs a warning (SSO token issue denied) with the samerequest_id; theSSO token issuedline keeps its text. Reasons:unknown_audience(only while the issuer is enabled: a disabled issuer stays a silent 404),not_navigation,invalid_state,impersonating,forbidden. The HTTP answers do not change, the audience is cut to 64 characters and reduced to printable ASCII, and neither the token nor the state reaches an event or a log line. A failure while signing (a user with no email, a throwingclaimsclass, an unusable issuer block) is recorded as the sixth reason,cannot_sign, and the exception goes on (500). Refusals by the route's middleware (not signed in, 2FA, email verification, the panel gate, throttle) happen before the controller and raise no event. Listeners run inside the request: a throwing listener ofSsoTokenIssuedstops the sign-in. The state, the token and the secret key are#[\SensitiveParameter]in the signed-token classes, so they do not appear in a stack trace (thecannot_sign500 included) even withzend.exception_ignore_argsoff. Therequest_idof the issuer and of the failed-callback line is bounded by one helper (printable ASCII, 128 bytes). martis.auth.sso.cache_store(MARTIS_SSO_CACHE_STORE,null= the default store). One helper,Martis\Sso\SsoCache, owns the lookup and every SSO cache call uses it: thejtirecord, the revocation, the origin nonces, the ended-session markers and the Azure tenant lookup. The store must be shared by every server, must not evict early and must not be flushed on a deploy. A name that is not incache.storesthrows, naming the key; an empty value is unset; with SSO off the non-SSO paths (password sign-in, sign-out, the plain 401) never touch the cache; with SSO on, anarrayornullstore throws outside the test suite.- Translatable provider
label.martis.auth.sso.providers.<name>.labelis a string or a map oflocale => string, resolved on the login page the wayauth.copyvalues are (exact locale, prefix,en, first entry), through the helper both now share (resolveCopyValueinauthCopy.ts).
Changed
- A configuration error of a signed-token provider block is a 500 on the callback, naming the key, not an
unknownrefusal with the generic text; a refusal is anSsoExceptiononly. The refusal of an expired state says it is older than the ten-minute lifetime (reason stillstate). - An unusable SSO cache store fails a sign-in before anyone is signed in: password (SPA and form), magic link and invitation call
SsoCache::assertUsable()ahead of the guard (a no-op with SSO off), so the answer is the error and nobody is authenticated. The magic link and the invitation are not spent. - A provider
labelthat is neither a string nor a map of strings throws, naming the key. It used to be dropped without a word. An empty-stringlabelnow reads as unset (the button says "Continue with "); it rendered an empty button before. - A signed-token callback whose
jtiwas already used answersreplayedeven when its state is also wrong (it answeredstatebefore).
Tests: 6540 Pest + 2587 Vitest = 9127 passing.