diff --git a/web/app/auth-actions.ts b/web/app/auth-actions.ts new file mode 100644 index 0000000..948185e --- /dev/null +++ b/web/app/auth-actions.ts @@ -0,0 +1,235 @@ +"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; +}