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.
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.
A review found that an open page kept sending to a DSN after consent was
taken back, that a state file or config file could name where telemetry
goes, and that `BULWARK_TELEMETRY=off` did nothing on the desktop build.
A due heartbeat now waits a random 30 s to 10 min, a refusal from the
collector isn't retried, crash files go at sign-out and account switch,
and server builds parse stacks as they did.
Existing assertions changed in the desktop telemetry tests: the catch-up
wait is now a range, the endpoint comes from the shell, and the platform
is `desktop` against the collector's patched branch.
The timers used to delete everything an account kept, unsent mail included.
They now follow the refused-login rule: sign out and hide, the next sign-in
brings it back, otherwise it goes after 30 days. A login without "Remember
me" that ends with the browser, an admin wipe and a wipe from settings don't
change.
- `wipeOnRefusedLogin` is now `wipeOnForcedSignOut`, since it also covers the
limits. The server's `SESSION_TOO_OLD` goes the same way, as `session`.
- A refused account beside one that stays signed in shows in the switcher as
needing sign-in, with the count and the date, and gets one toast.
Existing tests changed because the rule did: the session timer tests expected
a plain `logoutAll`, and the auth store tests expected no hook at the session
limit.
A refused login used to delete the account's data, and with it everyone's
S/MIME and OpenPGP keys, also with offline off. The usual cause is a password
changed on another device, which Stalwart 0.16 and 1.0 both answer with
`invalid_grant`, so that lost unsent mail for little protection.
- A refused login signs out at once and hides the data. A new password brings
it back, unsent mail included. Otherwise it goes after 30 days. The sign-in
page gives a count and a date. The 24-hour timer is gone.
- `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion
at once, of that account's own data only.
- The session limit no longer counts as a refusal on any route. It hides the
data and says nothing.
- A wipe runs under a Web Lock, tells other windows when it starts and ends,
and queues its report before deleting anything. It reports only once the
deletion worked and no plugin database is left.
- More goes with a wipe: Pro tabs, search history, Files and WebDAV state,
templates, pop-out drafts, shown notifications, push state, and Web Push
with the last account.
- The unsent count is per account, and unknown rather than 0 when the store
can't say. A new row is checked against the account that asked, and a
sign-out drops its row.
Existing tests changed because the rule did: `auth-store-keep-local.test.ts`
expected a wipe on `invalid_grant` and at the session limit, and
`device-wipe.test.ts` expected shared keys to go on a refused login.
Until now a lost laptop kept its offline mail for as long as anyone could
open it, and an account whose login the server had ended kept its copy too.
- `lib/devices`, `app/api/devices`: browsers with offline on and desktop
apps register, in a durable collection with no expiry. A check-in goes
by a per-device secret, so a revoked device still hears its wipe.
- `app/api/admin/devices` and the org devices page: wipe one device or all
of a user's, with audit entries for each request and each confirmation.
- `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then
waits up to a second for the instance before showing the local copy. A
wipe takes the store, keys, plugin databases and handoffs, signs out,
and reports back, again at the next contact if the report failed.
- `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the
session limit, or a 401 lasting past a re-auth count as refused. A 500,
a proxy page or an outage never do.
- Settings: "Remove everything from this device", with a confirmation.
`lib/shared-store/__tests__/import-data.test.ts` changes its assertion
because it lists every collection the import copies, and `devices` is new.
The lock is off by default, and an OS policy can require it and set the
timeout. It locks after a time without use counted across all windows and
tabs, on OS screen lock in the desktop app, and from Lock now.
Locking saves open composers, stops the outbox at its next message, closes
the store and stops its worker with every key, then reloads the page, so
nothing of the old page stays in reach. The new page holds the app back at a
Suspense boundary until someone unlocks. Pop-outs get no session while
locked, push notifications name nobody, and subjects leave plain storage.
One random lock key wraps each account's key as a layer. The PIN wraps the
lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries
that a reload doesn't reset. A passkey with PRF and the desktop's OS storage
wrap it too. Details and limits are in the decisions file.
A persisted account id with no subject yet read as decided: Everyone's
noOffline wiped mail a group scope allowed, and a wider scope went out at
every reload. A public, partial policy counted as the organisation's word,
and turned on the desktop default in a forbidding organisation.
- an organisation's cap on one file now binds a pin
- Clear and the off switch read what would be lost when asked, messages,
changes and drafts, and Clear waits for a connection
- turning one account off survives the last one going off, and off then on
no longer warns of an eviction
- a tab that takes over the store sends no scope until it has heard the policy
- a dropped account is judged by what applies to anyone
- a choice reaches the store once it settles; progress announces phases only
- restored Pro tabs ask for their titles
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
Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
The token route now answers 503 when the provider can't be reached, in every build, and a refused renewal for a background account now removes it as evicted, which keeps its data unless the server said invalid_grant. Two assertions from the desktop and pop-out branches change for that. Also drops a duplicate import from the same merge.