Files
webmail/lib/auth/mobile-handoff-client.ts
T
Linus Rath ab21bf2df3 fix: keep a desktop sign-in in the browser's page until the user says yes
The hand-off parked a password before its question, for up to five
minutes, and on a shared computer another local user could ask for
someone's sign-in with a link of their own. Now nothing leaves the page
before the yes, and the instance has a switch for such machines.

- `/check` tests a password and keeps nothing, `/park` runs on the yes and
  hands over tokens wherever the server allows, a password as a last
  resort
- `/discard` and a timer take out what SSO has to park first, and a no
  revokes the refresh token, and the shared store's TTL is the real expiry
- `DESKTOP_BROWSER_SIGN_IN`, default on, closes the routes, the page's
  hand-off and the app's button; the question says which device asked
- tokens from a typed password stay a password account under the name
  typed (#1432)
- redeem refuses a server of the user's own when the device's rule says
  no
- the park budget is reserved before going upstream, and only failures
  count
- "Not now" ends the flow in the app at once, and the question names the
  account

`desktop-handoff-routes.test.ts` now tests the two-step flow against the
real budgets. `sso-start-route.test.ts`'s config mock answers the new
setting, and the SSO test's expected answer has the account in it.
2026-11-04 02:40:37 +01:00

121 lines
4.2 KiB
TypeScript

import { apiFetch } from '@/lib/browser-navigation';
import { buildMobileHandoffCodeUrl } from '@/lib/auth/mobile-handoff';
/**
* Park a password sign-in for the mobile app (/api/auth/handoff/create) and
* return the app link that carries only the one-time code. Null when the
* server would not take it; the caller must not fall back to putting the
* password in the link, which is what the app's challenge asked to avoid.
*/
export async function requestMobileHandoffCodeUrl(opts: {
target: string;
challenge: string;
state: string;
payload: { server_url: string; username: string; password: string };
}): Promise<string | null> {
try {
const res = await apiFetch('/api/auth/handoff/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code_challenge: opts.challenge, state: opts.state, ...opts.payload }),
});
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as { code?: unknown } | null;
if (typeof data?.code !== 'string' || !data.code) return null;
return buildMobileHandoffCodeUrl(opts.target, data.code, opts.state);
} catch {
return null;
}
}
/** The desktop app's sign-in, before its question: is this password right? Nothing is kept. */
export async function checkDesktopSignIn(opts: {
serverUrl: string;
serverId?: string;
username: string;
password: string;
}): Promise<{ ok: true } | { error: string }> {
try {
const res = await apiFetch('/api/auth/desktop-handoff/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_url: opts.serverUrl, server_id: opts.serverId, username: opts.username, password: opts.password }),
});
if (res.ok) return { ok: true };
const data = (await res.json().catch(() => null)) as { error?: unknown } | null;
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
} catch {
return { error: 'network_error' };
}
}
/** The tab with an SSO sign-in the user hasn't answered went, or said no: it's not kept. */
export function discardDesktopSignIn(code: string): void {
void apiFetch('/api/auth/desktop-handoff/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
keepalive: true,
}).catch(() => {});
}
/**
* The user said yes: parks the sign-in for the app (/api/auth/desktop-handoff/park), as
* tokens wherever the server can give them. This browser gets no session out of it.
*/
export async function parkDesktopSignIn(opts: {
challenge: string;
state: string;
serverUrl: string;
serverId?: string;
username: string;
password: string;
totp?: string;
redirectUri: string;
}): Promise<{ code: string } | { error: string }> {
try {
const res = await apiFetch('/api/auth/desktop-handoff/park', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code_challenge: opts.challenge,
state: opts.state,
server_url: opts.serverUrl,
server_id: opts.serverId,
username: opts.username,
password: opts.password,
totp: opts.totp,
redirect_uri: opts.redirectUri,
}),
});
const data = (await res.json().catch(() => null)) as { code?: unknown; error?: unknown } | null;
if (res.ok && typeof data?.code === 'string' && data.code) return { code: data.code };
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
} catch {
return { error: 'network_error' };
}
}
/**
* Confirm an SSO hand-off the server parked (/api/auth/handoff/confirm) and
* return the app link with its code. Null when the code is gone (it waited
* too long); the user has to start the sign-in in the app again.
*/
export async function confirmMobileHandoffCodeUrl(opts: {
target: string;
code: string;
state: string;
}): Promise<string | null> {
try {
const res = await apiFetch('/api/auth/handoff/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: opts.code }),
});
if (!res.ok) return null;
return buildMobileHandoffCodeUrl(opts.target, opts.code, opts.state);
} catch {
return null;
}
}