diff --git a/.changeset/device-code-user-binding.md b/.changeset/device-code-user-binding.md
new file mode 100644
index 0000000000..717af29169
--- /dev/null
+++ b/.changeset/device-code-user-binding.md
@@ -0,0 +1,5 @@
+---
+"better-auth": patch
+---
+
+The device authorization plugin now accepts an optional `user_id` when issuing a device code via `/device/code`, pre-binding the code to that user. Only the bound user can approve or deny the code, so a publicly visible user code can no longer be claimed by someone else.
diff --git a/.changeset/openapi-schema-fixes.md b/.changeset/openapi-schema-fixes.md
new file mode 100644
index 0000000000..4c10c09ad2
--- /dev/null
+++ b/.changeset/openapi-schema-fixes.md
@@ -0,0 +1,6 @@
+---
+"better-auth": patch
+"@better-auth/passkey": patch
+---
+
+Fix invalid OpenAPI output for Better Auth callback, session, and passkey routes so client generators can consume the schema.
diff --git a/.changeset/young-cups-laugh.md b/.changeset/young-cups-laugh.md
new file mode 100644
index 0000000000..ae3667b767
--- /dev/null
+++ b/.changeset/young-cups-laugh.md
@@ -0,0 +1,7 @@
+---
+"better-auth": patch
+---
+
+`sendVerificationEmail` was invoked via `runInBackgroundOrAwait`, which could defer work when `advanced.backgroundTasks.handler` is configured (so the handler could return **200** before the email callback finished) and, in the default path, **caught and logged errors without rethrowing**. User callbacks that throw `APIError` (e.g. **429** from a rate limiter) were therefore not reliably reflected in the HTTP response ([better-auth/better-auth#8757](https://github.com/better-auth/better-auth/issues/8757)).
+
+Now we await `sendVerificationEmailFn` so failures surface to the client with the correct status. The unauthenticated `/send-verification-email` path enforces a constant-time floor (500 ms) so that the response duration does not reveal whether the email belongs to a real unverified user.
diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt
index 65e188514b..49dac81f39 100644
--- a/.cspell/custom-words.txt
+++ b/.cspell/custom-words.txt
@@ -23,3 +23,4 @@ CIBA
upvote
Suliman
Abdulrazzaq
+rethrowing
\ No newline at end of file
diff --git a/docs/content/docs/plugins/device-authorization.mdx b/docs/content/docs/plugins/device-authorization.mdx
index 7547630a21..4c6d4fccea 100644
--- a/docs/content/docs/plugins/device-authorization.mdx
+++ b/docs/content/docs/plugins/device-authorization.mdx
@@ -115,6 +115,12 @@ To initiate device authorization, call `device.code` with the client ID:
* Space-separated list of requested scopes (optional)
*/
scope?: string;
+ /**
+ * The user ID to which the device code should be pre-bound.
+ * When set, only that user can approve or deny the code.
+ * Pass this from trusted server-side code only. (optional)
+ */
+ user_id?: string;
}
```
@@ -136,6 +142,26 @@ if (data) {
}
```
+### Pre-binding to a User
+
+If your server already knows which user a device belongs to, pass `user_id` when requesting the device code. The code is then bound to that user from the start. It skips the claiming step, and only the bound user can approve or deny it. Any other signed-in user receives an `access_denied` error.
+
+This is useful when the user code is displayed where others can see it, because no one else can claim the code before the intended user verifies it.
+
+```ts title="server.ts"
+const data = await auth.api.deviceCode({
+ body: {
+ client_id: "your-client-id",
+ scope: "openid profile email",
+ user_id: user.id,
+ },
+});
+```
+
+
+ Pass `user_id` from trusted server-side code. The parameter only restricts who can approve the code, so an untrusted device cannot use it to access another user's account. The protection is only meaningful when your server controls how codes are issued.
+
+
### Polling for Token
After displaying the user code, poll for the access token:
@@ -556,6 +582,7 @@ authenticateCLI().catch((err) => {
4. **HTTPS Only**: Always use HTTPS in production for device authorization
5. **User Code Format**: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
6. **Authentication Required**: Users must be authenticated when calling `GET /device`. The verification step claims the pending device code for the calling session, and only that session can later approve or deny it
+7. **Pre-binding**: Device codes issued with `user_id` skip the claiming step and can only be approved or denied by that user. Pass `user_id` from trusted server-side code only
## Options
diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx
index 34dd7041e1..456add213a 100644
--- a/docs/content/docs/plugins/open-api.mdx
+++ b/docs/content/docs/plugins/open-api.mdx
@@ -38,7 +38,7 @@ This is a plugin that provides an Open API reference for Better Auth. It shows a
## Usage
-The Open API reference is generated using the [OpenAPI 3.0](https://swagger.io/specification/) specification. You can use the reference to generate client libraries, documentation, and more.
+The Open API reference is generated using the [OpenAPI 3.1.1](https://swagger.io/specification/) specification. You can use the reference to generate client libraries, documentation, and more. If you use the generated schema with client generators or other tooling, make sure they support OpenAPI 3.1 semantics.
The reference is generated using the [Scalar](https://scalar.com/) library. Scalar provides a way to view and test the endpoints. You can test the endpoints by clicking on the `Try it out` button and providing the required parameters.
diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx
index 543ae38079..92ac698b34 100644
--- a/docs/content/docs/reference/security.mdx
+++ b/docs/content/docs/reference/security.mdx
@@ -34,7 +34,9 @@ See the [session management](/docs/concepts/session-management) for more details
Better Auth includes multiple safeguards to prevent Cross-Site Request Forgery (CSRF) attacks:
1. **Avoid simple requests**
- See [Avoiding simple requests](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#avoiding_simple_requests) for more details. Better Auth only allows requests with a non-simple header or a `Content-Type` header of `application/json`.
+ See [Avoiding simple requests](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#avoiding_simple_requests) for more details. By default, Better Auth prefers non-simple requests for state-changing operations, typically by requiring a non-simple header or a `Content-Type` header of `application/json`.
+
+ Some routes intentionally accept `application/x-www-form-urlencoded` requests for progressive enhancement or protocol interoperability, including sign-in/sign-up email flows and some callback/token-style endpoints. Those routes rely on additional protections such as origin validation, Fetch Metadata checks where applicable, and protocol-specific state or nonce validation.
2. **Origin Validation**
Each request’s `Origin` header is verified to confirm it comes from your application or another explicitly trusted source. Requests from untrusted origins are rejected. By default, Better Auth trusts the base URL of your app, but you can specify additional trusted origins via the `trustedOrigins` configuration option.
@@ -122,7 +124,9 @@ Disables **URL validation** against `trustedOrigins`, including:
## OAuth State and PKCE
-To secure OAuth flows, Better Auth stores the OAuth state and PKCE (Proof Key for Code Exchange) in the database. The state helps prevent CSRF attacks, while PKCE protects against code injection threats. Once the OAuth process completes, these values are removed from the database.
+To secure OAuth flows, Better Auth stores the OAuth state and PKCE (Proof Key for Code Exchange) using the configured state storage strategy. By default, the state payload is stored in verification storage and the state value is also persisted in a signed cookie for validation. When `storeStateStrategy` is set to `cookie`, the full state payload is encrypted into a short-lived cookie instead of creating a verification record.
+
+The state helps prevent CSRF attacks, while PKCE protects against code injection threats. After the OAuth callback is processed, Better Auth expires the relevant cookie and, when verification storage is used, deletes the stored verification record.
## Cookies
diff --git a/packages/better-auth/src/api/routes/email-verification.test.ts b/packages/better-auth/src/api/routes/email-verification.test.ts
index 3a0d9d7bd6..f2131cd1a2 100644
--- a/packages/better-auth/src/api/routes/email-verification.test.ts
+++ b/packages/better-auth/src/api/routes/email-verification.test.ts
@@ -1,3 +1,4 @@
+import { APIError } from "@better-auth/core/error";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
@@ -151,6 +152,45 @@ describe("Email Verification", async () => {
);
});
+ /**
+ * @see https://github.com/better-auth/better-auth/issues/8757
+ */
+ it("should return APIError status when sendVerificationEmail throws (e.g. rate limit)", async () => {
+ let sendCalls = 0;
+ const { auth, testUser } = await getTestInstance({
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ },
+ emailVerification: {
+ async sendVerificationEmail() {
+ sendCalls += 1;
+ if (sendCalls >= 2) {
+ throw APIError.from("TOO_MANY_REQUESTS", {
+ code: "RATE_LIMIT_EXCEEDED",
+ message: "Too many requests. Please try again later.",
+ });
+ }
+ },
+ },
+ });
+
+ try {
+ await auth.api.sendVerificationEmail({
+ body: {
+ email: testUser.email,
+ },
+ });
+ expect.fail("Expected sendVerificationEmail to throw");
+ } catch (error) {
+ expect(error).toBeInstanceOf(APIError);
+ if (error instanceof APIError) {
+ expect(error.status).toBe("TOO_MANY_REQUESTS");
+ expect(error.body?.code).toBe("RATE_LIMIT_EXCEEDED");
+ }
+ }
+ });
+
it("should send a verification email if verification is required and user is not verified", async () => {
await signInWithUser(testUser.email, testUser.password);
@@ -468,6 +508,77 @@ describe("Email Verification", async () => {
});
});
+describe("Email Verification - Timing-safe unauthenticated path", () => {
+ it("should enforce a constant-time floor for both existing and non-existing emails", async () => {
+ const mockSendEmail = vi.fn();
+ const { auth, testUser } = await getTestInstance({
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ },
+ emailVerification: {
+ async sendVerificationEmail({ user }) {
+ mockSendEmail(user.email);
+ },
+ },
+ });
+
+ const startExisting = Date.now();
+ await auth.api.sendVerificationEmail({
+ body: { email: testUser.email },
+ });
+ const elapsedExisting = Date.now() - startExisting;
+
+ const startMissing = Date.now();
+ await auth.api.sendVerificationEmail({
+ body: { email: "nonexistent@example.com" },
+ });
+ const elapsedMissing = Date.now() - startMissing;
+
+ expect(elapsedExisting).toBeGreaterThanOrEqual(450);
+ expect(elapsedMissing).toBeGreaterThanOrEqual(450);
+
+ expect(mockSendEmail).toHaveBeenCalledWith(testUser.email);
+ expect(mockSendEmail).not.toHaveBeenCalledWith("nonexistent@example.com");
+ });
+
+ it("should still surface errors from sendVerificationEmail after the minimum delay", async () => {
+ let calls = 0;
+ const { auth, testUser } = await getTestInstance({
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ },
+ emailVerification: {
+ async sendVerificationEmail() {
+ calls += 1;
+ if (calls >= 2) {
+ throw APIError.from("TOO_MANY_REQUESTS", {
+ code: "RATE_LIMIT_EXCEEDED",
+ message: "Too many requests.",
+ });
+ }
+ },
+ },
+ });
+
+ const start = Date.now();
+ try {
+ await auth.api.sendVerificationEmail({
+ body: { email: testUser.email },
+ });
+ expect.fail("Expected to throw");
+ } catch (error) {
+ expect(error).toBeInstanceOf(APIError);
+ if (error instanceof APIError) {
+ expect(error.status).toBe("TOO_MANY_REQUESTS");
+ }
+ }
+ const elapsed = Date.now() - start;
+ expect(elapsed).toBeGreaterThanOrEqual(450);
+ });
+});
+
describe("Email Verification Secondary Storage", async () => {
const store = new Map();
let token: string;
diff --git a/packages/better-auth/src/api/routes/email-verification.ts b/packages/better-auth/src/api/routes/email-verification.ts
index c69a86b5bd..fc47ea5bb3 100644
--- a/packages/better-auth/src/api/routes/email-verification.ts
+++ b/packages/better-auth/src/api/routes/email-verification.ts
@@ -64,15 +64,15 @@ export async function sendVerificationEmailFn(
? encodeURIComponent(ctx.body.callbackURL)
: encodeURIComponent("/");
const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
- await ctx.context.runInBackgroundOrAwait(
- ctx.context.options.emailVerification.sendVerificationEmail(
- {
- user: user,
- url,
- token,
- },
- ctx.request?.clone(),
- ),
+ // Await directly: `runInBackgroundOrAwait` may defer work or swallow errors (see #8757).
+ // This path only runs once a real unverified user is known, so timing here does not weaken the unauthenticated anti-enumeration behavior above.
+ await ctx.context.options.emailVerification.sendVerificationEmail(
+ {
+ user: user,
+ url,
+ token,
+ },
+ ctx.request,
);
}
export const sendVerificationEmail = createAuthEndpoint(
@@ -171,7 +171,16 @@ export const sendVerificationEmail = createAuthEndpoint(
const { email } = ctx.body;
const session = await getSessionFromCtx(ctx);
if (!session) {
+ /**
+ * Enforce a constant-time floor so an attacker cannot distinguish
+ * "email not found / already verified" (fast local JWT sign) from
+ * "email found and unverified" (slow external email-send) by
+ * comparing response times.
+ */
+ const MINIMUM_MS = 500;
+ const start = Date.now();
const user = await ctx.context.internalAdapter.findUserByEmail(email);
+ let error: unknown;
if (!user || user.user.emailVerified) {
await createEmailVerificationToken(
ctx.context.secret,
@@ -179,12 +188,18 @@ export const sendVerificationEmail = createAuthEndpoint(
undefined,
ctx.context.options.emailVerification?.expiresIn,
);
- // We're returning true to avoid leaking information about the user
- return ctx.json({
- status: true,
- });
+ } else {
+ try {
+ await sendVerificationEmailFn(ctx, user.user);
+ } catch (e) {
+ error = e;
+ }
}
- await sendVerificationEmailFn(ctx, user.user);
+ const remaining = MINIMUM_MS - (Date.now() - start);
+ if (remaining > 0) {
+ await new Promise((resolve) => setTimeout(resolve, remaining));
+ }
+ if (error) throw error;
return ctx.json({
status: true,
});
diff --git a/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts b/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
index d6466ba2d0..e8b38f80a7 100644
--- a/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
+++ b/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
@@ -890,6 +890,152 @@ describe("device authorization ownership gate", () => {
});
});
+ it("rejects approve from a different user if the code was generated for a different user_id", async () => {
+ const { auth, client, signInWithTestUser, signInWithUser } =
+ await getTestInstance(
+ {
+ plugins: [
+ deviceAuthorization({
+ expiresIn: "5min",
+ interval: "2s",
+ }),
+ ],
+ },
+ {
+ clientOptions: {
+ plugins: [deviceAuthorizationClient()],
+ },
+ },
+ );
+
+ const { user } = await signInWithTestUser();
+
+ await client.signUp.email({
+ email: ATTACKER_EMAIL,
+ password: ATTACKER_PASSWORD,
+ name: "attacker",
+ });
+ const { headers: attackerHeaders } = await signInWithUser(
+ ATTACKER_EMAIL,
+ ATTACKER_PASSWORD,
+ );
+
+ const { user_code } = await auth.api.deviceCode({
+ body: {
+ client_id: "test-client",
+ user_id: user.id,
+ },
+ });
+
+ await expect(
+ auth.api.deviceApprove({
+ body: { userCode: user_code },
+ headers: attackerHeaders,
+ }),
+ ).rejects.toMatchObject({
+ status: "FORBIDDEN",
+ body: { error: "access_denied" },
+ });
+
+ await expect(
+ auth.api.deviceDeny({
+ body: { userCode: user_code },
+ headers: attackerHeaders,
+ }),
+ ).rejects.toMatchObject({
+ status: "FORBIDDEN",
+ body: { error: "access_denied" },
+ });
+ });
+
+ it("allows approve when the pre-bound user matches the current user", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ deviceAuthorization({
+ expiresIn: "5min",
+ interval: "2s",
+ }),
+ ],
+ });
+
+ const { headers: legitHeaders, user } = await signInWithTestUser();
+
+ const { user_code } = await auth.api.deviceCode({
+ body: {
+ client_id: "test-client",
+ user_id: user.id,
+ },
+ });
+
+ const approve = await auth.api.deviceApprove({
+ body: { userCode: user_code },
+ headers: legitHeaders,
+ });
+ expect(approve).toMatchObject({ success: true });
+ });
+
+ it("allows deny when the pre-bound user matches the current user", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ deviceAuthorization({
+ expiresIn: "5min",
+ interval: "2s",
+ }),
+ ],
+ });
+
+ const { headers: legitHeaders, user } = await signInWithTestUser();
+
+ const { user_code } = await auth.api.deviceCode({
+ body: {
+ client_id: "test-client",
+ user_id: user.id,
+ },
+ });
+
+ const deny = await auth.api.deviceDeny({
+ body: { userCode: user_code },
+ headers: legitHeaders,
+ });
+
+ expect(deny).toMatchObject({ success: true });
+ });
+
+ /**
+ * @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
+ */
+ it("treats an empty user_id as omitted and leaves the code unbound", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ deviceAuthorization({
+ expiresIn: "5min",
+ interval: "2s",
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ const { user_code } = await auth.api.deviceCode({
+ body: {
+ client_id: "test-client",
+ user_id: "",
+ },
+ });
+
+ // The unbound code is claimable by any signed-in user via verify
+ await auth.api.deviceVerify({
+ query: { user_code },
+ headers,
+ });
+
+ const approve = await auth.api.deviceApprove({
+ body: { userCode: user_code },
+ headers,
+ });
+ expect(approve).toMatchObject({ success: true });
+ });
+
it("does not overwrite a device code claimed after verify reads it", async () => {
let adapter: DBAdapter | null = null;
let concurrentOwnerId: string | undefined;
diff --git a/packages/better-auth/src/plugins/device-authorization/routes.ts b/packages/better-auth/src/plugins/device-authorization/routes.ts
index e7d3c27d97..4e8bb77a08 100644
--- a/packages/better-auth/src/plugins/device-authorization/routes.ts
+++ b/packages/better-auth/src/plugins/device-authorization/routes.ts
@@ -15,6 +15,12 @@ const deviceCodeBodySchema = z.object({
client_id: z.string().meta({
description: "The client ID of the application",
}),
+ user_id: z
+ .string()
+ .meta({
+ description: "The user ID to which the device code should be pre-bound.",
+ })
+ .optional(),
scope: z
.string()
.meta({
@@ -146,7 +152,7 @@ Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#secti
data: {
deviceCode,
userCode,
- userId: null,
+ userId: ctx.body.user_id || null, // An empty user_id is treated as omitted, per RFC 8628 section 3.1
expiresAt,
status: "pending",
pollingInterval: ms(opts.interval),
diff --git a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap
index 718526d9aa..f02afce531 100644
--- a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap
+++ b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap
@@ -17,7 +17,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"type": "string",
},
"createdAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -68,7 +67,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"Session": {
"properties": {
"createdAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -110,7 +108,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"User": {
"properties": {
"createdAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -140,7 +137,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"type": "string",
},
"updatedAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -159,7 +155,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"Verification": {
"properties": {
"createdAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -175,7 +170,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"type": "string",
},
"updatedAt": {
- "default": "Generated at runtime",
"format": "date-time",
"type": "string",
},
@@ -378,7 +372,16 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"get": {
"description": undefined,
"operationId": undefined,
- "parameters": [],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ ],
"responses": {
"400": {
"content": {
@@ -489,7 +492,16 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = `
"post": {
"description": undefined,
"operationId": undefined,
- "parameters": [],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ ],
"requestBody": {
"content": {
"application/json": {
diff --git a/packages/better-auth/src/plugins/open-api/generator.ts b/packages/better-auth/src/plugins/open-api/generator.ts
index 77a87f7101..ad58139b77 100644
--- a/packages/better-auth/src/plugins/open-api/generator.ts
+++ b/packages/better-auth/src/plugins/open-api/generator.ts
@@ -4,7 +4,6 @@ import type {
DBFieldAttributeConfig,
DBFieldType,
} from "@better-auth/core/db";
-import { toPascalCase } from "@better-auth/core/utils/string";
import type {
OpenAPIParameter as BetterCallOpenAPIParameter,
Endpoint,
@@ -112,9 +111,7 @@ export type FieldSchema = {
type: DBFieldType | "array";
/** Element schema for `type: "array"` (JSON Schema / OpenAPI array shape). */
items?: { type: string };
- default?:
- | (DBFieldAttributeConfig["defaultValue"] | "Generated at runtime")
- | undefined;
+ default?: DBFieldAttributeConfig["defaultValue"] | undefined;
readOnly?: boolean | undefined;
format?: string;
};
@@ -142,10 +139,9 @@ function getFieldSchema(field: DBFieldAttribute) {
};
if (field.defaultValue !== undefined) {
- schema.default =
- typeof field.defaultValue === "function"
- ? "Generated at runtime"
- : field.defaultValue;
+ if (typeof field.defaultValue !== "function") {
+ schema.default = field.defaultValue;
+ }
}
if (field.input === false) {
@@ -230,9 +226,11 @@ function getParameters(options: EndpointOptions) {
const parameters: OpenAPIParameter[] = [];
if (options.metadata?.openapi?.parameters) {
parameters.push(...options.metadata.openapi.parameters);
- return parameters;
}
- if (options.query instanceof z.ZodObject) {
+ if (
+ !options.metadata?.openapi?.parameters &&
+ options.query instanceof z.ZodObject
+ ) {
Object.entries(options.query.shape).forEach(([key, value]) => {
if (value instanceof z.ZodType) {
const parameterSchema = toOpenApiSchema(value as z.ZodType);
@@ -247,6 +245,28 @@ function getParameters(options: EndpointOptions) {
return parameters;
}
+function getPathParameters(path: string, parameters: OpenAPIParameter[]) {
+ const existingParameters = new Set(
+ parameters.map((parameter) => `${parameter.in}:${parameter.name}`),
+ );
+ return path
+ .split("/")
+ .filter((part) => part.startsWith(":"))
+ .map((part) => part.slice(1))
+ .filter((name) => !existingParameters.has(`path:${name}`))
+ .map(
+ (name) =>
+ ({
+ name,
+ in: "path",
+ required: true,
+ schema: {
+ type: "string",
+ },
+ }) satisfies OpenAPIParameter,
+ );
+}
+
function getRequestBodySchemaInfo(zodType: z.ZodType) {
return {
required: !schemaAcceptsUndefined(zodType),
@@ -676,6 +696,52 @@ function toOpenApiPath(path: string) {
.join("/");
}
+function getOperationId(
+ operationId: string | undefined,
+ method: string,
+ usedOperationIds: Set,
+) {
+ if (!operationId) {
+ return undefined;
+ }
+ if (!usedOperationIds.has(operationId)) {
+ usedOperationIds.add(operationId);
+ return operationId;
+ }
+ const normalizedMethod = method.toUpperCase();
+ const methodSuffix =
+ normalizedMethod.charAt(0) + normalizedMethod.slice(1).toLowerCase();
+ let candidate = `${operationId}${methodSuffix}`;
+ let duplicateIndex = 2;
+ while (usedOperationIds.has(candidate)) {
+ candidate = `${operationId}${methodSuffix}${duplicateIndex}`;
+ duplicateIndex += 1;
+ }
+ usedOperationIds.add(candidate);
+ return candidate;
+}
+
+function cloneOpenAPIValue(value: T): T {
+ if (Array.isArray(value)) {
+ return value.map((item) => cloneOpenAPIValue(item)) as T;
+ }
+
+ if (value instanceof Date) {
+ return new Date(value) as T;
+ }
+
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ key,
+ cloneOpenAPIValue(entry),
+ ]),
+ ) as T;
+ }
+
+ return value;
+}
+
export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
const baseEndpoints = getEndpoints(ctx, {
...options,
@@ -727,27 +793,18 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
};
const paths: Record = {};
- const seenOperationIds = new Set();
- const uniqueOperationId = (
- operationId: string | undefined,
- method: string,
- ) => {
- if (!operationId) return undefined;
- const base = seenOperationIds.has(operationId)
- ? `${operationId}${toPascalCase(method)}`
- : operationId;
- let result = base;
- let n = 2;
- while (seenOperationIds.has(result)) result = `${base}${n++}`;
- seenOperationIds.add(result);
- return result;
- };
+ const usedOperationIds = new Set();
Object.entries(baseEndpoints.api).forEach(([_, value]) => {
if (!value.path || ctx.options.disabledPaths?.includes(value.path)) return;
const options = value.options as EndpointOptions;
if (options.metadata?.SERVER_ONLY) return;
const path = toOpenApiPath(value.path);
+ const operationParameters = getParameters(options);
+ const parameters = [
+ ...operationParameters,
+ ...getPathParameters(value.path, operationParameters),
+ ];
const methods = Array.isArray(options.method)
? options.method
: [options.method];
@@ -757,17 +814,20 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
[method.toLowerCase()]: {
tags: ["Default", ...(options.metadata?.openapi?.tags || [])],
description: options.metadata?.openapi?.description,
- operationId: uniqueOperationId(
+ operationId: getOperationId(
options.metadata?.openapi?.operationId,
method,
+ usedOperationIds,
),
security: [
{
bearerAuth: [],
},
],
- parameters: getParameters(options),
- responses: getResponse(options.metadata?.openapi?.responses),
+ parameters: cloneOpenAPIValue(parameters),
+ responses: cloneOpenAPIValue(
+ getResponse(options.metadata?.openapi?.responses),
+ ),
},
};
}
@@ -780,18 +840,19 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
[method.toLowerCase()]: {
tags: ["Default", ...(options.metadata?.openapi?.tags || [])],
description: options.metadata?.openapi?.description,
- operationId: uniqueOperationId(
+ operationId: getOperationId(
options.metadata?.openapi?.operationId,
method,
+ usedOperationIds,
),
security: [
{
bearerAuth: [],
},
],
- parameters: getParameters(options),
+ parameters: cloneOpenAPIValue(parameters),
...(body
- ? { requestBody: body }
+ ? { requestBody: cloneOpenAPIValue(body) }
: {
requestBody: {
//set body none
@@ -805,7 +866,9 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
},
},
}),
- responses: getResponse(options.metadata?.openapi?.responses),
+ responses: cloneOpenAPIValue(
+ getResponse(options.metadata?.openapi?.responses),
+ ),
},
};
}
@@ -835,6 +898,11 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
const options = value.options as EndpointOptions;
if (options.metadata?.SERVER_ONLY) return;
const path = toOpenApiPath(value.path);
+ const operationParameters = getParameters(options);
+ const parameters = [
+ ...operationParameters,
+ ...getPathParameters(value.path, operationParameters),
+ ];
const methods = Array.isArray(options.method)
? options.method
: [options.method];
@@ -848,17 +916,20 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1),
],
description: options.metadata?.openapi?.description,
- operationId: uniqueOperationId(
+ operationId: getOperationId(
options.metadata?.openapi?.operationId,
method,
+ usedOperationIds,
),
security: [
{
bearerAuth: [],
},
],
- parameters: getParameters(options),
- responses: getResponse(options.metadata?.openapi?.responses),
+ parameters: cloneOpenAPIValue(parameters),
+ responses: cloneOpenAPIValue(
+ getResponse(options.metadata?.openapi?.responses),
+ ),
},
};
}
@@ -872,18 +943,21 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) {
plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1),
],
description: options.metadata?.openapi?.description,
- operationId: uniqueOperationId(
+ operationId: getOperationId(
options.metadata?.openapi?.operationId,
method,
+ usedOperationIds,
),
security: [
{
bearerAuth: [],
},
],
- parameters: getParameters(options),
- requestBody: getRequestBody(options),
- responses: getResponse(options.metadata?.openapi?.responses),
+ parameters: cloneOpenAPIValue(parameters),
+ requestBody: cloneOpenAPIValue(getRequestBody(options)),
+ responses: cloneOpenAPIValue(
+ getResponse(options.metadata?.openapi?.responses),
+ ),
},
};
}
diff --git a/packages/better-auth/src/plugins/open-api/open-api.test.ts b/packages/better-auth/src/plugins/open-api/open-api.test.ts
index 1d737219fe..8c012d32c7 100644
--- a/packages/better-auth/src/plugins/open-api/open-api.test.ts
+++ b/packages/better-auth/src/plugins/open-api/open-api.test.ts
@@ -378,6 +378,18 @@ describe("open-api", async () => {
expect(schemas["User"]!.required).not.toContain("preferences");
});
+ it("should omit runtime-generated defaults from model schemas", async () => {
+ const schema = await auth.api.generateOpenAPISchema();
+ const schemas = schema.components.schemas as Record<
+ string,
+ Record
+ >;
+
+ expect(schemas["User"]!.properties.createdAt.default).toBeUndefined();
+ expect(schemas["User"]!.properties.updatedAt.default).toBeUndefined();
+ expect(schemas["Session"]!.properties.createdAt.default).toBeUndefined();
+ });
+
it("should properly handle nested objects in request body schema", async () => {
const schema = await auth.api.generateOpenAPISchema();
const paths = schema.paths as Record;
@@ -475,6 +487,43 @@ describe("open-api", async () => {
expect(paths["/get-session"].post.operationId).toBe("getSessionPost");
});
+ it("should infer path parameters for routes with dynamic segments", async () => {
+ const schema = await auth.api.generateOpenAPISchema();
+ const paths = schema.paths as Record;
+
+ expect(paths["/callback/{id}"].get.parameters).toContainEqual({
+ name: "id",
+ in: "path",
+ required: true,
+ schema: {
+ type: "string",
+ },
+ });
+ expect(paths["/callback/{id}"].post.parameters).toContainEqual({
+ name: "id",
+ in: "path",
+ required: true,
+ schema: {
+ type: "string",
+ },
+ });
+ });
+
+ it("should not share operation parameter or response objects across methods", async () => {
+ const schema = await auth.api.generateOpenAPISchema();
+ const paths = schema.paths as Record;
+
+ expect(paths["/get-session"].get.parameters).not.toBe(
+ paths["/get-session"].post.parameters,
+ );
+ expect(paths["/get-session"].get.responses["200"]).not.toBe(
+ paths["/get-session"].post.responses["200"],
+ );
+ expect(paths["/callback/{id}"].get.parameters).not.toBe(
+ paths["/callback/{id}"].post.parameters,
+ );
+ });
+
/**
* @see https://github.com/better-auth/better-auth/issues/9669
*/
diff --git a/packages/passkey/src/open-api.test.ts b/packages/passkey/src/open-api.test.ts
new file mode 100644
index 0000000000..09c8bf7b7d
--- /dev/null
+++ b/packages/passkey/src/open-api.test.ts
@@ -0,0 +1,41 @@
+import { openAPI } from "better-auth/plugins";
+import { getTestInstance } from "better-auth/test";
+import { describe, expect, it } from "vitest";
+import { passkey } from ".";
+
+describe("passkey open-api", async () => {
+ const { auth } = await getTestInstance({
+ plugins: [passkey(), openAPI()],
+ });
+
+ it("should place passkey query parameters at the operation level", async () => {
+ const schema = await auth.api.generateOpenAPISchema();
+ const paths = schema.paths as Record;
+
+ const operation = paths["/passkey/generate-register-options"].get;
+ expect(operation.parameters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "authenticatorAttachment",
+ in: "query",
+ required: false,
+ schema: expect.objectContaining({
+ type: "string",
+ enum: ["platform", "cross-platform"],
+ }),
+ }),
+ expect.objectContaining({
+ name: "name",
+ in: "query",
+ required: false,
+ }),
+ expect.objectContaining({
+ name: "context",
+ in: "query",
+ required: false,
+ }),
+ ]),
+ );
+ expect(operation.responses["200"].parameters).toBeUndefined();
+ });
+});
diff --git a/packages/passkey/src/routes.ts b/packages/passkey/src/routes.ts
index 9e8e9d36b6..a2d0b3170f 100644
--- a/packages/passkey/src/routes.ts
+++ b/packages/passkey/src/routes.ts
@@ -21,6 +21,7 @@ import {
} from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import { generateRandomString } from "better-auth/crypto";
+import type { OpenAPIParameter } from "better-call";
import * as z from "zod";
import { PASSKEY_ERROR_CODES } from "./error-codes";
import type {
@@ -120,6 +121,41 @@ const generatePasskeyQuerySchema = z
})
.optional();
+const generatePasskeyRegistrationOptionsOpenAPIParameters: OpenAPIParameter[] =
+ [
+ {
+ name: "authenticatorAttachment",
+ in: "query",
+ required: false,
+ description: `Type of authenticator to use for registration.
+ "platform" for device-specific authenticators,
+ "cross-platform" for authenticators that can be used across devices.`,
+ schema: {
+ type: "string",
+ enum: ["platform", "cross-platform"],
+ },
+ },
+ {
+ name: "name",
+ in: "query",
+ required: false,
+ description: `Optional custom name for the passkey.
+ This can help identify the passkey when managing multiple credentials.`,
+ schema: {
+ type: "string",
+ },
+ },
+ {
+ name: "context",
+ in: "query",
+ required: false,
+ description: "Optional context for passkey-first registration flows.",
+ schema: {
+ type: "string",
+ },
+ },
+ ];
+
export const generatePasskeyRegistrationOptions = (
opts: RequiredPassKeyOptions,
{ maxAgeInSeconds }: { maxAgeInSeconds: number },
@@ -135,29 +171,10 @@ export const generatePasskeyRegistrationOptions = (
openapi: {
operationId: "generatePasskeyRegistrationOptions",
description: "Generate registration options for a new passkey",
+ parameters: generatePasskeyRegistrationOptionsOpenAPIParameters,
responses: {
200: {
description: "Success",
- parameters: {
- query: {
- authenticatorAttachment: {
- description: `Type of authenticator to use for registration.
- "platform" for device-specific authenticators,
- "cross-platform" for authenticators that can be used across devices.`,
- required: false,
- },
- name: {
- description: `Optional custom name for the passkey.
- This can help identify the passkey when managing multiple credentials.`,
- required: false,
- },
- context: {
- description:
- "Optional context for passkey-first registration flows.",
- required: false,
- },
- },
- },
content: {
"application/json": {
schema: {