- Extracts Auth, Registration, and Passkey routes into `server/routes/auth.ts`. - Extracts all Admin API endpoints into `server/routes/admin.ts`. - Extracts RPC Connect setup and mTLS listener into `server/rpc.ts`. - Extracts global rate limiters and IP helpers into `server/middleware.ts`. - Reduces `server/main.ts` purely to an entrypoint mounting orchestrator. - Ensures all existing tests and quality gates pass with zero regressions. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
929 lines
27 KiB
TypeScript
929 lines
27 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
|
import {
|
|
decodeBase64Url,
|
|
encodeBase64Url,
|
|
} from "jsr:@std/encoding@1/base64url";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
MetadataService,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
} from "jsr:@simplewebauthn/server@13";
|
|
import type {
|
|
AuthenticationResponseJSON,
|
|
RegistrationResponseJSON,
|
|
} from "jsr:@simplewebauthn/server@13";
|
|
|
|
import { sqlWrapper } from "../db.ts";
|
|
import { valkey } from "../valkey.ts";
|
|
import { auditWrapper } from "../audit.ts";
|
|
import {
|
|
extractAllSessionIds,
|
|
getAuthenticatedUser,
|
|
requirePrimarySession,
|
|
} from "../auth-session.ts";
|
|
import { getClientIp, publicRateLimiter } from "../middleware.ts";
|
|
|
|
export const authRoutes = new Hono();
|
|
|
|
const rpName = "Auth-Yes Identity Provider";
|
|
const rpID = Deno.env.get("RP_ID") ||
|
|
(import.meta.main ? undefined : "localhost");
|
|
const origin = Deno.env.get("ORIGIN") ||
|
|
(import.meta.main ? undefined : "http://localhost");
|
|
const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true";
|
|
|
|
export 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}`;
|
|
}
|
|
|
|
function generateSessionId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
authRoutes.use("/api/register/*", publicRateLimiter);
|
|
authRoutes.use("/api/login/*", publicRateLimiter);
|
|
authRoutes.use("/api/passkeys/*", requirePrimarySession);
|
|
|
|
authRoutes.get("/.well-known/webauthn", (c) => {
|
|
if (!origin) {
|
|
return c.json({ origins: [] });
|
|
}
|
|
return c.json({ origins: [origin] });
|
|
});
|
|
|
|
// Start a WebAuthn registration ceremony
|
|
authRoutes.post("/api/register/challenge", async (c) => {
|
|
const { username, inviteCode } = await c.req.json();
|
|
|
|
if (!username || !inviteCode) {
|
|
return c.json({ error: "Username and inviteCode required" }, 400);
|
|
}
|
|
|
|
// Validate invite code early (checks expiration and max_uses bounds)
|
|
const invite = await sqlWrapper
|
|
.sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
|
.then((res: any) => res[0]);
|
|
if (!invite) {
|
|
return c.json(
|
|
{ error: "Invalid, expired, or fully claimed invite code" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
// Prevent hijacking an existing user's account if they already exist
|
|
const existingUser = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
|
res[0]
|
|
);
|
|
if (existingUser) {
|
|
return c.json({ error: "Username already exists" }, 409);
|
|
}
|
|
|
|
const newUserId = crypto.randomUUID();
|
|
const userIdBytes = new TextEncoder().encode(newUserId);
|
|
|
|
if (!rpID) throw new Error("rpID is missing");
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
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
|
|
authRoutes.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);
|
|
}
|
|
|
|
// Prevent race condition account hijacking
|
|
let user = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`
|
|
.then(
|
|
(res: any) => res[0],
|
|
);
|
|
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);
|
|
}
|
|
|
|
// Enterprise Allow-List Verification
|
|
const allowlistCount = await sqlWrapper
|
|
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
|
Number(res[0].count)
|
|
);
|
|
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
|
const isAllowed = await sqlWrapper
|
|
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
|
.then((res: any) => res[0]);
|
|
if (!isAllowed) {
|
|
auditWrapper.auditLog(null, "failed_attestation_allowlist", null, {
|
|
aaguid: registrationInfo.aaguid,
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
|
}, 403);
|
|
}
|
|
}
|
|
|
|
// Optional Strict Hardware Attestation (e.g. YubiKey-only)
|
|
if (requireHardwareToken) {
|
|
if (
|
|
!registrationInfo.aaguid ||
|
|
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
|
) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
reason: "No AAGUID provided",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.",
|
|
}, 403);
|
|
}
|
|
|
|
let mdsStatement;
|
|
try {
|
|
mdsStatement = await MetadataService.getStatement(
|
|
registrationInfo.aaguid,
|
|
);
|
|
} catch (mdsError) {
|
|
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
|
}
|
|
|
|
if (!mdsStatement) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "AAGUID not found in MDS3",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`,
|
|
}, 403);
|
|
}
|
|
|
|
// @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1)
|
|
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "Software passkey detected",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.",
|
|
}, 403);
|
|
}
|
|
}
|
|
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) {
|
|
// Ephemeral Guest Sandbox in-flight promotion
|
|
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;
|
|
|
|
const insertRes = await sqlWrapper
|
|
.sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`;
|
|
user = insertRes[0];
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
|
`;
|
|
|
|
// Promote Valkey session
|
|
await valkey.setex(
|
|
upgrade_session,
|
|
28800, // Upgrade TTL to 8 hours
|
|
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
|
|
);
|
|
|
|
// Register session in PostgreSQL
|
|
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
|
await sqlWrapper.sql`
|
|
INSERT INTO sessions (id, user_id, expires_at)
|
|
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
|
`;
|
|
} else {
|
|
// Standard Registration Flow
|
|
const invite = await sqlWrapper
|
|
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
|
.then((res: any) => res[0]);
|
|
if (!invite) {
|
|
return c.json(
|
|
{ error: "Invalid, expired, or fully claimed invite code" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const initialStatus = invite.auto_activate === false
|
|
? "pending"
|
|
: "active";
|
|
const insertRes = await sqlWrapper
|
|
.sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`;
|
|
user = insertRes[0];
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
|
`;
|
|
|
|
await sqlWrapper.sql`
|
|
UPDATE invites
|
|
SET uses_count = uses_count + 1,
|
|
used_at = NOW(),
|
|
used_by = ${user.id}
|
|
WHERE id = ${invite.id}
|
|
`;
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO invite_redemptions (invite_id, user_id)
|
|
VALUES (${invite.id}, ${user.id})
|
|
`;
|
|
|
|
if (invite.app_id) {
|
|
await sqlWrapper.sql`
|
|
INSERT INTO grants (user_id, app_id, role)
|
|
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
|
`;
|
|
} else if (invite.role === "admin") {
|
|
const adminApp = await sqlWrapper
|
|
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
|
.then((res: any) => res[0]);
|
|
if (adminApp) {
|
|
await sqlWrapper.sql`
|
|
INSERT INTO grants (user_id, app_id, role)
|
|
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
|
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
auditWrapper.auditLog(
|
|
user.id,
|
|
"user_registered",
|
|
null,
|
|
{ username, inviteCode },
|
|
getClientIp(c),
|
|
);
|
|
|
|
// Set response cookie to clear out the challenge
|
|
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) {
|
|
deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" });
|
|
}
|
|
deleteCookie(c, "session_id", { path: "/" });
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Start a WebAuthn authentication ceremony
|
|
authRoutes.post("/api/login/challenge", async (c) => {
|
|
let body;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch (_err) {
|
|
body = {};
|
|
}
|
|
const username = body.username;
|
|
let extensions: any = undefined;
|
|
let allowCredentials: any[] | undefined = undefined;
|
|
|
|
if (username) {
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
|
res[0]
|
|
);
|
|
if (user) {
|
|
const passkeys = await sqlWrapper
|
|
.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`;
|
|
|
|
if (passkeys.length > 0) {
|
|
allowCredentials = passkeys.map((pk: any) => ({
|
|
id: pk.credential_id,
|
|
type: "public-key",
|
|
}));
|
|
|
|
const prfPasskeys = passkeys.filter((pk: any) =>
|
|
pk.prf_enabled && pk.prf_salt
|
|
);
|
|
if (prfPasskeys.length > 0) {
|
|
extensions = {
|
|
["prf" as string]: { evalByCredential: {} },
|
|
};
|
|
for (const pk of prfPasskeys) {
|
|
const saltBytes = decodeBase64Url(pk.prf_salt);
|
|
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
|
first: saltBytes,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!rpID) throw new Error("rpID is missing");
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID,
|
|
userVerification: "preferred",
|
|
timeout: 60000,
|
|
allowCredentials,
|
|
extensions,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options });
|
|
});
|
|
|
|
// Verify login and issue session
|
|
authRoutes.post("/api/login/verify", async (c) => {
|
|
const { response } = await c.req.json();
|
|
|
|
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
|
|
if (!expectedChallenge) {
|
|
return c.json(
|
|
{ error: "Missing or expired authentication challenge" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const base64CredentialID = response.id;
|
|
|
|
const passkey = await sqlWrapper
|
|
.sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}`
|
|
.then((res: any) => res[0]);
|
|
if (!passkey) {
|
|
return c.json({
|
|
error: "Passkey not found. Please register your passkey first.",
|
|
}, 404);
|
|
}
|
|
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) {
|
|
return c.json({ error: "User not found" }, 404);
|
|
}
|
|
|
|
const userId = user.id;
|
|
|
|
if (user.account_status !== "active") {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: `Account status is ${user.account_status}` },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error: "Account is not active. Please contact an administrator.",
|
|
}, 403);
|
|
}
|
|
|
|
const publicKeyBytes = decodeBase64Url(passkey.public_key);
|
|
|
|
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyAuthenticationResponse({
|
|
response: response as AuthenticationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false,
|
|
credential: {
|
|
id: passkey.credential_id,
|
|
publicKey: publicKeyBytes,
|
|
counter: Number(passkey.counter),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
return c.json({ error: error.message }, 400);
|
|
}
|
|
|
|
const { verified, authenticationInfo } = verification;
|
|
if (!verified || !authenticationInfo) {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "verification failed" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Verification failed" }, 400);
|
|
}
|
|
|
|
await sqlWrapper
|
|
.sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`;
|
|
|
|
const sessionId = generateSessionId();
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
// Persistence in PostgreSQL
|
|
await sqlWrapper
|
|
.sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`;
|
|
|
|
// Write session to Valkey with TTL matching expiresAt
|
|
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
|
try {
|
|
const sessionData = JSON.stringify({
|
|
uuid: user.id,
|
|
username: user.username,
|
|
});
|
|
await valkey.setex(sessionId, ttlSeconds, sessionData);
|
|
} catch (_err: unknown) {
|
|
// If Valkey fails, log and fail closed for security
|
|
auditWrapper.auditLog(
|
|
user.id,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "Cache write failure" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Internal server error" }, 500);
|
|
}
|
|
|
|
const oldSessionIds = extractAllSessionIds(c);
|
|
if (oldSessionIds.length > 0) {
|
|
for (const old of oldSessionIds) {
|
|
try {
|
|
await valkey.del(old);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
|
|
} catch (_e) {}
|
|
}
|
|
}
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
setCookie(c, "session_id", sessionId, {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
expires: expiresAt,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// Generate Ephemeral Guest Sandbox
|
|
authRoutes.post("/api/guests/sandbox", async (c) => {
|
|
const guestUuid = crypto.randomUUID();
|
|
const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
const username = `guest-${guestUuid.substring(0, 8)}`;
|
|
|
|
await valkey.setex(
|
|
sessionId,
|
|
7200, // 2-hour TTL
|
|
JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }),
|
|
);
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
setCookie(c, "session_id", sessionId, {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 7200,
|
|
});
|
|
|
|
return c.json({ success: true, sessionId, guestUuid });
|
|
});
|
|
|
|
// Revoke a session manually (used by layout logout)
|
|
authRoutes.post("/api/revoke", async (c) => {
|
|
// Try Authorization header first (SDK)
|
|
let token = "";
|
|
const authHeader = c.req.header("Authorization");
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
token = authHeader.split(" ")[1];
|
|
}
|
|
|
|
if (!token) {
|
|
// Fallback to session cookie (Web UI)
|
|
const tokens = extractAllSessionIds(c);
|
|
if (tokens.length === 0) {
|
|
return c.json({ error: "Missing or invalid token" }, 401);
|
|
}
|
|
|
|
for (const t of tokens) {
|
|
try {
|
|
await valkey.del(t);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
|
|
} catch (_e) {}
|
|
}
|
|
} else {
|
|
// SDK Token Path
|
|
try {
|
|
await valkey.del(token);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`;
|
|
} catch (_e) {}
|
|
}
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
if (cookieDomain) {
|
|
deleteCookie(c, "session_id", {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
}
|
|
deleteCookie(c, "session_id", {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Authenticated Passkey Registration (Adding a new device)
|
|
// ---------------------------------------------------------
|
|
|
|
authRoutes.post("/api/passkeys/register/challenge", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const user = await sqlWrapper
|
|
.sql`SELECT username FROM users WHERE id = ${auth.userId}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) return c.json({ error: "User not found" }, 404);
|
|
|
|
const userIdBytes = new TextEncoder().encode(auth.userId);
|
|
|
|
if (!rpID) throw new Error("rpID is missing");
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID,
|
|
userName: user.username,
|
|
userID: userIdBytes,
|
|
attestationType: "direct",
|
|
authenticatorSelection: {
|
|
residentKey: "required",
|
|
requireResidentKey: true,
|
|
userVerification: "preferred",
|
|
},
|
|
timeout: 60000,
|
|
});
|
|
|
|
setCookie(c, "expected_add_passkey_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options });
|
|
});
|
|
|
|
authRoutes.post("/api/passkeys/register/verify", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const { response } = await c.req.json();
|
|
const expectedChallenge = getCookie(c, "expected_add_passkey_challenge");
|
|
|
|
if (!expectedChallenge) {
|
|
return c.json({ error: "Missing or expired registration challenge" }, 400);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Enterprise Allow-List Verification
|
|
const allowlistCount = await sqlWrapper
|
|
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
|
Number(res[0].count)
|
|
);
|
|
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
|
const isAllowed = await sqlWrapper
|
|
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
|
.then((res: any) => res[0]);
|
|
if (!isAllowed) {
|
|
auditWrapper.auditLog(auth.userId, "failed_attestation_allowlist", null, {
|
|
aaguid: registrationInfo.aaguid,
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
|
}, 403);
|
|
}
|
|
}
|
|
|
|
// Optional Strict Hardware Attestation
|
|
if (requireHardwareToken) {
|
|
if (
|
|
!registrationInfo.aaguid ||
|
|
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
|
) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
reason: "No AAGUID provided",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json(
|
|
{ error: "Hardware attestation failed: No AAGUID provided." },
|
|
403,
|
|
);
|
|
}
|
|
|
|
let mdsStatement;
|
|
try {
|
|
mdsStatement = await MetadataService.getStatement(
|
|
registrationInfo.aaguid,
|
|
);
|
|
} catch (mdsError) {
|
|
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
|
}
|
|
|
|
if (!mdsStatement) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "AAGUID not found in MDS3",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error:
|
|
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob.`,
|
|
}, 403);
|
|
}
|
|
|
|
// @ts-ignore: FIDO MDS3 missing type
|
|
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "Software passkey detected",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: Authenticator is flagged as a software-based passkey.",
|
|
}, 403);
|
|
}
|
|
}
|
|
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),
|
|
);
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
|
VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
|
`;
|
|
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"passkey_added",
|
|
null,
|
|
null,
|
|
getClientIp(c),
|
|
);
|
|
|
|
setCookie(c, "expected_add_passkey_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// Get user's registered passkeys
|
|
authRoutes.get("/api/passkeys", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const passkeys = await sqlWrapper.sql`
|
|
SELECT id, counter
|
|
FROM passkeys
|
|
WHERE user_id = ${auth.userId}
|
|
`;
|
|
|
|
return c.json({ passkeys });
|
|
});
|
|
|
|
// Revoke a specific passkey
|
|
authRoutes.delete("/api/passkeys/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const targetPasskeyId = c.req.param("id");
|
|
|
|
// Verify the passkey belongs to the user
|
|
const passkey = await sqlWrapper.sql`
|
|
SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId}
|
|
`.then((res: any) => res[0]);
|
|
|
|
if (!passkey) {
|
|
return c.json({ error: "Passkey not found or access denied" }, 404);
|
|
}
|
|
|
|
// Prevent deleting the very last passkey to avoid locking out the user
|
|
const passkeyCount = await sqlWrapper.sql`
|
|
SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId}
|
|
`.then((res: any) => Number(res[0].count));
|
|
|
|
if (passkeyCount <= 1) {
|
|
return c.json({
|
|
error: "Cannot delete your last passkey. Register another one first.",
|
|
}, 400);
|
|
}
|
|
|
|
await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`;
|
|
|
|
auditWrapper.auditLog(auth.userId, "passkey_revoked", null, {
|
|
revoked_passkey_id: targetPasskeyId,
|
|
}, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|