Commit Graph
115 Commits
Author SHA1 Message Date
Linus Rath 526b79aaaf feat: ask for the email address first and find the mail server on sign-in
Where people may sign in to a server of their own (the custom endpoint
setting and the desktop app's standalone mode), the sign-in form asked
for a "JMAP Server" first, refused a host without https:// with the
browser's own bubble and talked about CORS. Now the email address comes
first and the server is looked up from its domain (JMAP well-known, SRV,
then the admin's default) through a new budgeted route. The "Mail
server" field opens only when nothing is found, takes a bare host, and
keeps the CORS hint behind "Having trouble?" (not in the app). Gmail,
Outlook.com and iCloud addresses get a plain note that these offer no
JMAP. The app's browser button stays the main one and asks for what it
needs under the field it needs.

Also on the page: the theme button is an icon named "Light or dark",
the heading is the app's name only when the admin gave one (else "Sign
in", without the subtitle repeating it), "I have a code from my
authenticator app", "Add account", and no 11 px text.
login-page-i18n.test.tsx now names the app for the subtitle test and
waits for the heading, since the default name no longer shows a subtitle,
and finds the theme button by its new name.
2026-11-04 06:18:56 +01:00
Linus Rath ab21bf2df3 fix: keep a desktop sign-in in the browser's page until the user says yes
The hand-off parked a password before its question, for up to five
minutes, and on a shared computer another local user could ask for
someone's sign-in with a link of their own. Now nothing leaves the page
before the yes, and the instance has a switch for such machines.

- `/check` tests a password and keeps nothing, `/park` runs on the yes and
  hands over tokens wherever the server allows, a password as a last
  resort
- `/discard` and a timer take out what SSO has to park first, and a no
  revokes the refresh token, and the shared store's TTL is the real expiry
- `DESKTOP_BROWSER_SIGN_IN`, default on, closes the routes, the page's
  hand-off and the app's button; the question says which device asked
- tokens from a typed password stay a password account under the name
  typed (#1432)
- redeem refuses a server of the user's own when the device's rule says
  no
- the park budget is reserved before going upstream, and only failures
  count
- "Not now" ends the flow in the app at once, and the question names the
  account

`desktop-handoff-routes.test.ts` now tests the two-step flow against the
real budgets. `sso-start-route.test.ts`'s config mock answers the new
setting, and the SSO test's expected answer has the account in it.
2026-11-04 02:40:37 +01:00
Linus Rath c8c98e79d4 feat: sign in to the desktop app in your browser, with your password manager
The desktop login page gets "Sign in with your browser…" as its main
button. On an instance it opens the instance's real login page in the
system browser, with a PKCE challenge. The page checks the password or
runs SSO, parks the sign-in under a one-time code, asks once, and sends
the code to the app's loopback listener. This browser gets no session
out of it, and the app's own page redeems the code, so SameSite=Strict
is fine. Standalone runs the mail server's OAuth against the listener,
with a page from main for a server with no OAuth at all.

- `/api/auth/desktop-handoff/park` and `redeem`, and `sso/complete` parks
  tokens the same way for a loopback target
- `auth.claim()`: the calls are taken once per document, in the first
  render, in `WindowControlsHost`
- `BrowserSignIn` policy, default on, with its ADMX and samples
- strings in all 30 languages, and the old button's are gone

`runDesktopBrowserSignIn`'s tests changed because the code no longer goes
through the callback page's address: it's redeemed where it arrives.
2026-11-04 01:09:12 +01:00
Linus Rath 3f264367af fix: harden the app lock: forget in an order that can't leave a sign-in without a PIN, hear wipes and clear subjects while locked, and refuse obvious PINs 2026-11-03 17:08:02 +01:00
Linus Rath 2edd6b5ea7 fix: hold forced policies against spoofed plugins, recommended values and stopped servers
A forced value could be got around from inside the app. Only a forced
value may now decide what's allowed: the keys that do are forced-only,
so a recommended one no longer acts as forced. With a plugin list a
package can name any id, so uploads are refused and only managed
plugins can match. A server taken off AllowedJmapServers stops its
accounts without signing out: client, push and sync stop, the switcher
drops them, restore and refresh answer 403 with the cookie kept, and
listing it again resumes with no sign-in. OAuth and SSO starts check
the list too.

- `policy.onWipeOfflineData` is gone from the bridge: main runs the wipe
- `KeystoreAllowedOrigins` added, and `POLICY_CONSUMERS` names who acts
  on each key or who still owes it
- a Bulwark server's name must be plain after parsing, a wildcard in the
  auth lists needs a domain of two labels
2026-11-03 12:06:21 +01:00
Linus Rath fb6ff162ff Merge branch 'feat/desktop-offline' into feat/do-policies 2026-11-03 11:40:53 +01:00
Linus Rath 6e262bf1c5 feat: move offline mode onto the encrypted store, with a merged outbox and conflicts
# Conflicts:
#	app/api/auth/token/route.ts
#	lib/offline/offline-mode.ts
#	stores/account-store.ts
#	stores/auth-store.ts
2026-11-03 10:21:34 +01:00
Linus Rath a9b6a05841 feat: enforce the desktop app's OS policies for plugins, mail servers and offline mail 2026-11-03 10:15:15 +01:00
Linus Rath 706550daf4 fix: keep local mail and the outbox when an automatic sign-out wasn't a real refusal of the login, for 30 days at most 2026-11-03 08:33:03 +01:00
Linus Rath 7ac582f803 fix: compile the desktop trust path out of every other build, and fail a build that keeps it 2026-11-03 06:55:22 +01:00
Linus Rath aa511e5d38 feat: sign in through the system browser in the desktop app, and refresh at the account's own server 2026-11-03 06:32:43 +01:00
Linus Rath e65f7fe31f feat: let the desktop app reach LAN and plain http mail servers the user approved 2026-11-03 06:32:28 +01:00
Linus Rath 29778da2d4 fix: wait out an unreachable token endpoint instead of counting it toward a sign-out
An existing assertion changes: a token endpoint that can't be reached now answers 503, not 500, because the client counts repeated 500s as permanent and signs the user out.
2026-11-03 05:54:34 +01:00
Linus Rath 481c4b6030 feat: serve Prometheus metrics and split health into live and ready
- `/api/metrics` in Prometheus text format, off until `METRICS_TOKEN`
  or `METRICS_TOKEN_FILE` is set (404 before, 401 without the bearer
  token): requests per route group with status class and latency,
  sign-in outcomes, calls to Stalwart, WOPI, translation, the plugin
  proxy and OAuth, browser errors, errors today, process memory,
  event-loop lag, uptime and build info. Labels come from fixed lists.
  Each replica serves its own.
- `/api/health/live` answers while the process runs;
  `/api/health/ready` checks the config, the shared store, writable
  data dirs and Stalwart's session resource. `/api/health` and
  `/api/ready` are unchanged.
- the Docker image gets a HEALTHCHECK on the live endpoint
- adds prom-client, and the OpenTelemetry packages the tracing step
  loads on demand
2026-11-01 02:59:40 +01:00
Linus Rath 47281e5156 fix: answer 503 when an impersonation link's use can't be recorded and keep used links as expiring keys #1066
The marker-write test from d28ee361c now expects the 503 path (a rejected promise) instead of false: refusing with "Token already used" sent admins looking for a replay that never happened, and the jti is now remembered in memory only after it was recorded, so a retry works once the store or state dir is back.
2026-11-01 00:07:41 +01:00
Linus Rath df1f5cd6a4 fix: give a pair step-up attempt back only to the windows it was counted in #1066 2026-11-01 00:05:09 +01:00
Linus Rath 47ec029717 fix: give a hand-off redeem back when the store fails, and hand out a sign-in whose tombstone couldn't be written #1066 2026-11-01 00:03:19 +01:00
Linus Rath ca5f2cf09c feat: let the mobile app redeem a sign-in hand-off through any instance #1066 2026-10-31 23:19:36 +01:00
Linus Rath 26cc7c68c3 feat: let a phone pair through any instance and keep pairing secrets sealed in the shared store #1066 2026-10-31 23:16:11 +01:00
Linus Rath ff0d2dc6e1 feat: stop sending a refused stored password from any instance once one has seen it refused #1066 2026-10-31 23:07:12 +01:00
Linus Rath 388daf767b feat: count sign-in, pre-check, pair step-up and hand-off limits across every instance #1066 2026-10-31 23:04:05 +01:00
Linus Rath f11ddf82dd fix: tell banner plugins when an admin opened the mailbox through impersonation 2026-10-31 17:48:29 +01:00
Linus Rath 1e462fc67e Merge pull request #1436 from shukiv/fix/impersonation-expired-app-passwords
fix: let a mailbox be opened through the handoff more than five times
2026-10-31 16:24:52 +01:00
Linus Rath 5931cd1678 feat: send an oauth resource indicator when one is configured #1461 2026-10-31 16:14:35 +01:00
Linus Rath 3bdbdb1364 docs: move the mobile hand-off contract to the website 2026-10-31 14:32:39 +01:00
Linus Rath fb3ba0275a feat: hand the mobile app a one-time code instead of the sign-in in its link 2026-10-31 14:06:44 +01:00
Linus Rath 50b3ddccec feat: let admins keep the refresh token valid on sign-out so Stalwart keeps the consent #1388 2026-10-29 20:47:18 +01:00
Linus Rath d875714f35 feat: offer remember me on the sso-only login and apply it to every sso path #1437 2026-10-29 20:06:44 +01:00
Shuki Vaknin 322274fff8 fix: let a mailbox be opened through the handoff more than five times
Each impersonation handoff makes an app password on the target mailbox
that expires after 8 hours. Stalwart keeps an app password after its
expiresAt and counts it against the mailbox's limit (5 by default), and
nothing deleted the expired ones, so after five handoffs every further
one failed with "You have exceeded your quota of 5 app passwords" and the
mailbox could no longer be opened that way.

Before making a new one, the handoff now lists the mailbox's app
passwords and deletes its own expired ones, recognised by the
"Support session " description. The user's own app passwords and a
session still running are never touched. It is best effort: if the list
or the delete fails, the handoff tries to create the password as before.
2026-10-29 16:37:06 +00:00
Linus Rath defe4e93f3 fix: bind the sign-in time to the refresh token and count unstamped sessions from a fixed time 2026-10-28 03:42:19 +01:00
Linus Rath 6e5d8faec6 feat: refuse to restore or renew sessions older than the admin-set maximum length 2026-10-28 02:30:32 +01:00
Linus Rath 004f0948d5 fix: ask Stalwart 1.0 for mail, contacts, calendar and file access when signing in with SSO 2026-10-26 20:25:42 +01:00
Linus Rath 8860dde005 fix: sign in with password and TOTP on Stalwart 1.0 2026-10-26 20:25:42 +01:00
Linus Rath 42117da498 fix: stop replaying a stored credential the mail server refused, so a stale session cannot get the webmail banned (GHSA-g6c7-r7wq-xrm2) 2026-10-26 15:00:56 +01:00
Linus Rath ae897b42a4 fix: make the impersonation handoff really clear the previous account list and ignore ?impersonated=1 links from elsewhere 2026-10-26 14:58:31 +01:00
Linus Rath 9ed3354550 fix: send a password and TOTP sign-in to the server the user typed, not the default one 2026-10-26 11:33:37 +01:00
Linus Rath 680742c3ef fix: end a password and TOTP sign-in without remember me with the browser session 2026-10-26 11:33:37 +01:00
Linus Rath 0b01a2d066 fix: ask before handing a sign-in to the mobile app and only to its own callback link 2026-10-19 01:22:19 +02:00
Linus Rath f91b214719 fix: key synced settings on the account a token login resolves to, not on a bare claimed name 2026-10-19 00:45:11 +02:00
Linus Rath 89ef4d14f2 fix: budget failed sign-ins per client and forward the client address to the mail server 2026-10-19 00:43:00 +02:00
Linus Rath 4adbad25f6 feat: end the provider session through a configured logout URL #1279 2026-10-18 13:46:48 +02:00
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