feat(web): Server Actions for password login + WebAuthn passkey flows
This commit is contained in:
parent
380e86b885
commit
7dd8bfcefa
235
web/app/auth-actions.ts
Normal file
235
web/app/auth-actions.ts
Normal file
@ -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<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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user