298 lines
8.2 KiB
TypeScript
298 lines
8.2 KiB
TypeScript
import {
|
|
generateRegistrationOptions,
|
|
verifyRegistrationResponse,
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
} from "@simplewebauthn/server";
|
|
import { runtimeConfig, resolveOrigin, resolveRpId } from "./config";
|
|
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
|
import type {
|
|
RegistrationResponseJSON,
|
|
AuthenticationResponseJSON,
|
|
} from "@simplewebauthn/server";
|
|
import {
|
|
createUser,
|
|
getUserByUsername,
|
|
getUserById,
|
|
storeCredential,
|
|
getCredentialById,
|
|
updateCredentialCounter,
|
|
createSession,
|
|
getSession,
|
|
deleteSession,
|
|
} from "./db";
|
|
|
|
const RP_NAME = "shukudai";
|
|
|
|
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
|
|
const challenges = new Map<
|
|
string,
|
|
{ challenge: string; userId?: number; expires: number }
|
|
>();
|
|
|
|
// Cleanup expired challenges periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, val] of challenges) {
|
|
if (val.expires < now) challenges.delete(key);
|
|
}
|
|
}, 60_000);
|
|
|
|
function getCookie(req: Request, name: string): string | undefined {
|
|
const cookies = req.headers.get("cookie");
|
|
if (!cookies) return undefined;
|
|
const match = cookies.split(";").find((c) => c.trim().startsWith(`${name}=`));
|
|
return match?.split("=").slice(1).join("=").trim();
|
|
}
|
|
|
|
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
|
|
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`;
|
|
}
|
|
|
|
function challengeCookie(key: string): string {
|
|
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`;
|
|
}
|
|
|
|
function clearChallengeCookie(): string {
|
|
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
}
|
|
|
|
// ---- Route handlers ----
|
|
|
|
export async function registerOptions(req: Request): Promise<Response> {
|
|
const { username } = (await req.json()) as { username: string };
|
|
if (!username || username.length < 1 || username.length > 32) {
|
|
return Response.json(
|
|
{ error: "Username must be 1-32 characters" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Check if username already taken
|
|
const existing = await getUserByUsername(username);
|
|
if (existing) {
|
|
return Response.json({ error: "Username already taken" }, { status: 409 });
|
|
}
|
|
|
|
// Create user
|
|
const user = await createUser(username);
|
|
|
|
const rpID = resolveRpId(req);
|
|
const options = await generateRegistrationOptions({
|
|
rpName: RP_NAME,
|
|
rpID,
|
|
userName: username,
|
|
userID: isoBase64URL.toBuffer(
|
|
isoBase64URL.fromUTF8String(user.id.toString()),
|
|
),
|
|
attestationType: "none",
|
|
authenticatorSelection: {
|
|
residentKey: "required",
|
|
userVerification: "preferred",
|
|
},
|
|
});
|
|
|
|
// Store challenge
|
|
const challengeKey = crypto.randomUUID();
|
|
challenges.set(challengeKey, {
|
|
challenge: options.challenge,
|
|
userId: user.id,
|
|
expires: Date.now() + 120_000,
|
|
});
|
|
|
|
return new Response(JSON.stringify(options), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": challengeCookie(challengeKey),
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function registerVerify(req: Request): Promise<Response> {
|
|
const body = (await req.json()) as RegistrationResponseJSON;
|
|
const challengeKey = getCookie(req, "challenge_key");
|
|
if (!challengeKey) {
|
|
return Response.json({ error: "No challenge found" }, { status: 400 });
|
|
}
|
|
|
|
const stored = challenges.get(challengeKey);
|
|
if (!stored || stored.expires < Date.now()) {
|
|
challenges.delete(challengeKey!);
|
|
return Response.json({ error: "Challenge expired" }, { status: 400 });
|
|
}
|
|
challenges.delete(challengeKey);
|
|
|
|
const rpID = resolveRpId(req);
|
|
const origin = resolveOrigin(req);
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyRegistrationResponse({
|
|
response: body,
|
|
expectedChallenge: stored.challenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
});
|
|
} catch (err) {
|
|
return Response.json(
|
|
{ error: `Verification failed: ${err}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (!verification.verified || !verification.registrationInfo) {
|
|
return Response.json({ error: "Verification failed" }, { status: 400 });
|
|
}
|
|
|
|
const { credential } = verification.registrationInfo;
|
|
await storeCredential(
|
|
credential.id,
|
|
stored.userId!,
|
|
isoBase64URL.fromBuffer(credential.publicKey),
|
|
credential.counter,
|
|
credential.transports,
|
|
);
|
|
|
|
// Create session
|
|
const token = await createSession(stored.userId!);
|
|
const user = await getUserById(stored.userId!);
|
|
|
|
return new Response(
|
|
JSON.stringify({ verified: true, username: user?.username }),
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function loginOptions(req: Request): Promise<Response> {
|
|
const rpID = resolveRpId(req);
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID,
|
|
userVerification: "preferred",
|
|
// Empty allowCredentials = discoverable credentials (passkey prompt)
|
|
});
|
|
|
|
const challengeKey = crypto.randomUUID();
|
|
challenges.set(challengeKey, {
|
|
challenge: options.challenge,
|
|
expires: Date.now() + 120_000,
|
|
});
|
|
|
|
return new Response(JSON.stringify(options), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": challengeCookie(challengeKey),
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function loginVerify(req: Request): Promise<Response> {
|
|
const body = (await req.json()) as AuthenticationResponseJSON;
|
|
const challengeKey = getCookie(req, "challenge_key");
|
|
if (!challengeKey) {
|
|
return Response.json({ error: "No challenge found" }, { status: 400 });
|
|
}
|
|
|
|
const stored = challenges.get(challengeKey);
|
|
if (!stored || stored.expires < Date.now()) {
|
|
challenges.delete(challengeKey!);
|
|
return Response.json({ error: "Challenge expired" }, { status: 400 });
|
|
}
|
|
challenges.delete(challengeKey);
|
|
|
|
// Look up credential
|
|
const credentialId = body.id;
|
|
const credential = await getCredentialById(credentialId);
|
|
if (!credential) {
|
|
return Response.json({ error: "Unknown credential" }, { status: 400 });
|
|
}
|
|
|
|
const rpID = resolveRpId(req);
|
|
const origin = resolveOrigin(req);
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyAuthenticationResponse({
|
|
response: body,
|
|
expectedChallenge: stored.challenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
credential: {
|
|
id: credential.id,
|
|
publicKey: isoBase64URL.toBuffer(credential.public_key),
|
|
counter: credential.counter,
|
|
transports: credential.transports
|
|
? JSON.parse(credential.transports)
|
|
: undefined,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return Response.json(
|
|
{ error: `Verification failed: ${err}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (!verification.verified) {
|
|
return Response.json({ error: "Verification failed" }, { status: 400 });
|
|
}
|
|
|
|
// Update counter
|
|
await updateCredentialCounter(
|
|
credentialId,
|
|
verification.authenticationInfo.newCounter,
|
|
);
|
|
|
|
// Create session
|
|
const token = await createSession(credential.user_id);
|
|
const user = await getUserById(credential.user_id);
|
|
|
|
return new Response(
|
|
JSON.stringify({ verified: true, username: user?.username }),
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function me(req: Request): Promise<Response> {
|
|
const token = getCookie(req, "session");
|
|
if (!token) {
|
|
return Response.json({ user: null });
|
|
}
|
|
|
|
const session = await getSession(token);
|
|
if (!session) {
|
|
return new Response(JSON.stringify({ user: null }), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": sessionCookie("", 0), // clear expired cookie
|
|
},
|
|
});
|
|
}
|
|
|
|
return Response.json({
|
|
user: { id: session.user_id, username: session.username },
|
|
});
|
|
}
|
|
|
|
export async function logout(req: Request): Promise<Response> {
|
|
const token = getCookie(req, "session");
|
|
if (token) {
|
|
await deleteSession(token);
|
|
}
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": sessionCookie("", 0),
|
|
},
|
|
});
|
|
}
|