Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:
- `credentials` in the manifest: admin scope (secret and server from the
admin config) or user scope (the user connects an account; the server
address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
credential's own origin, through the SSRF guard unless the admin set
the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
redirects or WebDAV moves to other servers, streamed bodies capped at
512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
Login Flow v2) and removes it from what the plugin gets back
"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.
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.