feat(web): MFA and passkey sign-in on the login page

This commit is contained in:
2026-09-16 09:22:18 +00:00
parent d8597ee3ae
commit 26825841fa
6 changed files with 825 additions and 132 deletions
+67 -4
View File
@@ -628,10 +628,13 @@ export interface AuthProviderUpdate {
order?: number;
}
class ApiError extends Error {
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public retryAfter?: number,
public attemptsLeft?: number,
) {
super(message);
this.name = "ApiError";
@@ -675,11 +678,16 @@ async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
if (!res.ok) {
let message = `HTTP ${res.status}`;
let code: string | undefined;
let attemptsLeft: number | undefined;
try {
const body = await res.json();
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (typeof body?.attempts_left === "number") attemptsLeft = body.attempts_left;
} catch {}
throw new ApiError(res.status, message);
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined, attemptsLeft);
}
if (res.status === 204) {
@@ -701,8 +709,8 @@ export const auth = {
});
},
login(email: string, password: string): Promise<{ ok: boolean }> {
return authRequest<{ ok: boolean }>("/auth/login", {
login(email: string, password: string): Promise<{ ok?: true } | { mfa_required: true; methods: string[] } | { enrol_required: true }> {
return authRequest("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
@@ -712,6 +720,61 @@ export const auth = {
return authRequest<void>("/auth/logout", { method: "POST" });
},
// --- second factor (an in-progress login, identified by the server-side ticket cookie) ---
mfaTotp(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/totp", { method: "POST", body: JSON.stringify({ code }) });
},
mfaRecovery(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/recovery", { method: "POST", body: JSON.stringify({ code }) });
},
mfaWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/webauthn/begin", { method: "POST" });
},
mfaWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/mfa/webauthn/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- passwordless passkey sign-in ---
passkeyLoginBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/passkey/begin", { method: "POST" });
},
passkeyLoginFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- forced enrolment (a fresh account that has not set up a second factor yet) ---
enrolTotpSetup(): Promise<{ secret: string; otpauth_url: string }> {
return authRequest("/auth/mfa/enrol/totp/setup", { method: "POST" });
},
enrolTotpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/totp/confirm", { method: "POST", body: JSON.stringify({ code }) });
},
enrolPasskeyBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/enrol/passkey/begin", { method: "POST" });
},
enrolPasskeyFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }),
});
},
me(): Promise<MeResponse> {
return authRequest<MeResponse>("/auth/me");
},
+68
View File
@@ -0,0 +1,68 @@
// Browser-side WebAuthn helpers. The server sends and expects base64url;
// the browser's credential APIs need ArrayBuffers.
function b64urlToBuffer(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "="));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToB64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function isPasskeySupported(): boolean {
return typeof window !== "undefined" && !!window.PublicKeyCredential;
}
export function toCreateOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
user: { ...options.user, id: b64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
export function toRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
// credentialToJSON produces the shape go-webauthn's parsers read.
export function credentialToJSON(cred: PublicKeyCredential): unknown {
const response = cred.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse;
const json: any = {
id: cred.id,
rawId: bufferToB64url(cred.rawId),
type: cred.type,
clientExtensionResults: cred.getClientExtensionResults(),
response: { clientDataJSON: bufferToB64url(response.clientDataJSON) },
};
if (response.attestationObject) {
json.response.attestationObject = bufferToB64url(response.attestationObject);
if (typeof response.getTransports === "function") {
json.response.transports = response.getTransports();
}
}
if (response.authenticatorData) {
json.response.authenticatorData = bufferToB64url(response.authenticatorData);
json.response.signature = bufferToB64url(response.signature);
json.response.userHandle = response.userHandle ? bufferToB64url(response.userHandle) : null;
}
return json;
}