feat(web): MFA and passkey sign-in on the login page
This commit is contained in:
+67
-4
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user