gh_UserManager/apps/web/lib/webauthn.ts
bermooda-company 54d5891edf user
2026-08-23 23:59:14 +03:30

130 lines
4.5 KiB
TypeScript

// WebAuthn helpers for passkey registration/authentication against the
// MyAccount Hamsoo API. Implements the real WebAuthn ceremony (the backend
// performs cryptographic attestation/assertion verification).
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
function bufToBase64url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let str = "";
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64urlToBuf(b64: string): ArrayBuffer {
const pad = b64.length % 4 ? 4 - (b64.length % 4) : 0;
const b = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "====".slice(0, pad));
const bytes = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) bytes[i] = b.charCodeAt(i);
return bytes.buffer;
}
function prepareCreationOptions(publicKey: any) {
publicKey.challenge = base64urlToBuf(publicKey.challenge);
if (publicKey.user) publicKey.user.id = base64urlToBuf(publicKey.user.id);
if (Array.isArray(publicKey.excludeCredentials)) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((c: any) => ({
...c,
id: base64urlToBuf(c.id),
}));
}
return publicKey;
}
export async function registerPasskey(token: string, name: string) {
const beginRes = await fetch(`${BASE}/auth/passkeys/register/begin/`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
credentials: "include",
});
if (!beginRes.ok) throw new Error("Failed to begin passkey registration");
const options = await beginRes.json();
const cred = (await navigator.credentials.create({
publicKey: prepareCreationOptions(options.publicKey),
})) as any;
const payload = {
id: cred.id,
rawId: bufToBase64url(cred.rawId),
response: {
clientDataJSON: bufToBase64url(cred.response.clientDataJSON),
attestationObject: bufToBase64url(cred.response.attestationObject),
},
type: cred.type,
clientExtensionResults: cred.getClientExtensionResults
? cred.getClientExtensionResults()
: {},
name,
};
const finishRes = await fetch(`${BASE}/auth/passkeys/register/finish/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!finishRes.ok) {
const err = await finishRes.json().catch(() => ({}));
throw new Error(err.detail || "Passkey registration failed");
}
return finishRes.json();
}
export async function authenticatePasskey(token: string | null, email: string) {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const beginRes = await fetch(`${BASE}/auth/passkeys/authenticate/begin/`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
credentials: "include",
body: JSON.stringify({ email }),
});
if (!beginRes.ok) throw new Error("Failed to begin passkey authentication");
const options = await beginRes.json();
const requestOptions = options.publicKey;
requestOptions.challenge = base64urlToBuf(requestOptions.challenge);
if (Array.isArray(requestOptions.allowCredentials)) {
requestOptions.allowCredentials = requestOptions.allowCredentials.map(
(c: any) => ({ ...c, id: base64urlToBuf(c.id) }),
);
}
const assertion = (await navigator.credentials.get({
publicKey: requestOptions,
})) as any;
const payload = {
id: assertion.id,
rawId: bufToBase64url(assertion.rawId),
response: {
clientDataJSON: bufToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufToBase64url(assertion.response.authenticatorData),
signature: bufToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufToBase64url(assertion.response.userHandle)
: undefined,
},
type: assertion.type,
clientExtensionResults: assertion.getClientExtensionResults
? assertion.getClientExtensionResults()
: {},
};
const finishRes = await fetch(`${BASE}/auth/passkeys/authenticate/finish/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (!finishRes.ok) {
const err = await finishRes.json().catch(() => ({}));
throw new Error(err.detail || "Passkey authentication failed");
}
return finishRes.json();
}