Skip to content

v2.22.0

Choose a tag to compare

@lmelomoura lmelomoura released this 12 Nov 09:53
9dee550

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 S in their own session) could get a signed-in user of panel A to open /{martis}/sso/issue/{audience}?state=S by a top-level navigation. Panel A signed for that user with S, the callback failed the state check in the victim's browser, but the jti was 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-time jti, 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 the throttle alias) runs the verification up to and including the jti first, 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 and ttl_seconds advice, and why neither Sec-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 (reason state) 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 environment refuses a token that carries the env claim (reason environment): the issuer is locked and the verifier would have ignored it. environment_claim is removed on both sides (the claim is always env): 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.
  • HEAD on the callback of a signed-token provider is 405, answered before anything is read or recorded, so a link scanner that probes with HEAD does not spend the token.

Added

  • Issuer audit events. Martis\Events\SsoTokenIssued (user, audience, request id) and Martis\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 same request_id; the SSO token issued line 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 throwing claims class, 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 of SsoTokenIssued stops 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 (the cannot_sign 500 included) even with zend.exception_ignore_args off. The request_id of 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: the jti record, 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 in cache.stores throws, 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, an array or null store throws outside the test suite.
  • Translatable provider label. martis.auth.sso.providers.<name>.label is a string or a map of locale => string, resolved on the login page the way auth.copy values are (exact locale, prefix, en, first entry), through the helper both now share (resolveCopyValue in authCopy.ts).

Changed

  • A configuration error of a signed-token provider block is a 500 on the callback, naming the key, not an unknown refusal with the generic text; a refusal is an SsoException only. The refusal of an expired state says it is older than the ten-minute lifetime (reason still state).
  • 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 label that is neither a string nor a map of strings throws, naming the key. It used to be dropped without a word. An empty-string label now reads as unset (the button says "Continue with "); it rendered an empty button before.
  • A signed-token callback whose jti was already used answers replayed even when its state is also wrong (it answered state before).

Tests: 6540 Pest + 2587 Vitest = 9127 passing.