mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-26 11:43:08 -05:00
feat(passkey): create session during passkey registration (#9873)
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
co-authored by
Gustavo Valverde
parent
f68044dcfb
commit
2f3a71458d
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user