auth-yes/src/features/auth/register_routes.ts

274 lines
7.6 KiB
TypeScript

import { Hono } from "jsr:@hono/hono@4";
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "jsr:@simplewebauthn/server@13";
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
import { valkey } from "../../core/valkey.ts";
import { getClientIp, publicRateLimiter } from "../../core/middleware.ts";
import { auditWrapper } from "../../core/audit.ts";
import {
createPasskey,
createSession,
createUser,
getInviteToken,
getUserByUsername,
markInviteTokenUsed,
} from "./queries.ts";
import { RegisterPageFragment } from "./register_fragments.tsx";
export const registerRoutes = new Hono();
const rpID = Deno.env.get("RP_ID") ||
(import.meta.main ? undefined : "localhost");
const origin = Deno.env.get("ORIGIN") ||
(import.meta.main ? undefined : "http://localhost");
function getCookieDomain(customRpId?: string): string | undefined {
const envDomain = Deno.env.get("COOKIE_DOMAIN");
if (envDomain) {
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
}
const targetId = customRpId || Deno.env.get("RP_ID") || "";
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
return undefined;
}
const parts = targetId.split(".").filter(Boolean);
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
// UI Route
registerRoutes.get("/register", (c) => {
const code = c.req.query("code") || "";
return c.html(RegisterPageFragment({ initialCode: code }));
});
// API Routes
registerRoutes.use("/api/register/*", publicRateLimiter);
// Register Challenge
registerRoutes.post("/api/register/challenge", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid payload" }, 400);
}
const { username, inviteCode } = body;
if (!username || !inviteCode) {
return c.json({ error: "Username and invite code are required" }, 400);
}
if (!rpID) throw new Error("rpID missing");
const userIdBytes = new Uint8Array(16);
crypto.getRandomValues(userIdBytes);
const newUserId = crypto.randomUUID();
const options = await generateRegistrationOptions({
rpName: "Auth-Yes Identity",
rpID,
userName: username,
userID: userIdBytes,
attestationType: "direct",
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "preferred",
},
timeout: 60000,
extensions: {
["prf" as string]: {},
} as any,
});
setCookie(c, "expected_registration_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
setCookie(c, "registration_user_id", newUserId, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options, username });
});
// Verify registration and create UUID/session
registerRoutes.post("/api/register/verify", async (c) => {
try {
const { response, username, inviteCode, upgrade_session } = await c.req
.json();
if (!inviteCode && !upgrade_session) {
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
}
const expectedChallenge = getCookie(c, "expected_registration_challenge");
const registrationUserId = getCookie(c, "registration_user_id");
if (!expectedChallenge || !registrationUserId) {
return c.json({
error: "Missing or expired registration challenge/user ID",
}, 400);
}
let user = await getUserByUsername(username);
if (user) {
return c.json({ error: "Username already exists" }, 409);
}
if (!origin || !rpID) throw new Error("Missing origin or rpID");
let verification;
try {
verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.json({ error: "Verification failed" }, 400);
}
const credentialID = registrationInfo.credential.id;
const credentialPublicKey = registrationInfo.credential.publicKey;
const counter = registrationInfo.credential.counter;
const base64CredentialID = typeof credentialID === "string"
? credentialID
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
const base64PublicKey = encodeBase64Url(
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
);
const prfEnabled =
(response.clientExtensionResults as any)?.prf?.enabled === true;
let prfSalt = null;
if (prfEnabled) {
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
prfSalt = encodeBase64Url(saltBytes);
}
if (upgrade_session) {
const sessionDataStr = await valkey.get(upgrade_session);
if (!sessionDataStr) {
return c.json({ error: "Invalid or expired guest session" }, 400);
}
const sessionData = JSON.parse(sessionDataStr);
if (
!sessionData || !sessionData.uuid ||
sessionData.account_status !== "guest"
) {
return c.json({ error: "Invalid guest session state" }, 400);
}
const guestUuid = sessionData.uuid;
user = await createUser(guestUuid, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await valkey.setex(
upgrade_session,
28800,
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
);
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
await createSession(upgrade_session, user.id, expiresAt);
} else {
const invite = await getInviteToken(inviteCode);
if (!invite) {
return c.json(
{ error: "Invalid, expired, or fully claimed invite code" },
400,
);
}
user = await createUser(registrationUserId, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await markInviteTokenUsed(invite.id, user.id);
}
auditWrapper.auditLog(
user.id,
"user_registered",
null,
{ username, inviteCode },
getClientIp(c),
);
setCookie(c, "expected_registration_challenge", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
setCookie(c, "registration_user_id", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
const cookieDomain = getCookieDomain(rpID);
if (cookieDomain) {
setCookie(c, "session_id", "", {
domain: cookieDomain,
path: "/",
maxAge: 0,
});
}
setCookie(c, "session_id", "", { path: "/", maxAge: 0 });
return c.json({ success: true });
} catch (error: any) {
console.error(
"[Auth API] Uncaught Exception in /api/register/verify:",
error,
);
return c.json({ error: error.message || "Internal server error" }, 500);
}
});