mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(01-08): bind passkey enrollment to fresh cookie proof
- require an existing-factor proof before issuing registration options - atomically claim session-bound challenges before credential persistence
This commit is contained in:
1 parent
9cb11abedb
commit
89c83510d4
3 files changed
+230
-42
No files matched your search
@@ -1,14 +1,114 @@
|
||||
import { createRegistrationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
createAuthenticationOptions,
|
||||
createRegistrationOptions,
|
||||
verifyAuthentication,
|
||||
} from "@/lib/auth/passkey";
|
||||
import {
|
||||
createMfaAuthenticationOptions,
|
||||
verifyMfaAuthentication,
|
||||
} from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const proofRequired = () =>
|
||||
apiError("Fresh existing-factor proof required", 401);
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
if (!request.headers.get("content-type")?.includes("application/json")) {
|
||||
return proofRequired();
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<
|
||||
Record<string, unknown>
|
||||
>(request, { maxBytes: 64 * 1024 });
|
||||
if (jsonError) return proofRequired();
|
||||
|
||||
// Begin an assertion ceremony without beginning registration. This lets the
|
||||
// browser re-prove a passkey or second-factor security key before this route
|
||||
// creates any enrollment challenge.
|
||||
if (
|
||||
Object.keys(body).length === 1 &&
|
||||
(body.method === "passkey" || body.method === "webauthn")
|
||||
) {
|
||||
const result =
|
||||
body.method === "passkey"
|
||||
? await createAuthenticationOptions(user.id)
|
||||
: await createMfaAuthenticationOptions(user.id);
|
||||
if (!result) {
|
||||
return apiError("That verification method is unavailable", 409);
|
||||
}
|
||||
return apiSuccess({ ...result, reauth: true });
|
||||
}
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
let proved = false;
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
proved = Boolean(
|
||||
user.passwordHash &&
|
||||
(await verifyPassword(user.passwordHash, parsed.data.password)),
|
||||
);
|
||||
} else if (parsed.data.method === "totp") {
|
||||
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
|
||||
proved = result.ok;
|
||||
} else {
|
||||
const expectedType =
|
||||
parsed.data.method === "passkey"
|
||||
? "authentication"
|
||||
: "mfa_authentication";
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: parsed.data.challengeId },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
|
||||
if (challenge?.userId === user.id && challenge.type === expectedType) {
|
||||
try {
|
||||
if (parsed.data.method === "passkey") {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
} else {
|
||||
proved = await verifyMfaAuthentication(
|
||||
parsed.data.challengeId,
|
||||
user.id,
|
||||
parsed.data.credential,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
proved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!proved) return proofRequired();
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
// session-bound challenge but cannot upgrade password-only authentication.
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const { options, challengeId } = await createRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
session.id,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register-options" } });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyRegistration } from "@/lib/auth/passkey";
|
||||
import {
|
||||
consumeRegistrationChallenge,
|
||||
verifyRegistration,
|
||||
} from "@/lib/auth/passkey";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -8,11 +11,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<
|
||||
Record<string, unknown>
|
||||
@@ -26,7 +29,43 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistration(challengeId, credential);
|
||||
// Read the live session row so a stale fresh-factor stamp cannot be carried
|
||||
// through a registration ceremony after it has been downgraded/revoked.
|
||||
const freshSession = await prisma.session.findUnique({
|
||||
where: { id: session.id },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
if (
|
||||
!freshSession ||
|
||||
(freshSession.mfaVerifiedAt &&
|
||||
freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000)
|
||||
) {
|
||||
return apiError("Fresh reauthentication proof required", 401);
|
||||
}
|
||||
|
||||
const claim = await consumeRegistrationChallenge({
|
||||
challengeId,
|
||||
userId: user.id,
|
||||
sessionId: session.id,
|
||||
});
|
||||
if (!claim.ok) {
|
||||
return apiError(
|
||||
claim.reason === "wrong_context"
|
||||
? "Fresh reauthentication proof required"
|
||||
: "Registration challenge invalid or expired",
|
||||
claim.reason === "wrong_context" ? 401 : 400,
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistration(
|
||||
claim.expectedChallenge,
|
||||
credential,
|
||||
);
|
||||
} catch {
|
||||
return apiError("Passkey verification failed", 400);
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Passkey verification failed", 400);
|
||||
|
||||
Reference in new issue
Block a user