Repository navigation
feat(auth): personal API tokens for scripts and tools (#617) - #664
Monkey7539 wants to merge 3 commits into
Conversation
|
@Monkey7539 yessss! |
A bearer credential for clients that are not a browser tab, such as an MCP server, so they no longer need the account password and keep working with two-factor sign-in on. This is the backend of maathimself#617, as agreed there. - api_tokens (migration 0072) stores only the SHA-256 of each token, its scopes, its expiry, and what confirmed its creation. An admin switch, api_tokens_enabled, is off by default. - Only `Authorization: Bearer mfat_...` makes a token request. Any other Authorization header goes to the cookie flow untouched, so installs behind an auth proxy that adds its own Bearer header are unaffected. - A token reaches only an explicit allowlist of routes, matched by method and exact path and grouped into the read, organize and draft scopes. Every other route answers 403, the scheduled-send routes included: a token may draft, never send. - Guards keep organize reversible: no moves into Trash, Junk, Drafts, Snoozed or folders MailFlow does not sync, no archive into Gmail's All Mail, and drafts are never moved, archived or snoozed. Token drafts are new and plain text only. - A token request gets a request-only session: no cookie and no Redis write. The CSRF check and the screen lock move into modules that know about tokens, and the upload re-check re-reads the token row. - Creating a token asks for the current two-factor code, or else the password. An account that cannot sign in with a password confirms with a sign-in through its SSO provider in the last 10 minutes; the new /auth/oidc/:slug/start?action=reauth records one without signing in again. Under required two-factor, a user without an authenticator sets one up first. Expiries are 7, 30, 90 or 365 days, and 7 or 30 for SSO. - A password reset, now one transaction, and an admin setting a password both delete the user's tokens. So does a change that tightens sign-in, in the same transaction: password login turned off keeps only SSO-confirmed tokens, required two-factor deletes those confirmed with only a password, an admin turning off a user's two-factor deletes that user's tokens, and a provider turned off or deleted takes the tokens it confirmed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
0971b16 to
fa67b58
Compare
|
Rebased onto main 0441d23 (after 4.1.0), still one commit, and the description is updated to match. What the rebase changed:
Results on the rebased commit: |
maathimself
left a comment
There was a problem hiding this comment.
I reviewed the whole diff, ran the full suite against PostgreSQL 16 (4,233 pass; lint and lint:plugins clean), and had an independent second review done. The allowlist, the session isolation and the CSRF change hold up: neither review found a way for a token to reach anything outside its scopes. What needs changing is how a token's creation is confirmed, plus a few revocation gaps.
Needed before merge
- SSO confirmation does not make the user sign in again.
action=reauthsends the normal authorize request, with nopromptormax_age, and the callback does not look atauth_time, so with a live session at the provider the round trip completes without any input.rememberOidcSessionalso records a confirmation at every SSO login. So at an unlocked browser, or with a stolen MailFlow session cookie in the 10 minutes after a login, an SSO-only account can get a 30-day token, while an account with a password always has to enter it or a two-factor code. Please:- send
prompt=loginandmax_age=0on the reauth authorize request; - store when the reauth flow started in
oidcPending, and in the callback refuse an id_token whoseauth_timeis missing or earlier than that (allowing a minute of clock skew), since not every provider honorsprompt; - record a confirmation only from the reauth flow, not from ordinary logins. That drops "an SSO sign-in counts" from the design; the token form's Confirm button covers it.
- send
fromScheduledon token drafts.routes/draft.jsdeletes the scheduled message named byfromScheduled(from 4.1). A token cannot learn an edit token today, since onlyroutes/scheduled.jsreturns one, but a draft-scope request should not carry a delete at all. Please refusefromScheduledin the draft guard, with a test.- Revocation gaps. Please delete tokens in the same transaction as each of these:
POST /api/totp/disable: the user's tokens confirmed withtotp.DELETE /api/auth/oidc/identities/:id: the user's tokens confirmed through that identity's provider, when they have no other identity left at it.PATCH /api/admin/oidc/:idchangingissuer_urlorclient_id: the provider's tokens, as forenabled: false.
- Policy race.
reauthForreads the sign-in policy before bcrypt or TOTP runs, and nothing checks it again at insert. If an admin requires two-factor or turns off password sign-in in between, the revoking DELETE cannot see the uncommitted row, and a password-confirmed token survives the change. Re-checking inside the transaction is not enough on its own at READ COMMITTED. IncreateToken's transaction, readmfa_enforcementandinternal_auth_disabledwithSELECT … FOR SHARE(both rows are seeded by migrations), and refuse ifconfirmedWithno longer qualifies. The settings PATCH updates those rows, so the two then wait for each other in either order.
Optional
- The bulk-archive guard refuses only All Mail. Running the resolved archive destination through
isProtectedDestination, as bulk-move does, would also cover an archive mapping that points at Trash or Junk.
Separately, linking a new identity (action=link) needs only a signed-in session, so whoever holds a session can link an identity of their own and then use it to confirm. That predates this PR and I'll track it in its own issue; it doesn't block this one.
…ps (maathimself#617) Review round 1 of maathimself#664. - The token form's SSO confirmation (action=reauth) sends prompt=login and max_age=0, records when it began, and refuses an id_token whose auth_time is missing or earlier than that, less a minute of clock skew. An ordinary SSO sign-in records no confirmation any more. - A token draft may not carry fromScheduled, which deletes the scheduled message a draft was made from. - Tokens are deleted in the same transaction when a user turns their own two-factor off (those it confirmed), when they unlink their last identity at a provider (those confirmed through it), and when an admin points a provider at another issuer or client (as when turning it off). - createToken reads mfa_enforcement and internal_auth_disabled FOR SHARE in its transaction and refuses a confirmation the policy no longer allows, so a policy change and a creation wait for each other instead of racing. - bulk-archive holds the resolved archive folder to a move's rules, so a mapping at Trash or Junk is refused as well as All Mail. Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
Thanks for the thorough review, and for the second one. All four are done, plus the optional archive check, as a second commit (8539946) on top of the rebased feature, so the delta is easy to see. The description is updated to match. 1. SSO confirmation is a sign-in just made. Agreed on every part, and the point about
One consequence to be aware of: a provider that never emits 2. 3. Revocation gaps, each in the same transaction as the change:
4. Policy race. Optional: archive. Done. The resolved archive folder goes through Each new check was proved by breaking it and watching its test fail (11 mutations, all caught). Results: |
…ps (maathimself#617) Review round 1 of maathimself#664. - The token form's SSO confirmation (action=reauth) sends prompt=login and max_age=0, records when it began, and refuses an id_token whose auth_time is missing or earlier than that, less a minute of clock skew. An ordinary SSO sign-in records no confirmation any more. - A token draft may not carry fromScheduled, which deletes the scheduled message a draft was made from. - Tokens are deleted in the same transaction when a user turns their own two-factor off (those it confirmed), when they unlink their last identity at a provider (those confirmed through it), and when an admin points a provider at another issuer or client (as when turning it off). - createToken reads mfa_enforcement and internal_auth_disabled FOR SHARE in its transaction and refuses a confirmation the policy no longer allows, so a policy change and a creation wait for each other instead of racing. - bulk-archive holds the resolved archive folder to a move's rules, so a mapping at Trash or Junk is refused as well as All Mail. Ported to dev, which keeps the fork's migrations 0067_api_tokens and 0068_api_tokens_confirmation rather than the PR's 0072: confirmed_with stays nullable there, revokeTokensForPolicy keeps its COALESCE for rows made before 0068, and confirmationAllowed reads a null confirmed_with the same way, as a password confirmation. Only the oidc.js imports conflicted. Co-Authored-By: Claude Fable 5.1 <[email protected]> (cherry picked from commit 8539946)
… button MailFlow's token form (maathimself/mailflow#664, review round 1) now makes the provider ask for credentials again, and an ordinary sign-in no longer counts, so step 3 of Create a token said something that is no longer true. The 401 note lists the revocation rules as they are now: turning your own two-factor off deletes the tokens it confirmed, an administrator's reset deletes all of yours, and a provider's tokens go when it is turned off, deleted or pointed at another issuer or client, or when you unlink your last identity at it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Summary
This is the backend of #617: personal API tokens, as settled in that thread. With a token, a script or tool can use MailFlow's API without the account password, and keeps working with two-factor sign-in on. My own case is an MCP server for Claude, running on another machine.
This is the first of the two PRs #617 describes. The Settings UI (the token card, the admin switch and 11 locales) is the second, and I'll open it once this one merges. The MCP server itself stays outside MailFlow, at https://github.com/Monkey7539/mailflow-mcp.
Changes
Storage. Migration 0072 adds
api_tokens. For each token it holds:trusted_devicesand reset links do;password,totporoidc), and for single sign-on, the provider.api_tokens_enabledin system_settings defaults to off. It is read through a 30 s cache, which the admin settings PATCH clears.Which requests are token requests. Only
Authorization: Bearer mfat_…, as agreed on #617:mfat_value that doesn't check out gets a 401, and any cookie on the request is ignored;Allowlist (
services/apiTokenAllowlist.js). Each entry is an exact method and path, compared withreq.originalUrl:/api/health,/api/version,/api/api-tokens/selfEvery other route answers 403, the scheduled-send routes included.
Guards after parsing (
middleware/apiTokenGuards.js):folderRoleshelper;existing*fields,fromScheduled(which deletes the scheduled message a draft was made from), any truthybodyIsHtml, andquotedBodyHtmlare refused;Session plumbing:
req.session: no cookie and no Redis write.mountApiAuth, so a test can check its order: token auth, body parsers, session, CSRF, screen lock, guards.reloadSessionre-checks the token after a large upload.requireAuthanswers a token whose user is gone with 401, and doesn't update the user's last-seen time for token requests.requireAdminrefuses tokens outright.Management API (
routes/apiTokens.js):reauth,expiryChoicesandmaxTokens;/self, so a token can see its own name and scopes.Confirming creation (
services/apiTokenReauth.js), as settled on #617:totp:<uid>limit as the sign-in challenge.auth:<ip>./auth/oidc/:slug/start?action=reauth:prompt=loginandmax_age=0, so the provider asks for credentials again and must putauth_timein the id_token;auth_timeis missing or earlier than that, less a minute of clock skew, since not every provider honoursprompt;Expiries are 7, 30, 90 or 365 days, and 7 or 30 for SSO-confirmed tokens. A wrong code or password is logged as
totp_failorlogin_fail. The confirmation is never answered with a 401, which the web client would take for an expired session.Revocation.
api_tokensis not restored.A change that tightens sign-in deletes the tokens it no longer allows, in the transaction that makes the change. The
[admin]log line says how many went:The policy and a creation cannot race.
createTokenreadsmfa_enforcementandinternal_auth_disabledwithSELECT … FOR SHAREinside its transaction, after the confirmation, and refuses (409) a confirmation the policy no longer allows. The settings PATCH updates those rows in its own transaction before deleting tokens, so whichever runs first, the other waits and then sees it: a token confirmed with a password can neither slip in behind a change to required two-factor nor survive it.Limits (numbers as agreed on #617):
apitoken-fail:<ip>, not the sign-in budget. Only malformed or unknown tokens spend it, so a stale token in a tool can't lock its owner out of web sign-in. An expired token always gets 401 and spends nothing;A refused body is drained up to 1 MB, then the connection is closed.
Logging. There are three auth events:
api_token_created,api_token_revokedandapi_token_auth_fail. Neither the token nor its hash is ever logged, and the diagnostics scrubber redactsmfat_.Where this differs from #617's original text
The thread settled the Bearer rule, the move and archive guards, re-authentication and expiry, the policy changes and the limits. These details were not in it:
revoked_at, so no lookup can forget to check a flag. The auth events keep the history.quotedBodyHtmlis stored unsanitized today.GET /api/accountsreturns a narrower shape to tokens: no hosts, ports, TLS settings, login names, signatures, folder mappings or plugin settings.req.securereflects whatever the client claims, so the check would mean little.Testing
Suite:
npm testpasses 4,053 tests; the 209 skipped are the PostgreSQL-only ones, which your CI now runs withMAILFLOW_TEST_DATABASE_URL.npm run lintandnpm run lint:pluginsare clean.What the new tests cover:
mountApiAuthchain on a real express-session, Basic credentials included;prompt,max_age,auth_time, a plain sign-in confirming nothing), each revocation path, the policy re-check and itsFOR SHARE, and the limits.I proved each security check by breaking the code and watching its test fail.
PostgreSQL 16 and Redis, in a temporary workflow on my fork: https://github.com/Monkey7539/mailflow/actions/runs/41069390623 (the review-round commit)
Not checked: multi-replica deployments. The search limiter is per process, as it is today.
Contributor License Agreement
By submitting this pull request I confirm that:
third-party material and confirmed it is compatible with the CLA).
🤖 Generated with Claude Code