auth-yes/ui/public/auth-client.js

258 lines
7.1 KiB
JavaScript

// deno-lint-ignore-file
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser;
function setStatus(msg, isError = false) {
const el = document.getElementById("statusMessage");
if (el) {
el.textContent = msg;
el.className = isError ? "error" : "success";
}
}
async function startWebAuthnRegistration(username, inviteCode) {
setStatus("");
if (!username || !inviteCode) {
setStatus("Username and Invite Code are required.", true);
return;
}
try {
// 1. Fetch challenge from API
const resp = await fetch("/api/register/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, inviteCode }),
});
let data;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
return;
}
if (!resp.ok) {
setStatus(data.error || "Failed to get registration challenge", true);
return;
}
// 2. Pass challenge to authenticator
let attResp;
try {
attResp = await startRegistration({ optionsJSON: data.options });
} catch (error) {
if (error.name === "InvalidStateError") {
setStatus("Authenticator was probably already registered.", true);
} else {
setStatus(error.message || "Registration failed on device", true);
}
throw error;
}
// Extract PRF client extension result
let extensionResults;
if (typeof attResp.getClientExtensionResults === "function") {
extensionResults = attResp.getClientExtensionResults();
} else {
extensionResults = attResp.clientExtensionResults || {};
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/register/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
inviteCode,
response: {
...attResp,
clientExtensionResults: extensionResults,
},
}),
});
let verificationJSON;
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Verification failed (${verificationResp.status}): ${text}`,
true,
);
return;
}
if (verificationJSON.success) {
setStatus("Registration successful! You can now log in.");
setTimeout(() => {
globalThis.location.href = "/login";
}, 2000);
} else {
setStatus(
verificationJSON.error || "Registration verification failed",
true,
);
}
} catch (err) {
console.error(err);
}
}
async function startWebAuthnLogin(username) {
setStatus("");
try {
// 1. Fetch challenge
const resp = await fetch("/api/login/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: username || "" }),
});
let data;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Login challenge failed (${resp.status}): ${text}`, true);
return;
}
if (!resp.ok) {
setStatus(data.error || "Failed to get login challenge", true);
return;
}
// 2. Pass challenge to authenticator
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON: data.options });
} catch (error) {
setStatus(error.message || "Authentication failed on device", true);
throw error;
}
// Extract PRF extension results
let extensionResults;
if (typeof asseResp.getClientExtensionResults === "function") {
extensionResults = asseResp.getClientExtensionResults();
} else {
extensionResults = asseResp.clientExtensionResults || {};
}
let kekDerived = false;
if (extensionResults?.prf?.results?.first) {
try {
// Base64Url decode the string into Uint8Array
const base64UrlString = extensionResults.prf.results.first;
const base64 = base64UrlString.replace(/-/g, "+").replace(/_/g, "/");
const binaryString = atob(base64);
const prfOutput = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
prfOutput[i] = binaryString.charCodeAt(i);
}
const salt = new Uint8Array(32); // 32 byte salt for HKDF
const info = new TextEncoder().encode("auth-yes:prf:device-share:v1");
const ikm = await crypto.subtle.importKey(
"raw",
prfOutput,
{ name: "HKDF" },
false,
["deriveKey"],
);
const kek = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: salt,
info: info,
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
console.log("WebAuthn PRF extension KEK derived successfully");
kekDerived = true;
} catch (err) {
console.error("Failed to derive KEK from PRF output:", err);
}
} else {
console.log(
"WebAuthn PRF extension not supported or no output returned. Proceeding with standard authentication.",
);
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/login/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
response: asseResp,
}),
});
let verificationJSON;
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Login verification failed (${verificationResp.status}): ${text}`,
true,
);
return;
}
if (verificationJSON.success) {
setStatus("Login successful! Redirecting...");
let targetRedirect = "/dashboard";
try {
const params = new URLSearchParams(window.location.search);
const rawRedirect = params.get("redirect");
if (rawRedirect) {
if (rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")) {
targetRedirect = rawRedirect;
} else {
const parsed = new URL(rawRedirect);
if (
parsed.hostname.endsWith(".atyg.org") ||
parsed.hostname === "atyg.org" ||
parsed.hostname === "localhost"
) {
targetRedirect = rawRedirect;
}
}
}
} catch (_e) {
// Fallback to default
}
setTimeout(() => {
globalThis.location.href = targetRedirect;
}, 1000);
} else {
setStatus(verificationJSON.error || "Login verification failed", true);
}
} catch (err) {
console.error("[WebAuthn Login]", err);
setStatus(
err.message || "An unexpected error occurred during authentication",
true,
);
}
}