docs: move the mobile hand-off contract to the website

This commit is contained in:
Linus Rath
2026-10-31 14:32:39 +01:00
parent f74ff49f58
commit 3bdbdb1364
13 changed files with 12 additions and 138 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ function OAuthCallbackInner() {
const data = await res.json();
if (typeof data.mobile_handoff_code === "string" && data.mobile_handoff_code) {
// The app sent a PKCE challenge: the server kept the tokens and
// the link carries only a one-time code (docs/mobile-handoff.md).
// the link carries only a one-time code (bulwarkmail.org/docs/development/mobile-handoff).
// The code starts working when the user confirms.
const handoffCode: string = data.mobile_handoff_code;
const handoffState = typeof data.mobile_state === "string" ? data.mobile_state : mobileState ?? "";
+1 -1
View File
@@ -195,7 +195,7 @@ function LoginPageContent() {
// handed over before the user confirms it (lib/auth/mobile-handoff.ts).
const rawMobileState = searchParams.get("mobile_state") ?? "";
// App builds that send a PKCE challenge get a one-time code in the link
// instead of the sign-in (docs/mobile-handoff.md). A malformed challenge
// instead of the sign-in (bulwarkmail.org/docs/development/mobile-handoff). A malformed challenge
// makes this no hand-off at all: falling back to the fragment would put
// the password in the link the app asked to keep it out of. Lite has no
// server to park the sign-in on and keeps handing over in the fragment.
+1 -1
View File
@@ -8,7 +8,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// was opened by the app with a `mobile_code_challenge`; after the user signed
// in and confirmed, it parks the sign-in here and opens the app's callback
// link with the returned code instead of the password
// (lib/auth/handoff-store.ts, docs/mobile-handoff.md).
// (lib/auth/handoff-store.ts, bulwarkmail.org/docs/development/mobile-handoff).
//
// The page already checked the password against the mail server when it
// signed in, and the old hand-off put whatever the page held into the link
+1 -1
View File
@@ -13,7 +13,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// from its callback link with the PKCE verifier behind the challenge it sent
// to the login page, and gets the sign-in the browser parked for it: the
// same fields older builds read from the link's fragment
// (lib/auth/handoff-store.ts, docs/mobile-handoff.md).
// (lib/auth/handoff-store.ts, bulwarkmail.org/docs/development/mobile-handoff).
//
// The code and verifier are the only credentials, so this route is
// unauthenticated (the app has no webmail cookies). Every failure is a 400
@@ -54,7 +54,7 @@ describe('MobileHandoffConsent', () => {
});
describe('MobileHandoffConsent with a code hand-off', () => {
// docs/mobile-handoff.md: the code only goes live when the user confirms,
// bulwarkmail.org/docs/development/mobile-handoff: the code only goes live when the user confirms,
// so the link is resolved on confirmation, not before.
const CODE_URL = 'bulwarkmobile://auth/callback?code=abc&state=m-1';
+1 -1
View File
@@ -8,7 +8,7 @@ interface MobileHandoffConsentProps {
/** The app link the browser will open, with the credential material in its fragment. */
url: string | null;
/**
* For the code hand-off (docs/mobile-handoff.md): makes the code live on
* For the code hand-off (bulwarkmail.org/docs/development/mobile-handoff): makes the code live on
* confirmation and returns the link to open, or null when that failed.
* Shows the dialog on its own, without `url`.
*/
-126
View File
@@ -1,126 +0,0 @@
# Mobile sign-in hand-off with a one-time code
The Bulwark mobile app can sign in through the webmail's login page in the
system browser. Until now the page opened the app's callback link with the
sign-in in the fragment (`flow=password` with the password, or `flow=oauth`
with the tokens). Any app on the device can register the `bulwarkmobile`
scheme, so that link can be caught by someone else.
With a PKCE challenge the link carries only a one-time code. The sign-in
stays on the webmail server until the app redeems the code with the matching
verifier. Without a challenge the page behaves as before, so older app builds
keep working.
## 1. Open the login page
```
GET <webmail>/<locale>/login
?mobile_redirect_uri=bulwarkmobile://auth/callback
&mobile_state=<opaque, up to 512 chars>
&mobile_code_challenge=<S256 challenge>
```
- `mobile_code_challenge` is `BASE64URL(SHA256(code_verifier))` without
padding, exactly 43 characters (RFC 7636, method S256 only). The verifier is
43 to 128 characters from `A-Z a-z 0-9 - . _ ~`. Use a fresh verifier for
every sign-in.
- A challenge that is present but malformed makes the page ignore the
hand-off: it acts as a normal sign-in page and gives the app nothing.
- `mobile_redirect_uri` must be exactly `bulwarkmobile://auth/callback`, as
before.
This works for the password form and for SSO/OAuth sign-in (the page routes
mobile OAuth through the server-side SSO flow). Bulwark Lite has no server to
keep the sign-in on; it ignores the challenge and still uses the fragment.
## 2. The callback link
After the user signs in and confirms the hand-off, the browser opens:
```
bulwarkmobile://auth/callback?code=<64 lowercase hex chars>&state=<mobile_state>
```
The parameters are in the query string, not the fragment. There is no
`flow`, no server URL and no secret in the link. Check `state` against the
value you sent before redeeming.
A link with a fragment (`#flow=...`) means the webmail is older than this
change, or is a Lite build; handle it as before.
## 3. Redeem the code
```
POST <webmail>/api/auth/handoff/redeem
Content-Type: application/json
{ "code": "<code from the link>", "code_verifier": "<verifier>" }
```
The code is valid for 60 seconds from the moment the user confirmed the
hand-off in the browser, and works once. Send no `Origin` or
`Sec-Fetch-Site` header (native HTTP clients don't), or the same-origin check
answers 403.
### Success: 200
The same fields the fragment used to carry, as JSON. `state` is included so
existing fragment parsing can be reused.
```json
{
"flow": "password",
"server_url": "https://mail.example.org",
"username": "alice@example.org",
"password": "...",
"state": "<mobile_state>"
}
```
```json
{
"flow": "oauth",
"server_url": "https://mail.example.org",
"access_token": "...",
"refresh_token": "...",
"expires_in": 3540,
"token_endpoint": "https://mail.example.org/auth/token",
"client_id": "bulwark-webmail",
"state": "<mobile_state>"
}
```
- `expires_in` is a number (the fragment had a string) and counts from the
redeem, not from the sign-in.
- `refresh_token` and `expires_in` are optional, as before.
- `token_endpoint` may point at the webmail's token proxy
(`<webmail>/api/auth/pair/token`), with a sealed `refresh_token`, exactly
like the old fragment and the QR pairing bundle.
### Errors
Every failure spends the code. Start a new sign-in after any of them.
| Status | `error` | Meaning |
| ------ | ------------------- | ----------------------------------------------- |
| 400 | `invalid_request` | Body is not JSON or `code` is malformed |
| 400 | `invalid_code` | Never issued, already redeemed or tried |
| 400 | `expired_code` | Confirmed more than 60 seconds ago |
| 400 | `invalid_verifier` | Verifier malformed or does not match |
| 429 | `too_many_requests` | More than 10 failed redeems from this address in 15 minutes |
| 403 | | Cross-origin browser request |
All responses carry `Cache-Control: no-store`.
## Webmail internals
- Store: `lib/auth/handoff-store.ts` (in-process, like the QR pairing store,
so single-instance deployments or sticky sessions).
- Password flow: when the user confirms, the login page parks the sign-in at
`POST /api/auth/handoff/create` and opens the link with the code it gets.
- SSO flow: `POST /api/auth/sso/start` takes `mobile_code_challenge` with the
other mobile parameters. `POST /api/auth/sso/complete` parks the tokens
itself and returns only `mobile_handoff_code`, so the tokens never reach
the browser. That code waits up to five minutes for the user; when they
confirm, the callback page calls `POST /api/auth/handoff/confirm`, which
starts its 60 seconds, and opens the link.
+1 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The browser side of the code hand-off (docs/mobile-handoff.md): parking a
// The browser side of the code hand-off (bulwarkmail.org/docs/development/mobile-handoff): parking a
// password sign-in, and confirming an SSO one, each give the app link with
// only the code. A failure gives no link at all, never the old fragment.
@@ -3,7 +3,7 @@ import { createHash } from 'crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// The code hand-off to the mobile app (docs/mobile-handoff.md): the browser
// The code hand-off to the mobile app (bulwarkmail.org/docs/development/mobile-handoff): the browser
// parks the sign-in (/api/auth/handoff/create, or /api/auth/sso/complete plus
// /api/auth/handoff/confirm), the app link carries only a code, and the app
// redeems it with its PKCE verifier (/api/auth/handoff/redeem). Wrong
+1 -1
View File
@@ -68,7 +68,7 @@ describe('signedInLoginAction', () => {
});
describe('code hand-off link', () => {
// docs/mobile-handoff.md: an app that sent a challenge gets a link with
// bulwarkmail.org/docs/development/mobile-handoff: an app that sent a challenge gets a link with
// the one-time code and its state, in the query, and nothing else.
it('carries only the code and the state', () => {
const url = new URL(buildMobileHandoffCodeUrl(MOBILE_HANDOFF_REDIRECT_URI, 'ab'.repeat(32), 'st&ate=1'));
+1 -1
View File
@@ -150,7 +150,7 @@ describe('/api/auth/sso/complete for the mobile app handoff', () => {
});
describe('/api/auth/sso/complete for an app that sent a PKCE challenge', () => {
// docs/mobile-handoff.md: the tokens stay on the server and the page gets
// bulwarkmail.org/docs/development/mobile-handoff: the tokens stay on the server and the page gets
// only a one-time code for the app link.
const VERIFIER = 'x'.repeat(50);
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
+1 -1
View File
@@ -4,7 +4,7 @@ import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff';
import { createFailureBudget } from '@/lib/auth/verify-budget';
// One-time codes for the browser sign-in hand-off to the mobile app
// (lib/auth/mobile-handoff.ts, docs/mobile-handoff.md). The app opens the
// (lib/auth/mobile-handoff.ts, bulwarkmail.org/docs/development/mobile-handoff). The app opens the
// login page with a PKCE `mobile_code_challenge`; once the user has signed in
// and confirmed, the sign-in (an OAuth bundle, or username + password) is
// parked here and the browser opens the app's callback link with nothing but
+1 -1
View File
@@ -38,7 +38,7 @@ export function buildMobileHandoffUrl(target: string, fragment: Record<string, s
* The sign-in is parked on the server (lib/auth/handoff-store.ts) and the
* link carries a one-time code the app redeems with its verifier at
* /api/auth/handoff/redeem. Older builds send no challenge and keep getting
* the fragment above. Contract: docs/mobile-handoff.md.
* the fragment above. Contract: bulwarkmail.org/docs/development/mobile-handoff.
*/
export function isMobileCodeChallenge(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);