chore(deps): move to SimpleWebAuthn 14 and React 19.3

Bump @simplewebauthn/server 13.3.3 -> 14.0.2 and @simplewebauthn/browser
13.3.0 -> 14.0.0, and keep React on an exact pin at 19.3.0 with react-dom
and both type packages moved alongside.

SimpleWebAuthn 14 changes one default the server relied on: the verifier
used to accept every algorithm the library knew (EdDSA, ES256, ES512,
PS256/384/512, RS256/384/512, RS1) and now accepts only the three it
offers. Pass both lists explicitly so a registration that succeeded
before still succeeds, and so the offered list stays EdDSA/ES256/RS256
on a runtime that would otherwise prepend ML-DSA-44. The option JSON of
all four ceremonies, and verification against stored credentials, match
the 13.x snapshots unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 19:53:47 +02:00
1 parent 831fc9e0a7
commit 76ca6bbb30
6 files changed
+950 -824

No files matched your search

+6 -6
View File
@@ -65,8 +65,8 @@
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.2",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-virtual": "^3.14.12",
"@types/sax": "^1.2.7",
@@ -90,9 +90,9 @@
"prisma": "^7.10.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.7",
"react": "19.2.7",
"react": "19.3.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"react-dom": "19.3.0",
"react-hook-form": "7.83.0",
"recharts": "^3.10.1",
"rrule": "^2.8.1",
@@ -116,8 +116,8 @@
"@types/node": "26.5.1",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.5",
+837 -818
View File
File diff suppressed because it is too large. Load diff
@@ -42,6 +42,10 @@ import {
} from "vitest";
import { NextRequest } from "next/server";
import { isoCBOR } from "@simplewebauthn/server/helpers";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
// ── In-memory stand-in for the four tables the ceremonies touch ──────
@@ -110,6 +114,16 @@ vi.mock("@/lib/db", () => {
};
});
// Pass-through spies: the real library runs, the test can read the arguments.
vi.mock("@simplewebauthn/server", async (importOriginal) => {
const real = await importOriginal<typeof import("@simplewebauthn/server")>();
return {
...real,
generateRegistrationOptions: vi.fn(real.generateRegistrationOptions),
verifyRegistrationResponse: vi.fn(real.verifyRegistrationResponse),
};
});
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
@@ -130,6 +144,7 @@ import {
createMfaAuthenticationOptions,
createMfaRegistrationOptions,
verifyMfaAuthentication,
verifyMfaRegistration,
} from "../mfa/webauthn";
import { POST as LOGIN_OPTIONS } from "@/app/api/auth/passkey/login-options/route";
@@ -655,6 +670,70 @@ describe("algorithm set", () => {
);
});
describe("security-key algorithm set", () => {
it.each(LEGACY_ALGS.map((a) => [a.name, a] as const))(
"%s registers as a second factor",
async (_name, alg) => {
const keys = alg.keyPair();
const credentialId = randomBytes(16);
const { options, challengeId } = await createMfaRegistrationOptions(
USER_ID,
"user@example.test",
);
const verification = await verifyMfaRegistration(
challengeId,
USER_ID,
registrationResponse(
options.challenge,
credentialId,
alg.cose(keys.publicKey),
["usb"],
),
);
expect(verification.verified).toBe(true);
},
);
});
describe("algorithm lists are passed explicitly", () => {
// A library default can move under a runtime change (a post-quantum capable
// runtime prepends ML-DSA-44) without any option snapshot on this runtime
// noticing, so the arguments themselves are pinned.
it("both ceremonies offer EdDSA, ES256, RS256 and accept the full legacy set", async () => {
vi.mocked(generateRegistrationOptions).mockClear();
vi.mocked(verifyRegistrationResponse).mockClear();
const keys = ES256.keyPair();
await registerPasskey(ES256, keys, randomBytes(16));
const mfa = await createMfaRegistrationOptions(
USER_ID,
"user@example.test",
);
await verifyMfaRegistration(
mfa.challengeId,
USER_ID,
registrationResponse(
mfa.options.challenge,
randomBytes(16),
ES256.cose(keys.publicKey),
["usb"],
),
);
const offered = vi
.mocked(generateRegistrationOptions)
.mock.calls.map(([opts]) => opts.supportedAlgorithmIDs);
const accepted = vi
.mocked(verifyRegistrationResponse)
.mock.calls.map(([opts]) => opts.supportedAlgorithmIDs);
expect(offered).toEqual([
[-8, -7, -257],
[-8, -7, -257],
]);
const legacy = LEGACY_ALGS.map((a) => a.alg);
expect(accepted).toEqual([legacy, legacy]);
});
});
// ── 3. Stored credentials ────────────────────────────────────────────
/** A P-256 key derived from a fixed seed, so its stored bytes are stable. */
+4
View File
@@ -35,6 +35,8 @@ import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
REGISTRATION_ACCEPTED_ALGORITHM_IDS,
REGISTRATION_OFFERED_ALGORITHM_IDS,
} from "@/lib/auth/webauthn-rp";
// Boundary narrowing mirrors `passkey.ts`: a malformed body fails fast with a
@@ -107,6 +109,7 @@ export async function createMfaRegistrationOptions(
rpID: getRpId(),
userName: username,
attestationType: "none",
supportedAlgorithmIDs: REGISTRATION_OFFERED_ALGORITHM_IDS,
excludeCredentials: existing.map((c) => ({
id: c.credentialId,
transports: c.transports as Transport[],
@@ -168,6 +171,7 @@ export async function verifyMfaRegistration(
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
});
} finally {
await prisma.authChallenge
+4
View File
@@ -14,6 +14,8 @@ import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
REGISTRATION_ACCEPTED_ALGORITHM_IDS,
REGISTRATION_OFFERED_ALGORITHM_IDS,
} from "@/lib/auth/webauthn-rp";
import { hashToken } from "@/lib/auth/hmac";
@@ -111,6 +113,7 @@ export async function createRegistrationOptions(
rpID: getRpId(),
userName: username,
attestationType: "none",
supportedAlgorithmIDs: REGISTRATION_OFFERED_ALGORITHM_IDS,
excludeCredentials: existingPasskeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as Transport[],
@@ -217,6 +220,7 @@ export async function verifyRegistration(
expectedChallenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
});
}
+20
View File
@@ -58,3 +58,23 @@ export function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
/**
* COSE algorithms offered to the authenticator at registration, in order of
* preference: EdDSA, ES256, RS256. Passed explicitly so the options a browser
* receives do not follow a library default; a runtime with post-quantum
* support would otherwise put ML-DSA-44 at the front of the list.
*/
export const REGISTRATION_OFFERED_ALGORITHM_IDS = [-8, -7, -257];
/**
* COSE algorithms a registration response may carry. Wider than the offered
* list on purpose: it is the set the server has always accepted (EdDSA,
* ES256, ES512, PS256/384/512, RS256/384/512 and legacy RS1), so an
* authenticator that picks an algorithm it was not offered keeps registering
* as it did before. Narrowing it would turn away authenticators that work
* today.
*/
export const REGISTRATION_ACCEPTED_ALGORITHM_IDS = [
-8, -7, -36, -37, -38, -39, -257, -258, -259, -65535,
];