A WOPI token carries the user's credentials to the editor server and
stayed valid for its whole six-hour lifetime, so an editor opened
before signing out could still read and save the document afterwards.
Each token now records the browser (a random id in an HttpOnly cookie,
hashed) and account slot that opened it. Signing out records a
revocation time for that browser and slot, or for every slot on a full
sign-out, and WOPI calls with a token minted before it are refused.
Signing out on another device leaves this browser's editors alone.
Revocations live in the state directory, so they survive a restart and
reach replicas sharing the state volume, and are dropped once the
tokens they cover have expired.
`/api/auth/verify` checked any username and password against the JMAP
server without a limit. Every probe reaches the server from the
webmail's own address, so the route was a password oracle and enough
wrong passwords could trip Stalwart's authentication ban against the
webmail itself, locking out every user.
Wrong passwords now use up a budget of five per client and thirty
across all clients per fifteen minutes; right ones are refunded. Over
budget the route answers `inconclusive` and the browser probes the JMAP
server itself, as it did before the pre-check existed.
Used impersonation token ids were only remembered in process memory, so
a captured handoff link could be redeemed a second time within its five
minute lifetime once the process restarted, or on another replica. Each
used id now also leaves a marker file in the state directory, created
exclusively, which every replica sharing the state volume sees; markers
are pruned once the token could no longer verify.
The absolute session limit comes from the eight-hour app password that
Stalwart itself expires.
The code exchange, token refresh and revocation POSTs carry the
authorization code, refresh token and client secret, and used a plain
`fetch` that follows redirects. The endpoints come from a validated
discovery document, but a redirect from them (or from an endpoint that
changed after discovery) re-sent the body to a host nobody validated.
These requests now fail on any redirect. A failed refresh still keeps
the refresh token, as for any other outage.
A failed TOTP login returned the first 500 bytes of the upstream error
body. With custom JMAP endpoints allowed and no server configured, the
upstream is any address the caller names, so the route read other
servers' responses on the caller's behalf. The detail is now only
passed on from an admin-configured server.
A Bearer login may claim a bare local part, which is accepted when the
server's `Session.username` has the same local part (TOTP users who
type `john` log in this way). Settings were keyed on that claim, so on a
multi-domain server `john@b.example` could claim `john` and read and
overwrite the synced settings of the `john@a.example` who signs in as
`john` with a password.
`resolveJmapIdentity` now returns the canonical account name for a bare
Bearer claim, the context cookie carries it as `accountName`, and the
settings route keys on it. Basic logins and fully-qualified claims keep
their existing key.
An impersonation handoff sealed `<target>%<master>` and the master
password into the session cookie, and `PUT /api/auth/session` hands that
cookie's contents to the browser on every restore. Anyone who received
a handoff could read a credential that signs in as every mailbox and
into Stalwart administration, with no expiry and no audit trail.
The impersonate route now uses the master credential server-side, once,
to create an app password on the target mailbox that expires after
8 hours. Only that app password reaches the cookies. Signing out
revokes it through a sealed grant cookie, and PUT refuses any session
that still holds the configured master password, so cookies minted
before this change are dropped instead of returned.
After a master-user impersonation handoff (GET /api/auth/impersonate) the server
swaps the slot-0 session cookie but the client's persisted account registry
(account-registry / auth-storage in localStorage) still lists the previous
account, so the top-left account chip keeps showing the old mailbox until a
manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless
ImpersonationReconciler that drops the stale persisted account/auth state (and
server-derived caches) then reloads to a clean URL, so the app rehydrates empty
and re-derives the single account from the fresh session. Cookies untouched, so
the just-granted session survives. Runs exactly once.
Reported downstream: shukiv/jabali-panel#646.
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".
Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.
Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
Lets a per-brand authorize host front a single canonical issuer, so the
IdP token's `iss` stays constant for downstream validation while login
branding varies per domain. Discovery, token exchange and refresh keep
using OAUTH_ISSUER_URL.
The multi-account refresh-token cookie slot wiring was half-implemented:
every account's refresh token ended up on slot 0, so "+ Add Account"
silently clobbered the previous account's `jmap_rt` cookie. On page
refresh, only the most-recently-added account had a working refresh
token; the others bounced to login.
Three coordinated changes:
1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free
cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting
to the IdP. `loginWithOAuth` already reads this key but it was never
written, so it always defaulted to 0.
2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set"
(`rawSlot === null`) from "value is 0". Previously
`parseInt(getItem(...) || '0')` collapsed both cases, making the
`getNextCookieSlot()` fallback unreachable.
3. `stores/auth-store.ts` (loginWithServerSso) +
`app/api/auth/sso/complete/route.ts`: pass the slot through the body of
the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as
the existing `/api/auth/token POST` that already accepts a slot. The
server defaults to 0 for back-compat with any caller that omits it.
After the fix, signing in with multiple accounts produces distinct
`jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot
field in account-store) and all accounts survive a page refresh.
Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
account vanishes from the dropdown; switching to the first account in
the dropdown still shows the second account's identity in the From box.