Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-hounds-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🐛 fix multi-device passkey auth
5 changes: 5 additions & 0 deletions .changeset/sharp-webs-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🗃️ drop passkey counter column
9 changes: 3 additions & 6 deletions server/api/auth/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates
describeRoute({
summary: "Authenticate",
description: `
Authenticates a user using a WebAuthn credential. This endpoint verifies the authentication response from the client, updates the credential counter, and sets a signed cookie for the authenticated session.
Authenticates a user using a WebAuthn credential. This endpoint verifies the authentication response from the client and sets a signed cookie for the authenticated session.

**SIWE Authentication**

Expand Down Expand Up @@ -309,7 +309,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
if (!sessionId) return c.json({ code: "bad session" }, 400);
const [credential, challenge] = await Promise.all([
database.query.credentials.findFirst({
columns: { publicKey: true, account: true, factory: true, transports: true, counter: true },
columns: { publicKey: true, account: true, factory: true, transports: true },
where: eq(credentials.id, assertion.id),
}),
redis.getdel(sessionId),
Expand Down Expand Up @@ -347,7 +347,6 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
}
setUser({ id: parse(Address, credential.account) });

let newCounter: number | undefined;
try {
switch (assertion.method) {
case "siwe": {
Expand All @@ -374,13 +373,12 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
id: assertion.id,
publicKey: credential.publicKey,
transports: (credential.transports as AuthenticatorTransportFuture[] | undefined) ?? undefined,
counter: credential.counter,
counter: 0,
Comment thread
cruzdanilo marked this conversation as resolved.
Comment thread
cruzdanilo marked this conversation as resolved.
},
});
if (!verified || authenticationInfo.credentialID !== assertion.id) {
return c.json({ code: "bad authentication", legacy: "bad authentication" }, 400);
}
newCounter = authenticationInfo.newCounter;
}
}
} catch (error) {
Expand All @@ -398,7 +396,6 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
? { sameSite: "lax", secure: false }
: { domain, sameSite: "none", secure: true, partitioned: true }),
}),
newCounter && database.update(credentials).set({ counter: newCounter }).where(eq(credentials.id, assertion.id)),
]);

return c.json(
Expand Down
11 changes: 1 addition & 10 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,15 +677,7 @@ async function encryptPIN(pin: string) {
return mutex
.runExclusive(async () => {
const credential = await database.query.credentials.findFirst({
columns: {
account: true,
counter: true,
factory: true,
pandaId: true,
publicKey: true,
source: true,
transports: true,
},
columns: { account: true, factory: true, pandaId: true, publicKey: true, source: true, transports: true },
where: eq(credentials.id, credentialId),
with: {
cards: {
Expand Down Expand Up @@ -776,7 +768,6 @@ async function encryptPIN(pin: string) {
credential: {
publicKey: { type: "Buffer", data: [...credential.publicKey] },
transports: credential.transports,
counter: credential.counter,
},
assertion: patch.assertion,
factory: credential.factory,
Expand Down
1 change: 0 additions & 1 deletion server/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const credentials = pgTable(
factory: text("factory").notNull(),
account: text("account").notNull(),
transports: text("transports").array(),
counter: integer("counter").notNull().default(0),
kycId: text("kyc_id"),
pandaId: text("panda_id"),
bridgeId: text("bridge_id"),
Expand Down
14 changes: 5 additions & 9 deletions server/test/api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ describe("authentication", () => {
publicKey: new Uint8Array(),
account: zeroAddress,
factory: parse(Address, inject("ExaAccountFactory")),
counter: 0,
transports: [],
},
]);
Expand Down Expand Up @@ -198,7 +197,7 @@ describe("authentication", () => {
it("consumes challenge after unverified authentication response to prevent replay", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValueOnce({
verified: false,
authenticationInfo: { credentialID: "dGVzdC1jcmVkLWlk", newCounter: 1 },
authenticationInfo: { credentialID: "dGVzdC1jcmVkLWlk" },
} as Awaited<ReturnType<typeof verifyAuthenticationResponse>>);

const firstResponse = await appClient.index.$post(
Expand Down Expand Up @@ -237,7 +236,7 @@ describe("authentication", () => {
it("consumes challenge after mismatched authentication credential id to prevent replay", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValueOnce({
verified: true,
authenticationInfo: { credentialID: "another-credential", newCounter: 1 },
authenticationInfo: { credentialID: "another-credential" },
} as Awaited<ReturnType<typeof verifyAuthenticationResponse>>);

const firstResponse = await appClient.index.$post(
Expand Down Expand Up @@ -429,7 +428,6 @@ describe("registration", () => {
credential: {
id: "another-credential",
publicKey: new Uint8Array(65),
counter: 0,
transports: ["internal"],
},
credentialDeviceType: "multiDevice",
Expand All @@ -452,7 +450,6 @@ describe("registration", () => {
credential: {
id: "dGVzdC1jcmVkLWlk2",
publicKey: new Uint8Array(65),
counter: 0,
transports: ["internal"],
},
credentialDeviceType: "singleDevice",
Expand Down Expand Up @@ -581,16 +578,16 @@ vi.mock("@simplewebauthn/server", async (importOriginal) => {
return {
...actual,
verifyAuthenticationResponse: vi
.fn<() => Promise<{ authenticationInfo: { credentialID: string; newCounter: number }; verified: boolean }>>()
.fn<() => Promise<{ authenticationInfo: { credentialID: string }; verified: boolean }>>()
.mockResolvedValue({
verified: true,
authenticationInfo: { credentialID: "dGVzdC1jcmVkLWlk", newCounter: 1 },
authenticationInfo: { credentialID: "dGVzdC1jcmVkLWlk" },
}),
verifyRegistrationResponse: vi
.fn<
(options: { response: { id: string } }) => Promise<{
registrationInfo: {
credential: { counter: number; id: string; publicKey: Uint8Array; transports: string[] };
credential: { id: string; publicKey: Uint8Array; transports: string[] };
credentialDeviceType: string;
};
verified: boolean;
Expand All @@ -603,7 +600,6 @@ vi.mock("@simplewebauthn/server", async (importOriginal) => {
credential: {
id: options.response.id,
publicKey: new Uint8Array(65),
counter: 0,
transports: ["internal"],
},
credentialDeviceType: "multiDevice",
Expand Down
11 changes: 3 additions & 8 deletions server/test/api/card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1477,7 +1477,6 @@ describe("authenticated", () => {
factory,
pandaId: "webauthn-verify-panda",
transports: ["internal"],
counter: 5,
});
await database.insert(cards).values({ id: "webauthn-verify-card", credentialId, lastFour: "3377" });
const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({});
Expand All @@ -1496,7 +1495,6 @@ describe("authenticated", () => {
credential: {
publicKey: { type: "Buffer", data: [1, 2, 3] },
transports: ["internal"],
counter: 5,
},
assertion,
factory,
Expand All @@ -1514,7 +1512,6 @@ describe("authenticated", () => {
account,
factory,
pandaId: "webauthn-null-panda",
counter: 0,
});
await database.insert(cards).values({ id: "webauthn-null-card", credentialId, lastFour: "2020" });
const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({});
Expand All @@ -1530,7 +1527,7 @@ describe("authenticated", () => {
await expect(response.json()).resolves.toStrictEqual({ verification: "OK" });
expect(verifySpy).toHaveBeenCalledWith("webauthn-null-panda", {
authType: "webauthn",
credential: { publicKey: { type: "Buffer", data: [9, 8, 7] }, transports: null, counter: 0 },
credential: { publicKey: { type: "Buffer", data: [9, 8, 7] }, transports: null },
assertion,
factory,
statement,
Expand Down Expand Up @@ -1570,7 +1567,6 @@ describe("authenticated", () => {
factory,
pandaId: "webauthn-panda-401-panda",
transports: ["internal"],
counter: 0,
});
await database.insert(cards).values({ id: "webauthn-panda-401-card", credentialId, lastFour: "4141" });
const verifySpy = vi
Expand All @@ -1587,7 +1583,7 @@ describe("authenticated", () => {
await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" });
expect(verifySpy).toHaveBeenCalledWith("webauthn-panda-401-panda", {
authType: "webauthn",
credential: { publicKey: { type: "Buffer", data: [1, 2, 3] }, transports: ["internal"], counter: 0 },
credential: { publicKey: { type: "Buffer", data: [1, 2, 3] }, transports: ["internal"] },
assertion,
factory,
statement: `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 4141 for my user (webauthn-panda-401-panda)`,
Expand All @@ -1605,7 +1601,6 @@ describe("authenticated", () => {
factory,
pandaId: "webauthn-panda-503-panda",
transports: ["internal"],
counter: 0,
});
await database.insert(cards).values({ id: "webauthn-panda-503-card", credentialId, lastFour: "5151" });
const verifySpy = vi
Expand All @@ -1621,7 +1616,7 @@ describe("authenticated", () => {
expect(response.status).toBe(500);
expect(verifySpy).toHaveBeenCalledWith("webauthn-panda-503-panda", {
authType: "webauthn",
credential: { publicKey: { type: "Buffer", data: [4, 5, 6] }, transports: ["internal"], counter: 0 },
credential: { publicKey: { type: "Buffer", data: [4, 5, 6] }, transports: ["internal"] },
assertion,
factory,
statement: `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 5151 for my user (webauthn-panda-503-panda)`,
Expand Down
1 change: 0 additions & 1 deletion server/utils/createCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export default async function createCredential<C extends string>(
publicKey,
factory: exaAccountFactoryAddress,
transports: options?.webauthn?.transports,
counter: options?.webauthn?.counter,
source: options?.source,
},
]);
Expand Down
1 change: 0 additions & 1 deletion server/utils/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ export function verify(
};
authType: "webauthn";
credential: {
counter: number;
publicKey: { data: number[]; type: "Buffer" };
transports: null | string[];
};
Expand Down