130 lines
4.5 KiB
TypeScript
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();
|
|
}
|