Files
openchamber/packages/ui/src/lib/passkeys.ts
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

213 lines
6.2 KiB
TypeScript

import {
startAuthentication,
startRegistration,
WebAuthnAbortService,
WebAuthnError,
} from '@simplewebauthn/browser';
import { runtimeFetch } from './runtime-fetch';
const PASSKEY_AUTH_OPTIONS_ENDPOINT = '/auth/passkey/authenticate/options';
const PASSKEY_AUTH_VERIFY_ENDPOINT = '/auth/passkey/authenticate/verify';
const PASSKEY_REGISTER_OPTIONS_ENDPOINT = '/auth/passkey/register/options';
const PASSKEY_REGISTER_VERIFY_ENDPOINT = '/auth/passkey/register/verify';
const PASSKEY_LIST_ENDPOINT = '/api/passkeys';
const PASSKEY_STATUS_ENDPOINT = '/auth/passkey/status';
const AUTH_RESET_ENDPOINT = '/api/auth/reset';
export type PasskeyStatus = {
enabled: boolean;
hasPasskeys: boolean;
passkeyCount: number;
rpID: string | null;
};
export type StoredPasskey = {
id: string;
label: string;
createdAt: number;
lastUsedAt: number | null;
deviceType: string;
backedUp: boolean;
};
type PasskeyAuthenticationOptions = {
issueClientToken?: boolean;
clientLabel?: string;
clientKind?: string;
dedupeKey?: string;
};
export const defaultPasskeyStatus: PasskeyStatus = {
enabled: false,
hasPasskeys: false,
passkeyCount: 0,
rpID: null,
};
const postJson = async (url: string, body?: unknown): Promise<Response> => runtimeFetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
export const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise<string> => {
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
return payload.error;
}
} catch {
// Ignore malformed error payloads and fall back to the provided message.
}
return fallback;
};
export const isPasskeyCeremonyAbort = (error: unknown): boolean => (
error instanceof WebAuthnError && error.code === 'ERROR_CEREMONY_ABORTED'
);
export const cancelPasskeyCeremony = () => {
WebAuthnAbortService.cancelCeremony();
};
export const getPasskeySupportState = () => {
if (typeof window === 'undefined') {
return { supported: false, reason: 'Passkeys are unavailable outside the browser.' };
}
if (!window.isSecureContext) {
return { supported: false, reason: 'Passkeys require HTTPS or localhost.' };
}
return { supported: true, reason: '' };
};
export const registerCurrentDevicePasskey = async () => {
const support = getPasskeySupportState();
if (!support.supported) {
throw new Error(support.reason);
}
const label = typeof navigator.userAgent === 'string' && navigator.userAgent.trim()
? navigator.userAgent
: 'This device';
const optionsResponse = await postJson(PASSKEY_REGISTER_OPTIONS_ENDPOINT, { label });
if (!optionsResponse.ok) {
throw new Error(await getPasskeyErrorMessage(optionsResponse, 'Could not start passkey setup.'));
}
const { requestId, optionsJSON } = await optionsResponse.json();
const registrationResponse = await startRegistration({ optionsJSON });
const verifyResponse = await postJson(PASSKEY_REGISTER_VERIFY_ENDPOINT, {
requestId,
response: registrationResponse,
});
if (!verifyResponse.ok) {
throw new Error(await getPasskeyErrorMessage(verifyResponse, 'Could not finish passkey setup.'));
}
return verifyResponse.json().catch(() => null);
};
export const authenticateWithPasskey = async (trustDevice: boolean, options: PasskeyAuthenticationOptions = {}) => {
const support = getPasskeySupportState();
if (!support.supported) {
throw new Error(support.reason);
}
const optionsResponse = await postJson(PASSKEY_AUTH_OPTIONS_ENDPOINT);
if (!optionsResponse.ok) {
throw new Error(await getPasskeyErrorMessage(optionsResponse, 'Passkey sign-in is not available right now.'));
}
const { requestId, optionsJSON } = await optionsResponse.json();
const authResponse = await startAuthentication({ optionsJSON });
const verifyResponse = await postJson(PASSKEY_AUTH_VERIFY_ENDPOINT, {
requestId,
response: authResponse,
trustDevice,
issueClientToken: options.issueClientToken === true,
clientLabel: options.clientLabel,
clientKind: options.clientKind,
dedupeKey: options.dedupeKey,
});
if (!verifyResponse.ok) {
throw new Error(await getPasskeyErrorMessage(verifyResponse, 'Passkey sign-in failed.'));
}
return verifyResponse.json().catch(() => null);
};
export const fetchPasskeyStatus = async (): Promise<PasskeyStatus> => {
const response = await runtimeFetch(PASSKEY_STATUS_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
return defaultPasskeyStatus;
}
const payload = await response.json().catch(() => null);
return {
enabled: payload?.enabled === true,
hasPasskeys: payload?.hasPasskeys === true,
passkeyCount: typeof payload?.passkeyCount === 'number' ? payload.passkeyCount : 0,
rpID: typeof payload?.rpID === 'string' && payload.rpID ? payload.rpID : null,
};
};
export const fetchStoredPasskeys = async (): Promise<StoredPasskey[]> => {
const response = await runtimeFetch(PASSKEY_LIST_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not load passkeys.'));
}
const payload = await response.json().catch(() => null);
return Array.isArray(payload?.passkeys) ? payload.passkeys : [];
};
export const revokeStoredPasskey = async (id: string) => {
const response = await runtimeFetch(`${PASSKEY_LIST_ENDPOINT}/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not remove passkey.'));
}
return response.json().catch(() => null);
};
export const resetAllAuth = async () => {
const response = await postJson(AUTH_RESET_ENDPOINT);
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not clear saved authentication.'));
}
return response.json().catch(() => null);
};