From 2f3a71458db9d6a37f2d666acbd24255330ba8a3 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Thu, 23 Jul 2026 20:59:19 +0530 Subject: [PATCH] feat(passkey): create session during passkey registration (#9873) Co-authored-by: Gustavo Valverde --- .changeset/passkey-registration-session.md | 5 + docs/content/docs/plugins/passkey.mdx | 8 + packages/passkey/src/client.test.ts | 101 +++++++++++ packages/passkey/src/client.ts | 36 +++- packages/passkey/src/error-codes.ts | 1 + packages/passkey/src/open-api.test.ts | 27 +++ packages/passkey/src/passkey.test.ts | 156 +++++++++++++++++ packages/passkey/src/routes.ts | 189 ++++++++++++++------- 8 files changed, 455 insertions(+), 68 deletions(-) create mode 100644 .changeset/passkey-registration-session.md diff --git a/.changeset/passkey-registration-session.md b/.changeset/passkey-registration-session.md new file mode 100644 index 0000000000..957343dfde --- /dev/null +++ b/.changeset/passkey-registration-session.md @@ -0,0 +1,5 @@ +--- +"@better-auth/passkey": minor +--- + +Add the optional `createSession` setting to passkey registration. When enabled, successful registration signs the user in, sets the session cookie, and returns the session and user with the registered passkey. diff --git a/docs/content/docs/plugins/passkey.mdx b/docs/content/docs/plugins/passkey.mdx index b6af1a32ad..3da3d62c59 100644 --- a/docs/content/docs/plugins/passkey.mdx +++ b/docs/content/docs/plugins/passkey.mdx @@ -120,9 +120,12 @@ When using passkey-first flows (`registration.requireSession: false`), pass the await authClient.passkey.addPasskey({ name: "Primary passkey", context: "signed-registration-token", + createSession: true, }) ``` +When `createSession` is `true`, successful verification sets the session cookie and returns the `session` and `user` with the registered passkey. If you want to avoid creating a user before the WebAuthn ceremony succeeds, create or load the user in `registration.afterVerification` and return its `userId`. + ## Usage ### Add/Register a passkey @@ -152,6 +155,11 @@ To add or register a passkey make sure a user is authenticated and then call the * Optional context for passkey-first registration flows. Forwarded to `registration.resolveUser`. */ context?: string + /** + * Create a session after successfully registering the passkey. The + * response includes the session and user when enabled. + */ + createSession?: boolean } ``` diff --git a/packages/passkey/src/client.test.ts b/packages/passkey/src/client.test.ts index e02a9aa01f..768a468644 100644 --- a/packages/passkey/src/client.test.ts +++ b/packages/passkey/src/client.test.ts @@ -201,4 +201,105 @@ describe("passkey client", () => { consoleError.mockRestore(); } }); + + it("forwards createSession and notifies the session signal", async () => { + const fetchMock = vi.fn(async (path: string) => { + if (path === "/passkey/generate-register-options") { + return { + data: { + challenge: "challenge", + rp: { name: "Test", id: "example.com" }, + user: { id: "user", name: "user" }, + pubKeyCredParams: [], + }, + }; + } + if (path === "/passkey/verify-registration") { + return { + data: { + id: "passkey-id", + userId: "user", + session: { + id: "session-id", + token: "session-token", + }, + user: { + id: "user", + }, + }, + }; + } + return { data: null }; + }); + const listPasskeys = { set: vi.fn() }; + const store = { notify: vi.fn() }; + const actions = getPasskeyActions(fetchMock as any, { + $listPasskeys: listPasskeys as any, + $store: store as any, + }); + + mocks.startRegistration.mockResolvedValue({ + clientExtensionResults: {}, + response: { + transports: ["internal"], + }, + }); + + await actions.passkey.addPasskey({ + createSession: true, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/passkey/verify-registration", + expect.objectContaining({ + body: expect.objectContaining({ + createSession: true, + }), + }), + ); + expect(listPasskeys.set).toHaveBeenCalled(); + expect(store.notify).toHaveBeenCalledWith("$sessionSignal"); + }); + + it("does not notify the session signal when registration does not return a session", async () => { + const fetchMock = vi.fn(async (path: string) => { + if (path === "/passkey/generate-register-options") { + return { + data: { + challenge: "challenge", + rp: { name: "Test", id: "example.com" }, + user: { id: "user", name: "user" }, + pubKeyCredParams: [], + }, + }; + } + if (path === "/passkey/verify-registration") { + return { + data: { + id: "passkey-id", + userId: "user", + }, + }; + } + return { data: null }; + }); + const listPasskeys = { set: vi.fn() }; + const store = { notify: vi.fn() }; + const actions = getPasskeyActions(fetchMock as any, { + $listPasskeys: listPasskeys as any, + $store: store as any, + }); + + mocks.startRegistration.mockResolvedValue({ + clientExtensionResults: {}, + response: { + transports: ["internal"], + }, + }); + + await actions.passkey.addPasskey(); + + expect(listPasskeys.set).toHaveBeenCalled(); + expect(store.notify).not.toHaveBeenCalledWith("$sessionSignal"); + }); }); diff --git a/packages/passkey/src/client.ts b/packages/passkey/src/client.ts index c05e2ede25..b9445eedb1 100644 --- a/packages/passkey/src/client.ts +++ b/packages/passkey/src/client.ts @@ -27,6 +27,11 @@ import { PASSKEY_ERROR_CODES } from "./error-codes"; import type { Passkey } from "./types"; import { PACKAGE_VERSION } from "./version"; +type AddPasskeyResponse = Passkey & { + session?: Session; + user?: User; +}; + export const getPasskeyActions = ( $fetch: BetterFetch, { @@ -148,6 +153,10 @@ export const getPasskeyActions = ( * Optional context for passkey-first registration flows. */ context?: string | null; + /** + * Create a session after successfully registering the passkey. + */ + createSession?: boolean; /** * Optional WebAuthn extensions to include during registration. */ @@ -205,21 +214,30 @@ export const getPasskeyActions = ( useAutoRegister: opts?.useAutoRegister, }); const { clientExtensionResults, ...responseBody } = res; - const verified = await $fetch("/passkey/verify-registration", { - ...opts?.fetchOptions, - ...fetchOpts, - body: { - response: responseBody, - name: opts?.name, + const verified = await $fetch( + "/passkey/verify-registration", + { + ...opts?.fetchOptions, + ...fetchOpts, + body: { + response: responseBody, + name: opts?.name, + ...(opts?.createSession && { + createSession: true, + }), + }, + method: "POST", + throw: false, }, - method: "POST", - throw: false, - }); + ); if (!verified.data) { return verified; } $listPasskeys.set(Math.random()); + if (verified.data.session) { + $store.notify("$sessionSignal"); + } if (opts?.returnWebAuthnResponse) { return { ...verified, diff --git a/packages/passkey/src/error-codes.ts b/packages/passkey/src/error-codes.ts index b6efa4a87f..f2a7b23ee6 100644 --- a/packages/passkey/src/error-codes.ts +++ b/packages/passkey/src/error-codes.ts @@ -8,6 +8,7 @@ export const PASSKEY_ERROR_CODES = defineErrorCodes({ PASSKEY_NOT_FOUND: "Passkey not found", AUTHENTICATION_FAILED: "Authentication failed", UNABLE_TO_CREATE_SESSION: "Unable to create session", + USER_NOT_FOUND: "User not found", FAILED_TO_UPDATE_PASSKEY: "Failed to update passkey", PREVIOUSLY_REGISTERED: "Previously registered", REGISTRATION_CANCELLED: "Registration cancelled", diff --git a/packages/passkey/src/open-api.test.ts b/packages/passkey/src/open-api.test.ts index 09c8bf7b7d..f2ff97dd92 100644 --- a/packages/passkey/src/open-api.test.ts +++ b/packages/passkey/src/open-api.test.ts @@ -38,4 +38,31 @@ describe("passkey open-api", async () => { ); expect(operation.responses["200"].parameters).toBeUndefined(); }); + + it("should describe the optional registration session response", async () => { + const schema = await auth.api.generateOpenAPISchema(); + const paths = schema.paths as Record; + + const responseSchema = + paths["/passkey/verify-registration"].post.responses["200"].content[ + "application/json" + ].schema; + expect(responseSchema).toEqual({ + type: "object", + allOf: [ + { $ref: "#/components/schemas/Passkey" }, + { + type: "object", + properties: { + session: { + $ref: "#/components/schemas/Session", + }, + user: { + $ref: "#/components/schemas/User", + }, + }, + }, + ], + }); + }); }); diff --git a/packages/passkey/src/passkey.test.ts b/packages/passkey/src/passkey.test.ts index cebd0366d5..135327bc73 100644 --- a/packages/passkey/src/passkey.test.ts +++ b/packages/passkey/src/passkey.test.ts @@ -1,5 +1,6 @@ import { APIError } from "@better-auth/core/error"; import type { Verification } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; import { createAuthClient } from "better-auth/client"; import { getTestInstance } from "better-auth/test"; import { @@ -125,6 +126,161 @@ describe("passkey", async () => { expect(options).toHaveProperty("pubKeyCredParams"); }); + /** + * @see https://github.com/better-auth/better-auth/issues/9866 + */ + it("should create a session after pre-auth passkey registration", async () => { + let userId = ""; + const { + auth: preAuth, + client: preAuthClient, + cookieSetter, + } = await getTestInstance({ + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + passkey: [], + }), + plugins: [ + passkey({ + registration: { + requireSession: false, + resolveUser: async () => ({ + id: "pending-passkey-registration", + name: "passkey-first@example.com", + }), + afterVerification: async ({ ctx }) => { + const user = await ctx.context.internalAdapter.createUser( + { + name: "Passkey First", + email: "passkey-first@example.com", + }, + { method: "test" }, + ); + userId = user.id; + return { userId }; + }, + }, + }), + ], + }); + const headers = new Headers({ origin: "http://localhost:3000" }); + const setCookie = cookieSetter(headers); + + await preAuthClient.$fetch("/passkey/generate-register-options", { + method: "GET", + onResponse: setCookie, + }); + serverMocks.verifyRegistrationResponse.mockResolvedValue( + mockRegistrationVerification, + ); + + const result = await preAuth.api.verifyPasskeyRegistration({ + headers, + body: { + response: mockRegistrationResponse, + createSession: true, + }, + returnHeaders: true, + }); + + expect(result.response).toMatchObject({ + credentialID: mockRegistrationVerification.registrationInfo.credential.id, + session: { userId }, + user: { id: userId }, + }); + expect(result.headers.get("set-cookie")).toContain( + "better-auth.session_token=", + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/9866 + */ + it("should roll back passkey persistence when session creation fails", async () => { + let userId = ""; + const { + auth: preAuth, + client: preAuthClient, + cookieSetter, + } = await getTestInstance({ + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + passkey: [], + }), + plugins: [ + passkey({ + registration: { + requireSession: false, + resolveUser: async () => ({ + id: "pending-failed-registration", + name: "failed-session@example.com", + }), + afterVerification: async ({ ctx }) => { + const user = await ctx.context.internalAdapter.createUser( + { + name: "Failed Session", + email: "failed-session@example.com", + }, + { method: "test" }, + ); + userId = user.id; + return { userId }; + }, + }, + }), + ], + }); + const headers = new Headers({ origin: "http://localhost:3000" }); + const setCookie = cookieSetter(headers); + + await preAuthClient.$fetch("/passkey/generate-register-options", { + method: "GET", + onResponse: setCookie, + }); + serverMocks.verifyRegistrationResponse.mockResolvedValue( + mockRegistrationVerification, + ); + const context = await preAuth.$context; + const createSession = vi + .spyOn(context.internalAdapter, "createSession") + .mockResolvedValueOnce(null as never); + + try { + await expect( + preAuth.api.verifyPasskeyRegistration({ + headers, + body: { + response: mockRegistrationResponse, + createSession: true, + }, + }), + ).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { code: "UNABLE_TO_CREATE_SESSION" }, + }); + } finally { + createSession.mockRestore(); + } + + const passkeys = await context.adapter.findMany({ + model: "passkey", + where: [ + { + field: "credentialID", + value: mockRegistrationVerification.registrationInfo.credential.id, + }, + ], + }); + expect(passkeys).toHaveLength(0); + expect(await context.internalAdapter.findUserById(userId)).toBeNull(); + }); + it("should require resolveUser when session is not available", async () => { const { auth: preAuth } = await getTestInstance({ plugins: [ diff --git a/packages/passkey/src/routes.ts b/packages/passkey/src/routes.ts index a2d0b3170f..6ccd31736a 100644 --- a/packages/passkey/src/routes.ts +++ b/packages/passkey/src/routes.ts @@ -1,5 +1,9 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; +import { + getCurrentAdapter, + runWithTransaction, +} from "@better-auth/core/context"; import { APIError } from "@better-auth/core/error"; import { base64 } from "@better-auth/utils/base64"; import type { @@ -535,8 +539,28 @@ const verifyPasskeyRegistrationBodySchema = z.object({ description: "Name of the passkey", }) .optional(), + createSession: z + .boolean() + .meta({ + description: "Create a session after registering the passkey", + }) + .optional(), }); +const passkeyRegistrationResponseSchema = { + type: "object", + allOf: [ + { $ref: "#/components/schemas/Passkey" }, + { + type: "object", + properties: { + session: { $ref: "#/components/schemas/Session" }, + user: { $ref: "#/components/schemas/User" }, + }, + }, + ], +} as const; + export const verifyPasskeyRegistration = (options: RequiredPassKeyOptions) => { const requireSession = options.registration?.requireSession ?? true; return createAuthEndpoint( @@ -554,9 +578,7 @@ export const verifyPasskeyRegistration = (options: RequiredPassKeyOptions) => { description: "Success", content: { "application/json": { - schema: { - $ref: "#/components/schemas/Passkey", - }, + schema: passkeyRegistrationResponseSchema, }, }, }, @@ -644,67 +666,116 @@ export const verifyPasskeyRegistration = (options: RequiredPassKeyOptions) => { } const { aaguid, credentialDeviceType, credentialBackedUp, credential } = registrationInfo; - const resolvedUser: PasskeyRegistrationUser = { - id: userData.id, - name: userData.name || userData.id, - displayName: userData.displayName, - }; - let targetUserId = resolvedUser.id; - let resolvedName = ctx.body.name || undefined; - if (options.registration?.afterVerification) { - const result = await options.registration.afterVerification({ - ctx, - verification, - user: resolvedUser, - clientData: resp, - context, + const persistRegistration = async () => { + const resolvedUser: PasskeyRegistrationUser = { + id: userData.id, + name: userData.name || userData.id, + displayName: userData.displayName, + }; + let targetUserId = resolvedUser.id; + let resolvedName = ctx.body.name || undefined; + if (options.registration?.afterVerification) { + const result = await options.registration.afterVerification({ + ctx, + verification, + user: resolvedUser, + clientData: resp, + context, + }); + if (result?.userId) { + if (typeof result.userId !== "string" || !result.userId) { + throw APIError.from( + "BAD_REQUEST", + PASSKEY_ERROR_CODES.RESOLVED_USER_INVALID, + ); + } + if (session?.user?.id && result.userId !== session.user.id) { + throw APIError.from( + "UNAUTHORIZED", + PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY, + ); + } + targetUserId = result.userId; + } + if (!resolvedName) { + resolvedName = result?.name?.trim() || undefined; + } + } + if (!targetUserId) { + throw APIError.from( + "BAD_REQUEST", + PASSKEY_ERROR_CODES.RESOLVED_USER_INVALID, + ); + } + + const user = ctx.body.createSession + ? await ctx.context.internalAdapter.findUserById(targetUserId) + : null; + if (ctx.body.createSession && !user) { + throw APIError.from( + "INTERNAL_SERVER_ERROR", + PASSKEY_ERROR_CODES.USER_NOT_FOUND, + ); + } + + const pubKey = base64.encode(credential.publicKey); + const adapter = await getCurrentAdapter(ctx.context.adapter); + const passkey = await adapter.create, Passkey>({ + model: "passkey", + data: { + name: resolvedName, + userId: targetUserId, + credentialID: credential.id, + publicKey: pubKey, + counter: credential.counter, + deviceType: credentialDeviceType, + transports: resp.response.transports?.join(",") ?? "", + backedUp: credentialBackedUp, + createdAt: new Date(), + aaguid, + }, }); - if (result?.userId) { - if (typeof result.userId !== "string" || !result.userId) { - throw APIError.from( - "BAD_REQUEST", - PASSKEY_ERROR_CODES.RESOLVED_USER_INVALID, - ); - } - if (session?.user?.id && result.userId !== session.user.id) { - throw APIError.from( - "UNAUTHORIZED", - PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY, - ); - } - targetUserId = result.userId; + if (!user) { + return { passkey }; } - if (!resolvedName) { - resolvedName = result?.name?.trim() || undefined; + + const createdSession = + await ctx.context.internalAdapter.createSession( + targetUserId, + undefined, + undefined, + undefined, + { deferSecondaryStorageWrites: true }, + ); + if (!createdSession) { + throw APIError.from( + "INTERNAL_SERVER_ERROR", + PASSKEY_ERROR_CODES.UNABLE_TO_CREATE_SESSION, + ); } - } - if (!targetUserId) { - throw APIError.from( - "BAD_REQUEST", - PASSKEY_ERROR_CODES.RESOLVED_USER_INVALID, + return { passkey, session: createdSession, user }; + }; + const registration = ctx.body.createSession + ? await runWithTransaction(ctx.context.adapter, persistRegistration) + : await persistRegistration(); + + if (registration.session && registration.user) { + await setSessionCookie(ctx, { + session: registration.session, + user: registration.user, + }); + return ctx.json( + { + ...registration.passkey, + session: registration.session, + user: registration.user, + }, + { + status: 200, + }, ); } - const pubKey = base64.encode(credential.publicKey); - const newPasskey: Omit = { - name: resolvedName, - userId: targetUserId, - credentialID: credential.id, - publicKey: pubKey, - counter: credential.counter, - deviceType: credentialDeviceType, - transports: resp.response.transports?.join(",") ?? "", - backedUp: credentialBackedUp, - createdAt: new Date(), - aaguid: aaguid, - }; - const newPasskeyRes = await ctx.context.adapter.create< - Omit, - Passkey - >({ - model: "passkey", - data: newPasskey, - }); - return ctx.json(newPasskeyRes, { + return ctx.json(registration.passkey, { status: 200, }); } catch (e) {