274 lines
7.6 KiB
TypeScript
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);
|
|
}
|
|
});
|