cm_bot_v2/web/app/auth-actions.ts
yiekheng f4d5f97c42 fix(web-auth): import WebAuthn JSON types from @simplewebauthn/types
In @simplewebauthn/server v11 the JSON response and transport types are
no longer re-exported from the server package — they live in the sibling
@simplewebauthn/types package. Adds the dep and switches the imports.
2026-05-03 09:45:36 +08:00

238 lines
7.0 KiB
TypeScript

"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { timingSafeEqual } from "node:crypto";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";
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<ActionResult> {
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<void> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<boolean> {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
if (!expectedUsername) return false;
const list = await readPasskeys(expectedUsername);
return list.length > 0;
}