"use server"; import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { timingSafeEqual } from "node:crypto"; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, type AuthenticationResponseJSON, type RegistrationResponseJSON, } from "@simplewebauthn/server"; import { getSession, setSession, clearSession, requireSession, } from "@/lib/auth"; import { getRpInfo } from "@/lib/auth-rp"; import { readPasskeys, appendPasskey, removePasskey, bumpCounter, } from "@/lib/auth-store"; export type ActionResult = { ok: true } | { ok: false; error: string }; function constantTimeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); const len = Math.max(ab.length, bb.length); const ap = Buffer.alloc(len); const bp = Buffer.alloc(len); ab.copy(ap); bb.copy(bp); return timingSafeEqual(ap, bp) && ab.length === bb.length; } export async function loginWithPassword( username: string, password: string, ): Promise { const expectedUsername = process.env.CM_AGENT_ID ?? ""; const expectedPassword = process.env.CM_AGENT_PASSWORD ?? ""; if (!expectedUsername || !expectedPassword) { return { ok: false, error: "Server credentials not configured" }; } const usernameOk = constantTimeEqual(username, expectedUsername); const passwordOk = constantTimeEqual(password, expectedPassword); if (!usernameOk || !passwordOk) { return { ok: false, error: "Invalid credentials" }; } await setSession({ username: expectedUsername, authenticatedAt: Date.now(), }); return { ok: true }; } export async function logout(): Promise { await clearSession(); redirect("/cm-auth"); } export async function beginRegistration() { const session = await requireSession(); const { rpID, rpName } = await getRpInfo(); const existing = await readPasskeys(session.username); const options = await generateRegistrationOptions({ rpName, rpID, userName: session.username, userID: new TextEncoder().encode(session.username), attestationType: "none", authenticatorSelection: { residentKey: "preferred", userVerification: "preferred", authenticatorAttachment: "platform", }, excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports, })), }); await setSession({ ...session, pendingChallenge: { kind: "register", challenge: options.challenge, expiresAt: Date.now() + 5 * 60 * 1000, }, }); return options; } export async function finishRegistration( response: RegistrationResponseJSON, deviceName: string, ): Promise { const session = await requireSession(); const pending = session.pendingChallenge; if (!pending || pending.kind !== "register" || pending.expiresAt < Date.now()) { return { ok: false, error: "Registration challenge expired or missing" }; } const { rpID, origin } = await getRpInfo(); let verification; try { verification = await verifyRegistrationResponse({ response, expectedChallenge: pending.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, }); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Verification failed", }; } if (!verification.verified || !verification.registrationInfo) { return { ok: false, error: "Registration not verified" }; } const info = verification.registrationInfo; const cred = info.credential; const trimmedName = (deviceName || "").trim() || "Unnamed device"; await appendPasskey(session.username, { id: cred.id, publicKey: Buffer.from(cred.publicKey).toString("base64url"), counter: cred.counter, transports: response.response.transports ?? [], name: trimmedName, createdAt: new Date().toISOString(), }); await setSession({ username: session.username, authenticatedAt: session.authenticatedAt, }); revalidatePath("/cm-passkeys"); return { ok: true }; } export async function beginAuthentication() { const expectedUsername = process.env.CM_AGENT_ID ?? ""; const passkeys = await readPasskeys(expectedUsername); const { rpID } = await getRpInfo(); const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", allowCredentials: passkeys.map((p) => ({ id: p.id, transports: p.transports, })), }); const existing = (await getSession()) ?? { username: "", authenticatedAt: 0, }; await setSession({ ...existing, pendingChallenge: { kind: "authenticate", challenge: options.challenge, expiresAt: Date.now() + 5 * 60 * 1000, }, }); return options; } export async function finishAuthentication( response: AuthenticationResponseJSON, ): Promise { const expectedUsername = process.env.CM_AGENT_ID ?? ""; if (!expectedUsername) { return { ok: false, error: "Server identity not configured" }; } const session = await getSession(); const pending = session?.pendingChallenge; if (!pending || pending.kind !== "authenticate" || pending.expiresAt < Date.now()) { return { ok: false, error: "Authentication challenge expired or missing" }; } const passkeys = await readPasskeys(expectedUsername); const stored = passkeys.find((p) => p.id === response.id); if (!stored) { return { ok: false, error: "Unknown credential" }; } const { rpID, origin } = await getRpInfo(); let verification; try { verification = await verifyAuthenticationResponse({ response, expectedChallenge: pending.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: stored.id, publicKey: Buffer.from(stored.publicKey, "base64url"), counter: stored.counter, transports: stored.transports, }, requireUserVerification: false, }); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Verification failed", }; } if (!verification.verified) { return { ok: false, error: "Authentication not verified" }; } await bumpCounter(expectedUsername, stored.id, verification.authenticationInfo.newCounter); await setSession({ username: expectedUsername, authenticatedAt: Date.now(), }); return { ok: true }; } export async function deletePasskey(credentialId: string): Promise { const session = await requireSession(); const removed = await removePasskey(session.username, credentialId); if (!removed) return { ok: false, error: "Passkey not found" }; revalidatePath("/cm-passkeys"); return { ok: true }; } export async function hasPasskeysForLogin(): Promise { const expectedUsername = process.env.CM_AGENT_ID ?? ""; if (!expectedUsername) return false; const list = await readPasskeys(expectedUsername); return list.length > 0; }