* feat(auth): add passkey login for protected UI * fix: polish passkey setup and correct WebAuthn user IDs * feat: improve passkey login management * build: align passkey deps with upstream main * refactor: move ui auth out of opencode module --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
import {
|
|
startAuthentication,
|
|
startRegistration,
|
|
WebAuthnAbortService,
|
|
WebAuthnError,
|
|
} from '@simplewebauthn/browser';
|
|
|
|
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;
|
|
};
|
|
|
|
export const defaultPasskeyStatus: PasskeyStatus = {
|
|
enabled: false,
|
|
hasPasskeys: false,
|
|
passkeyCount: 0,
|
|
rpID: null,
|
|
};
|
|
|
|
const postJson = async (url: string, body?: unknown): Promise<Response> => fetch(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) => {
|
|
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,
|
|
});
|
|
|
|
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 fetch(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 fetch(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 fetch(`${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);
|
|
};
|