Files
webmail/lib/auth/desktop-handoff-server.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

64 lines
2.7 KiB
TypeScript

import { configManager } from '@/lib/admin/config-manager';
import { findServerById, findServerByUrl, parseJmapServers, resolveTrustedJmapUrl, type JmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
/**
* What the desktop app's sign-in routes share (app/api/auth/desktop-handoff). The same
* pinning as the other sign-in routes, and the instance admin's switch: on a terminal
* server another local user can bind a loopback port, so the admin can turn the hand-off
* off there. A device's own `BrowserSignIn` doesn't reach this: it's about the app, and
* the other user's link opens in a browser.
*/
export interface DesktopHandoffRefusal {
error: string;
status: number;
}
export function desktopHandoffEnabled(): boolean {
return !IS_DESKTOP_BUILD && configManager.get<boolean>('desktopBrowserSignIn', true);
}
export type DesktopHandoffServer =
| { refusal: DesktopHandoffRefusal }
| {
entry: JmapServerEntry | undefined;
/** What's checked and parked, whatever the body said. */
resolved: string;
upstream: string;
trusted: boolean;
/** The user's own server, by `allowCustomJmapEndpoint`. A device's policy may refuse it. */
custom: boolean;
};
export async function resolveDesktopHandoffServer(serverUrl: string, serverId: unknown): Promise<DesktopHandoffServer> {
await configManager.ensureLoaded();
if (!desktopHandoffEnabled()) return { refusal: { error: 'disabled', status: 403 } };
if (configManager.get<boolean>('oauthEnabled', false) && configManager.get<boolean>('oauthOnly', false)) {
return { refusal: { error: 'oauth_only', status: 403 } };
}
const configured =
configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const list = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const requested = typeof serverId === 'string' && serverId ? serverId : null;
const entry = findServerById(list, requested) || findServerByUrl(list, serverUrl) || undefined;
let resolved: string;
let trusted = true;
let custom = false;
if (entry) {
resolved = entry.url;
} else {
const pinned = resolveTrustedJmapUrl(serverUrl, configured, list);
if (pinned) {
resolved = pinned;
} else if (configManager.get<boolean>('allowCustomJmapEndpoint', false) && (await isPublicHttpUrl(serverUrl))) {
resolved = serverUrl;
trusted = false;
custom = true;
} else {
return { refusal: { error: 'invalid_server_url', status: 400 } };
}
}
return { entry, resolved, upstream: resolved.replace(/\/+$/, ''), trusted, custom };
}