Commit Graph
74 Commits
Author SHA1 Message Date
Linus Rath 633df44090 fix: keep the sign-in response small enough for proxies with large OIDC tokens #1237 2026-10-14 13:59:41 +02:00
Linus Rath 29c74c3b77 feat: sign in with an access token 2026-10-01 00:24:47 +02:00
Linus Rath 4db88255e8 fix: stop a sixth sso account from overwriting the first account's sign-in
`/api/auth/sso/complete` only accepted cookie slots 0-4 and quietly
used slot 0 for anything higher. Since the account limit rose to
`MAX_ACCOUNT_SLOTS` (50) on HTTP/2, adding a sixth account through
server-side SSO wrote its refresh token over the first account's cookie,
while the new account was registered on slot 5 with no cookie behind it.

The route and the OAuth callback's pending-slot check now use the same
bound as every other auth route.
2026-09-30 19:31:03 +02:00
Linus Rath 377c283d86 fix: say up front when the mobile app cannot use an http mail server
The app refuses a JMAP server over plain http (loopback aside), yet the
desktop issued a code, reported "Signed in on your phone" once it was
redeemed, and the phone failed. Linking now stops before the step-up
with an explanation.
2026-09-27 04:53:49 +02:00
Linus Rath a5583aa8e8 fix: re-check a paired phone's token endpoint against the current config
The token proxy trusted the endpoint sealed into a phone's refresh token
for good: after a server was removed or its provider moved, it kept
posting there with the plain fetch and whichever secret the server id
now maps to. Trust is now re-derived on every renewal; an endpoint the
webmail is no longer configured for gets neither the secret nor the
unguarded fetch.
2026-09-27 04:50:38 +02:00
Linus Rath 5c1da4efa7 fix: refuse a pairing re-auth the identity provider did not make fresh
The step-up for SSO accounts asks the provider for a new login with
`prompt=login`, but a provider that ignores it answers from its existing
session, so anyone at an unlocked desktop could link a phone. When the
ID token's `auth_time` is older than the step-up, the re-auth is now
refused.
2026-09-27 04:49:54 +02:00
Linus Rath 75bffd35e6 fix: limit pairing password attempts per mail account and per day
The step-up's failure count was keyed on the cookie slot, which the
client chooses, so a session could spread guesses over 50 slots; and
even one key allowed 480 failures a day, well above Stalwart's ban of
100 a day per address, which would ban the webmail's own IP for every
user. Failures now count per server and login name, 5 per 15 minutes
and 20 a day.
2026-09-27 04:49:03 +02:00
Linus Rath 3494d2f138 fix: keep the app's "Sign in via webmail" working past its first token expiry
The mobile handoff returned the provider's token endpoint and the
webmail's client id as they were. With a confidential client the phone
could not renew (it has no secret), and a provider on another host was
refused by the app outright. The handoff now returns the same bundle a
paired phone gets: such grants renew through `/api/auth/pair/token` with
a sealed refresh token.
2026-09-27 04:34:19 +02:00
Linus Rath 1446fd39dc fix: link the mobile app from every account with a sign-in of its own
"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.

The step-up now mints the phone's own grant:

- Password and password+TOTP accounts confirm the current password (and
  code) in the dialog. The server uses it once to get an OAuth grant
  from Stalwart's structured login for the app's own client, retries
  with the webmail's client when the server insists on a registered one,
  and falls back to an app password ("Bulwark mobile (paired <date>)")
  or, on servers that offer neither, the verified password. Failed
  attempts are limited per account so the webmail's address cannot be
  banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
  where offered; its fresh tokens become the phone's grant instead of
  being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
  context, never from the request.

Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.

The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
2026-09-27 04:16:18 +02:00
Linus Rath bb80d01ddf fix: keep a pairing re-auth from signing the browser in as another account
The step-up for linking a device sends the user to the identity
provider with `prompt=login`. The login route accepted that pending
state too, so a code from the re-auth could be spent there and sign this
browser in as whichever account answered the prompt. It now refuses a
re-auth state; only the pairing route completes one.

The callback page also handles its code once. React's development
double effect ran it twice: the second pass no longer saw the re-auth
flag and raced the pairing route for the single-use code.
2026-09-27 04:14:15 +02:00
Linus Rath ce5f177d53 refactor: move Stalwart's password and TOTP login into a shared helper
The password+TOTP login drove Stalwart's structured login (`/api/auth`,
then `/auth/token` with PKCE) inline. Linking the mobile app needs the
same exchange to mint a phone's own grant, so it now lives in
`lib/auth/stalwart-password-login.ts` and the route only maps its
result to the responses it always gave.
2026-09-27 04:11:30 +02:00
Linus Rath 204cd09951 fix: end office editor sessions when the user signs out
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.
2026-09-26 16:40:46 +02:00
Linus Rath a2253982e5 fix: limit wrong passwords tried through the login pre-check
`/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.
2026-09-26 16:36:58 +02:00
Linus Rath 5622eca36b fix: refuse a used impersonation link after a restart or on another replica
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.
2026-09-26 16:34:58 +02:00
Linus Rath 49e8d08c23 fix: never follow redirects on OAuth token and revocation requests
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.
2026-09-26 15:13:41 +02:00
Linus Rath b73d0dfabf fix: stop reflecting a user-chosen server's error body from the TOTP login
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.
2026-09-26 15:08:35 +02:00
Linus Rath 3f01e94319 fix: key synced settings on the account a bearer token belongs to
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.
2026-09-26 14:59:55 +02:00
Linus Rath 11eb0bfb16 fix: keep the Stalwart master password out of impersonated sessions
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.
2026-09-26 14:05:05 +02:00
Linus Rath e33ab899b0 fix: leave out the id token when sign-in cookies would overflow nginx's header buffer #1096 2026-09-24 18:23:11 +02:00
Linus Rath f97218375e fix: end the identity provider session on sign-out #905 2026-09-23 20:44:46 +02:00
Linus Rath f8592bc6b7 fix: accept oauth endpoints on the configured issuer's own host #1028 2026-09-23 19:26:07 +02:00
Linus Rath dc9d609c43 fix: re-authenticate against the account's own server when linking the mobile app 2026-09-23 18:35:15 +02:00
Linus Rath 691cde6468 fix: explain why calendar subscriptions fail with not authenticated #1073 2026-09-23 18:31:27 +02:00
Linus Rath e055ee8201 fix: verify credentials upstream before minting identity cookies (GHSA-wxcm-j4jc-9fxq) 2026-09-17 20:48:42 +02:00
Linus Rath b7f53a3a8b fix: bind a purpose into encrypted payloads (GHSA-cqqx-mjcf-mh55) 2026-09-17 20:16:51 +02:00
Linus Rath e908aee994 fix: reject cross-site requests to /api/auth/* to stop session fixation (GHSA-qvr9-m8cq-7wvg) 2026-09-17 20:01:35 +02:00
Linus Rath c395226926 fix: reject wrong passwords server-side so the browser never shows its Basic Auth dialog #969 2026-09-09 19:21:40 +02:00
Linus Rath af234f64a4 fix: drop max_age=0 from OIDC re-authentication requests #938 2026-09-07 22:34:47 +02:00
Linus Rath 3288589e52 fix: pin resolved IP at connect time to block DNS-rebinding SSRF (GHSA-24w9-8r42-8jwm) 2026-08-26 22:11:06 +02:00
Linus Rath 9a6cc23876 fix: refresh TOTP-minted tokens with the default client id when no OAuth client is configured #873 2026-08-20 13:59:57 +02:00
Linus Rath e44f35a203 fix: reuse cached access token on session restore #552 2026-08-16 22:20:07 +02:00
shukiv 246df49c03 fix(impersonation): reconcile stale persisted account chip after handoff
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.
2026-07-27 05:20:53 +03:00
dealerweb 9110bc388f Fix: keep the session when the auth server is briefly unreachable
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.
2026-07-06 15:52:05 +02:00
Linus Rath 63e087f3ef fix: support MFA login via structured auth endpoint 2026-06-28 19:53:08 +02:00
Loïs Postula 638fc7db4e feat(oauth): add OAUTH_AUTHORIZE_URL to override authorize endpoint
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.
2026-06-19 12:30:23 +02:00
Linus Rath 964136b540 feat: require re-authentication for device pairing and SSO 2026-06-05 19:23:06 +02:00
Linus Rath 1d050f8469 feat: add QR code device pairing for mobile app login 2026-06-05 17:41:55 +02:00
Linus Rath 31100b8f87 fix: discover OIDC metadata server-side to avoid CORS failures #382 2026-06-02 00:15:50 +02:00
Linus Rath 63f2169ae7 fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups 2026-05-22 17:22:14 +02:00
Linus Rath fc5f6f43d6 feat: expose PWA, app identity, and extension directory keys in JSON config #312 2026-05-21 23:35:58 +02:00
Linus Rath 97ddf935a8 fix: mobile handoff flow for OAuth authentication 2026-05-19 00:45:35 +02:00
Linus Rath 973ce1e5bd feat: add mobile handoff page and JMAP authentication verification 2026-05-19 00:05:40 +02:00
Linus Rath ecd0467ffa fix: stop pulling node:dns into client bundle via OAuth discovery 2026-05-18 19:31:38 +02:00
Linus Rath f275fbe2e4 fix: bind stalwart auth context to credential, not cookie-claimed username 2026-05-18 13:00:40 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath fae15f073e fix: honor cookieSameSite admin config override #284 2026-05-14 21:49:37 +02:00
Linus Rath c646c87030 fix: standardize punctuation in tooltips and comments across multiple locales and code files 2026-05-14 21:44:24 +02:00