mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
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.
121 lines
4.2 KiB
TypeScript
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;
|
|
}
|
|
}
|