feat(passkey): create session during passkey registration (#9873)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Gautam Manchandani
2026-07-23 11:29:19 -04:00
committed by GitHub
co-authored by Gustavo Valverde
parent f68044dcfb
commit 2f3a71458d
8 changed files with 455 additions and 68 deletions
@@ -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.
+8
View File
@@ -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
}
```
</APIMethod>
+101
View File
@@ -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");
});
});
+27 -9
View File
@@ -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>("/passkey/verify-registration", {
...opts?.fetchOptions,
...fetchOpts,
body: {
response: responseBody,
name: opts?.name,
const verified = await $fetch<AddPasskeyResponse>(
"/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,
+1
View File
@@ -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",
+27
View File
@@ -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<string, any>;
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",
},
},
},
],
});
});
});
+156
View File
@@ -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<Passkey>({
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: [
+130 -59
View File
@@ -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<Omit<Passkey, "id">, 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<Passkey, "id"> = {
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, "id">,
Passkey
>({
model: "passkey",
data: newPasskey,
});
return ctx.json(newPasskeyRes, {
return ctx.json(registration.passkey, {
status: 200,
});
} catch (e) {