diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index e9862713d..8a6c3cdbf 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -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 ?? ""; diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index dbe79acf4..6270b85cc 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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. diff --git a/app/api/auth/handoff/create/route.ts b/app/api/auth/handoff/create/route.ts index ee9705a0c..0e03ccd05 100644 --- a/app/api/auth/handoff/create/route.ts +++ b/app/api/auth/handoff/create/route.ts @@ -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 diff --git a/app/api/auth/handoff/redeem/route.ts b/app/api/auth/handoff/redeem/route.ts index fc6166af0..96c57eaf8 100644 --- a/app/api/auth/handoff/redeem/route.ts +++ b/app/api/auth/handoff/redeem/route.ts @@ -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 diff --git a/components/auth/__tests__/mobile-handoff-consent.test.tsx b/components/auth/__tests__/mobile-handoff-consent.test.tsx index c4d2243d0..8d6c4ef71 100644 --- a/components/auth/__tests__/mobile-handoff-consent.test.tsx +++ b/components/auth/__tests__/mobile-handoff-consent.test.tsx @@ -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'; diff --git a/components/auth/mobile-handoff-consent.tsx b/components/auth/mobile-handoff-consent.tsx index 39033e317..695161a7e 100644 --- a/components/auth/mobile-handoff-consent.tsx +++ b/components/auth/mobile-handoff-consent.tsx @@ -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`. */ diff --git a/docs/mobile-handoff.md b/docs/mobile-handoff.md deleted file mode 100644 index 1a38fb9ec..000000000 --- a/docs/mobile-handoff.md +++ /dev/null @@ -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 //login - ?mobile_redirect_uri=bulwarkmobile://auth/callback - &mobile_state= - &mobile_code_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= -``` - -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 /api/auth/handoff/redeem -Content-Type: application/json - -{ "code": "", "code_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": "" -} -``` - -```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": "" -} -``` - -- `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 - (`/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. diff --git a/lib/__tests__/mobile-handoff-client.test.ts b/lib/__tests__/mobile-handoff-client.test.ts index 67caf953c..65f54ea2f 100644 --- a/lib/__tests__/mobile-handoff-client.test.ts +++ b/lib/__tests__/mobile-handoff-client.test.ts @@ -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. diff --git a/lib/__tests__/mobile-handoff-code-routes.test.ts b/lib/__tests__/mobile-handoff-code-routes.test.ts index e56c1f1c9..e49bde36e 100644 --- a/lib/__tests__/mobile-handoff-code-routes.test.ts +++ b/lib/__tests__/mobile-handoff-code-routes.test.ts @@ -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 diff --git a/lib/__tests__/mobile-handoff.test.ts b/lib/__tests__/mobile-handoff.test.ts index bc616c2aa..d47094808 100644 --- a/lib/__tests__/mobile-handoff.test.ts +++ b/lib/__tests__/mobile-handoff.test.ts @@ -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')); diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index 8de15ddf5..e5da63b89 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -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'); diff --git a/lib/auth/handoff-store.ts b/lib/auth/handoff-store.ts index 57ef77fb1..d4a2f6a53 100644 --- a/lib/auth/handoff-store.ts +++ b/lib/auth/handoff-store.ts @@ -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 diff --git a/lib/auth/mobile-handoff.ts b/lib/auth/mobile-handoff.ts index 5356600bc..3de25976f 100644 --- a/lib/auth/mobile-handoff.ts +++ b/lib/auth/mobile-handoff.ts @@ -38,7 +38,7 @@ export function buildMobileHandoffUrl(target: string, fragment: Record