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