mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
docs: move the mobile hand-off contract to the website
This commit is contained in:
@@ -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 ?? "";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user