better-auth 1.7.9 → 1.8.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { safeCloneRequest } from "../../utils/request.mjs";
6
6
  import { createEmailVerificationToken } from "./email-verification.mjs";
7
7
  import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
8
- import { runWithTransaction } from "@better-auth/core/context";
8
+ import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
9
9
  import { isDevelopment } from "@better-auth/core/env";
10
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
11
11
  import { generateId } from "@better-auth/core/utils/id";
@@ -198,7 +198,13 @@ const signUpEmail = () => createAuthEndpoint("/sign-up/email", {
198
198
  * between existing and non-existing emails.
199
199
  */
200
200
  await ctx.context.password.hash(password);
201
- if (ctx.context.options.emailAndPassword?.onExistingUserSignUp) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailAndPassword.onExistingUserSignUp({ user: dbUser.user }, safeCloneRequest(ctx.request)));
201
+ const onExistingUserSignUp = ctx.context.options.emailAndPassword?.onExistingUserSignUp;
202
+ if (onExistingUserSignUp) {
203
+ const request = safeCloneRequest(ctx.request);
204
+ await queueAfterTransactionHook(async () => {
205
+ await ctx.context.runInBackgroundOrAwait(onExistingUserSignUp({ user: dbUser.user }, request));
206
+ }, { onError: (error) => ctx.context.logger.error("Failed to run onExistingUserSignUp after sign-up", error) });
207
+ }
202
208
  return buildGenericDuplicateResponse();
203
209
  }
204
210
  throw APIError.from("UNPROCESSABLE_ENTITY", BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL);
@@ -238,15 +244,21 @@ const signUpEmail = () => createAuthEndpoint("/sign-up/email", {
238
244
  accountId: createdUser.id,
239
245
  password: hash
240
246
  });
241
- if (ctx.context.options.emailVerification?.sendOnSignUp ?? ctx.context.options.emailAndPassword.requireEmailVerification) {
242
- const token = await createEmailVerificationToken(ctx.context.secret, createdUser.email, void 0, ctx.context.options.emailVerification?.expiresIn);
247
+ const shouldSendVerificationEmail = ctx.context.options.emailVerification?.sendOnSignUp ?? ctx.context.options.emailAndPassword.requireEmailVerification;
248
+ const sendVerificationEmail = ctx.context.options.emailVerification?.sendVerificationEmail;
249
+ if (shouldSendVerificationEmail && sendVerificationEmail) {
250
+ const user = createdUser;
243
251
  const callbackURL = body.callbackURL ? encodeURIComponent(body.callbackURL) : encodeURIComponent("/");
244
- const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
245
- if (ctx.context.options.emailVerification?.sendVerificationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
246
- user: createdUser,
247
- url,
248
- token
249
- }, safeCloneRequest(ctx.request)));
252
+ const request = safeCloneRequest(ctx.request);
253
+ await queueAfterTransactionHook(async () => {
254
+ const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
255
+ const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
256
+ await ctx.context.runInBackgroundOrAwait(sendVerificationEmail({
257
+ user,
258
+ url,
259
+ token
260
+ }, request));
261
+ }, { onError: (error) => ctx.context.logger.error("Failed to send the sign-up verification email", error) });
250
262
  }
251
263
  if (shouldSkipAutoSignIn) return ctx.json({
252
264
  token: null,
@@ -310,16 +310,16 @@ async function handleOAuthUserInfo(c, opts) {
310
310
  isRegister: false
311
311
  };
312
312
  const requireEmailVerification = c.context.socialProviders.find((p) => p.id === account.providerId)?.options?.requireEmailVerification;
313
- if (isRegister && !user.emailVerified && (c.context.options.emailVerification?.sendOnSignUp ?? requireEmailVerification)) await dispatchVerificationEmail(c, user, callbackURL, opts.deferNonDatabaseWrites);
313
+ if (isRegister && !user.emailVerified && (c.context.options.emailVerification?.sendOnSignUp ?? requireEmailVerification)) await dispatchVerificationEmail(c, user, callbackURL);
314
314
  if (requireEmailVerification && !user.emailVerified) {
315
- if (!isRegister && c.context.options.emailVerification?.sendOnSignIn) await dispatchVerificationEmail(c, user, callbackURL, opts.deferNonDatabaseWrites);
315
+ if (!isRegister && c.context.options.emailVerification?.sendOnSignIn) await dispatchVerificationEmail(c, user, callbackURL);
316
316
  return {
317
317
  error: OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_VERIFIED,
318
318
  data: null,
319
319
  isRegister
320
320
  };
321
321
  }
322
- const session = await c.context.internalAdapter.createSession(user.id, void 0, void 0, void 0, { deferSecondaryStorageWrites: opts.deferNonDatabaseWrites === true });
322
+ const session = await c.context.internalAdapter.createSession(user.id, void 0, void 0, void 0, { deferSecondaryStorageWrites: true });
323
323
  if (!session) return {
324
324
  error: "unable to create session",
325
325
  data: null,
@@ -339,7 +339,7 @@ async function handleOAuthUserInfo(c, opts) {
339
339
  accountCookie: pendingAccountCookie
340
340
  };
341
341
  }
342
- async function dispatchVerificationEmail(c, user, callbackURL, deferUntilAfterTransaction) {
342
+ async function dispatchVerificationEmail(c, user, callbackURL) {
343
343
  const sendVerificationEmail = c.context.options.emailVerification?.sendVerificationEmail;
344
344
  if (!sendVerificationEmail) return;
345
345
  const send = async () => {
@@ -355,8 +355,7 @@ async function dispatchVerificationEmail(c, user, callbackURL, deferUntilAfterTr
355
355
  c.context.logger.error("Failed to send OAuth verification email", e);
356
356
  }
357
357
  };
358
- if (deferUntilAfterTransaction) await queueAfterTransactionHook(send);
359
- else await send();
358
+ await queueAfterTransactionHook(send);
360
359
  }
361
360
  /**
362
361
  * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.9";
2
+ var version = "1.8.0-beta.0";
3
3
  //#endregion
4
4
  export { version };
@@ -1,6 +1,6 @@
1
1
  import { TimeString, ms, sec } from "../../utils/time.mjs";
2
2
  import { DeviceCode } from "./schema.mjs";
3
- import { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, redeemDeviceCode } from "./routes.mjs";
3
+ import { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, assertDeviceCodePending, buildVerificationUris, decideDeviceCode, findDeviceCodeByUserCode, redeemDeviceCode } from "./routes.mjs";
4
4
  import { GenericEndpointContext, StandardSchemaV1 } from "@better-auth/core";
5
5
  import { DBFieldAttribute } from "@better-auth/core/db";
6
6
  import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
@@ -74,7 +74,7 @@ interface DeviceAuthorizationGrantAuthorization {
74
74
  * A token grant that contributes its request state to device authorization
75
75
  * without expanding the standalone plugin's database or endpoint contracts.
76
76
  */
77
- interface DeviceAuthorizationGrant<RequestFields extends z.ZodRawShape = z.ZodRawShape, VerificationContext extends Record<string, unknown> = Record<string, unknown>> {
77
+ interface DeviceAuthorizationGrant<RequestFields extends z.ZodRawShape = z.ZodRawShape> {
78
78
  /** Additional request fields accepted only when this grant is configured. */
79
79
  requestSchemaFields: RequestFields;
80
80
  /** Additional request errors introduced by the grant's protocol extensions. */
@@ -90,15 +90,16 @@ interface DeviceAuthorizationGrant<RequestFields extends z.ZodRawShape = z.ZodRa
90
90
  ctx: GenericEndpointContext;
91
91
  request: DeviceAuthorizationRequest & z.infer<z.ZodObject<RequestFields>>;
92
92
  }) => DeviceAuthorizationGrantAuthorization | undefined | Promise<DeviceAuthorizationGrantAuthorization | undefined>;
93
- /** Refuse the standalone session-token endpoint for grant-owned codes. */
94
- assertSessionRedemption: (input: {
93
+ /**
94
+ * Throw when a code this grant issued reaches a standalone endpoint:
95
+ * `/device/token` (`redeem`) or `/device`, `/device/approve`, `/device/deny`
96
+ * (`approve`). The error should name the endpoint to use instead.
97
+ */
98
+ assertStandaloneUse: (input: {
95
99
  ctx: GenericEndpointContext;
96
100
  deviceCode: Record<string, unknown>;
101
+ use: "redeem" | "approve";
97
102
  }) => void | Promise<void>;
98
- /** Add grant-owned information to the owner-only verification response. */
99
- getVerificationContext: (deviceCode: Record<string, unknown>) => VerificationContext | undefined;
100
- /** OpenAPI properties matching `getVerificationContext()`. */
101
- verificationOpenAPIProperties?: Record<string, Record<string, unknown>>;
102
103
  }
103
104
  type DeviceAuthorizationPluginOptions<Grant extends DeviceAuthorizationGrant | undefined = undefined> = Partial<DeviceAuthorizationOptions> & {
104
105
  /** Optional token grant that extends the device authorization flow. */grant?: Grant;
@@ -158,37 +159,37 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
158
159
  cloneRequest: true;
159
160
  body: z.ZodObject<(("scope" | "user_id" | "client_id") & keyof (Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
160
161
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
161
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends never ? {
162
+ }>> ? RequestFields : Record<never, never>) extends never ? {
162
163
  client_id: z.ZodString;
163
164
  user_id: z.ZodOptional<z.ZodString>;
164
165
  scope: z.ZodOptional<z.ZodString>;
165
166
  } & ((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
166
167
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
167
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends infer T_1 ? { -readonly [P in keyof T_1]: T_1[P] } : never) : ({
168
+ }>> ? RequestFields : Record<never, never>) extends infer T_1 ? { -readonly [P in keyof T_1]: T_1[P] } : never) : ({
168
169
  client_id: z.ZodString;
169
170
  user_id: z.ZodOptional<z.ZodString>;
170
171
  scope: z.ZodOptional<z.ZodString>;
171
172
  } extends infer T_2 extends z.core.util.SomeObject ? { [K in keyof T_2 as K extends keyof (Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
172
173
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
173
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) ? never : K]: T_2[K] } : never) & (((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
174
+ }>> ? RequestFields : Record<never, never>) ? never : K]: T_2[K] } : never) & (((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
174
175
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
175
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends infer T_4 ? { -readonly [P in keyof T_4]: T_4[P] } : never) extends infer T_3 extends z.core.util.SomeObject ? { [K_1 in keyof T_3]: T_3[K_1] } : never)) extends infer T ? { [k in keyof T]: T[k] } : never, z.core.$strip> | z.ZodObject<(("scope" | "user_id" | "client_id") & keyof (Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
176
+ }>> ? RequestFields : Record<never, never>) extends infer T_4 ? { -readonly [P in keyof T_4]: T_4[P] } : never) extends infer T_3 extends z.core.util.SomeObject ? { [K_1 in keyof T_3]: T_3[K_1] } : never)) extends infer T ? { [k in keyof T]: T[k] } : never, z.core.$strip> | z.ZodObject<(("scope" | "user_id" | "client_id") & keyof (Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
176
177
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
177
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends never ? {
178
+ }>> ? RequestFields : Record<never, never>) extends never ? {
178
179
  user_id: z.ZodOptional<z.ZodString>;
179
180
  scope: z.ZodOptional<z.ZodString>;
180
181
  client_id: z.ZodOptional<z.ZodString>;
181
182
  } & ((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
182
183
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
183
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends infer T_6 ? { -readonly [P in keyof T_6]: T_6[P] } : never) : ({
184
+ }>> ? RequestFields : Record<never, never>) extends infer T_6 ? { -readonly [P in keyof T_6]: T_6[P] } : never) : ({
184
185
  user_id: z.ZodOptional<z.ZodString>;
185
186
  scope: z.ZodOptional<z.ZodString>;
186
187
  client_id: z.ZodOptional<z.ZodString>;
187
188
  } extends infer T_7 extends z.core.util.SomeObject ? { [K_2 in keyof T_7 as K_2 extends keyof (Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
188
189
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
189
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) ? never : K_2]: T_7[K_2] } : never) & (((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
190
+ }>> ? RequestFields : Record<never, never>) ? never : K_2]: T_7[K_2] } : never) & (((Grant extends DeviceAuthorizationGrant<infer RequestFields extends Readonly<{
190
191
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
191
- }>, Record<string, unknown>> ? RequestFields : Record<never, never>) extends infer T_9 ? { -readonly [P in keyof T_9]: T_9[P] } : never) extends infer T_8 extends z.core.util.SomeObject ? { [K_1 in keyof T_8]: T_8[K_1] } : never)) extends infer T_5 ? { [k_1 in keyof T_5]: T_5[k_1] } : never, z.core.$strip>;
192
+ }>> ? RequestFields : Record<never, never>) extends infer T_9 ? { -readonly [P in keyof T_9]: T_9[P] } : never) extends infer T_8 extends z.core.util.SomeObject ? { [K_1 in keyof T_8]: T_8[K_1] } : never)) extends infer T_5 ? { [k_1 in keyof T_5]: T_5[k_1] } : never, z.core.$strip>;
192
193
  error: z.ZodObject<{
193
194
  error: z.ZodEnum<{ [k_3 in (readonly ["invalid_request", "invalid_client", "unauthorized_client", "invalid_scope", ...Grant extends {
194
195
  requestErrorCodes: infer ErrorCodes extends readonly string[];
@@ -307,10 +308,10 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
307
308
  error: z.ZodObject<{
308
309
  error: z.ZodEnum<{
309
310
  invalid_request: "invalid_request";
310
- authorization_pending: "authorization_pending";
311
- slow_down: "slow_down";
312
311
  expired_token: "expired_token";
313
312
  access_denied: "access_denied";
313
+ authorization_pending: "authorization_pending";
314
+ slow_down: "slow_down";
314
315
  invalid_grant: "invalid_grant";
315
316
  }>;
316
317
  error_description: z.ZodString;
@@ -414,13 +415,11 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
414
415
  };
415
416
  };
416
417
  }, {
417
- user_code: string;
418
- status: string;
419
418
  client_id?: string | undefined;
420
419
  scope?: string | undefined;
421
- } & Partial<Grant extends DeviceAuthorizationGrant<infer _RequestFields extends Readonly<{
422
- [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
423
- }>, infer VerificationContext extends Record<string, unknown>> ? VerificationContext : Record<never, never>>>;
420
+ user_code: string;
421
+ status: string;
422
+ }>;
424
423
  deviceApprove: _$better_call0.StrictEndpoint<"/device/approve", {
425
424
  method: "POST";
426
425
  body: z.ZodObject<{
@@ -431,7 +430,6 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
431
430
  invalid_request: "invalid_request";
432
431
  expired_token: "expired_token";
433
432
  access_denied: "access_denied";
434
- device_code_already_processed: "device_code_already_processed";
435
433
  unauthorized: "unauthorized";
436
434
  }>;
437
435
  error_description: z.ZodString;
@@ -526,7 +524,7 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
526
524
  options: {
527
525
  grant: DeviceAuthorizationGrant<Readonly<{
528
526
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
529
- }>, Record<string, unknown>> | undefined;
527
+ }>> | undefined;
530
528
  expiresIn: TimeString;
531
529
  interval: TimeString;
532
530
  deviceCodeLength: number;
@@ -555,4 +553,4 @@ declare const deviceAuthorization: <Grant extends DeviceAuthorizationGrant | und
555
553
  };
556
554
  };
557
555
  //#endregion
558
- export { DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, type DeviceCode, type DeviceCodeRedemptionAuthorization, type DeviceCodeRedemptionResult, TimeString, deviceAuthorization, deviceAuthorizationOptionsSchema, ms, redeemDeviceCode, sec };
556
+ export { DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, type DeviceCode, type DeviceCodeRedemptionAuthorization, type DeviceCodeRedemptionResult, TimeString, assertDeviceCodePending, buildVerificationUris, decideDeviceCode, deviceAuthorization, deviceAuthorizationOptionsSchema, findDeviceCodeByUserCode, ms, redeemDeviceCode, sec };
@@ -3,7 +3,7 @@ import { ms } from "../../utils/time.mjs";
3
3
  import { PACKAGE_VERSION } from "../../version.mjs";
4
4
  import { DEVICE_AUTHORIZATION_ERROR_CODES } from "./error-codes.mjs";
5
5
  import { schema } from "./schema.mjs";
6
- import { deviceApprove, deviceCode, deviceDeny, deviceToken, deviceVerify, redeemDeviceCode } from "./routes.mjs";
6
+ import { assertDeviceCodePending, buildVerificationUris, decideDeviceCode, deviceCode, deviceDecisionEndpoint, deviceToken, deviceVerify, findDeviceCodeByUserCode, redeemDeviceCode } from "./routes.mjs";
7
7
  import { BetterAuthError } from "@better-auth/core/error";
8
8
  import * as z from "zod";
9
9
  //#region src/plugins/device-authorization/index.ts
@@ -33,19 +33,11 @@ const deviceAuthorizationRequestFields = new Set([
33
33
  "user_id",
34
34
  "scope"
35
35
  ]);
36
- const deviceVerificationResponseFields = new Set([
37
- "user_code",
38
- "status",
39
- "client_id",
40
- "scope"
41
- ]);
42
36
  function assertGrantFieldsAreAdditional(grant) {
43
37
  const conflictingDeviceCodeFields = Object.keys(grant?.deviceCodeSchemaFields ?? {}).filter((field) => field in schema.deviceCode.fields);
44
38
  if (conflictingDeviceCodeFields.length > 0) throw new BetterAuthError(`Device authorization grant fields must be additional and cannot redefine deviceCode fields: ${conflictingDeviceCodeFields.join(", ")}`);
45
39
  const conflictingRequestFields = Object.keys(grant?.requestSchemaFields ?? {}).filter((field) => deviceAuthorizationRequestFields.has(field));
46
40
  if (conflictingRequestFields.length > 0) throw new BetterAuthError(`Device authorization grant request fields must be additional and cannot redefine request fields: ${conflictingRequestFields.join(", ")}`);
47
- const conflictingVerificationFields = Object.keys(grant?.verificationOpenAPIProperties ?? {}).filter((field) => deviceVerificationResponseFields.has(field));
48
- if (conflictingVerificationFields.length > 0) throw new BetterAuthError(`Device authorization grant verification fields must be additional and cannot redefine response fields: ${conflictingVerificationFields.join(", ")}`);
49
41
  }
50
42
  const deviceAuthorization = (options = {}) => {
51
43
  const { grant: configuredGrant, ...deviceAuthorizationOptions } = options;
@@ -66,8 +58,8 @@ const deviceAuthorization = (options = {}) => {
66
58
  deviceCode: deviceCode(opts, grant),
67
59
  deviceToken: deviceToken(opts, grant),
68
60
  deviceVerify: deviceVerify(grant),
69
- deviceApprove,
70
- deviceDeny
61
+ deviceApprove: deviceDecisionEndpoint("/device/approve", grant),
62
+ deviceDeny: deviceDecisionEndpoint("/device/deny", grant)
71
63
  },
72
64
  rateLimit: [{
73
65
  pathMatcher(path) {
@@ -84,4 +76,4 @@ const deviceAuthorization = (options = {}) => {
84
76
  };
85
77
  };
86
78
  //#endregion
87
- export { deviceAuthorization, deviceAuthorizationOptionsSchema, redeemDeviceCode };
79
+ export { assertDeviceCodePending, buildVerificationUris, decideDeviceCode, deviceAuthorization, deviceAuthorizationOptionsSchema, findDeviceCodeByUserCode, redeemDeviceCode };
@@ -3,6 +3,39 @@ import { User } from "../../types/models.mjs";
3
3
  import { DeviceCode } from "./schema.mjs";
4
4
  import { GenericEndpointContext } from "@better-auth/core";
5
5
  //#region src/plugins/device-authorization/routes.d.ts
6
+ /**
7
+ * Preserve exact custom user codes, and normalize only default-alphabet codes.
8
+ */
9
+ declare function findDeviceCodeByUserCode(ctx: GenericEndpointContext, userCode: string): Promise<{
10
+ id: string;
11
+ deviceCode: string;
12
+ userCode: string;
13
+ expiresAt: Date;
14
+ status: string;
15
+ userId?: string | undefined;
16
+ lastPolledAt?: Date | undefined;
17
+ pollingInterval?: number | undefined;
18
+ clientId?: string | undefined;
19
+ scope?: string | undefined;
20
+ } | null>;
21
+ /** Throws unless the device code is unexpired and still awaiting a decision. */
22
+ declare function assertDeviceCodePending(deviceCode: DeviceCode): void;
23
+ /**
24
+ * Records `userId`'s approval or denial of a pending, unexpired device code,
25
+ * binding an unclaimed code to that user in the same write. The write is
26
+ * guarded, so a decision that loses a race with another decision, or outlasts
27
+ * the code, fails instead of overwriting it.
28
+ */
29
+ declare function decideDeviceCode(ctx: GenericEndpointContext, input: {
30
+ deviceCode: DeviceCode;
31
+ userId: string;
32
+ decision: "approved" | "denied";
33
+ /**
34
+ * Fields to persist with the decision, such as grant-owned fields or a
35
+ * scope the user narrowed on a consent page.
36
+ */
37
+ fields?: Record<string, unknown> | undefined;
38
+ }): Promise<void>;
6
39
  interface DeviceCodeRedemptionAuthorization<AuthorizationContext> {
7
40
  /** Additional ownership predicate used by the atomic claim. */
8
41
  ownershipWhere: Where;
@@ -28,5 +61,13 @@ declare function redeemDeviceCode<DeviceCodeFields extends Record<string, unknow
28
61
  authorizeRedemption: (deviceCode: DeviceCode & DeviceCodeFields) => DeviceCodeRedemptionAuthorization<AuthorizationContext> | Promise<DeviceCodeRedemptionAuthorization<AuthorizationContext>>;
29
62
  prepareRedemption: (deviceCode: DeviceCode & DeviceCodeFields, authorizationContext: AuthorizationContext) => RedemptionContext | Promise<RedemptionContext>;
30
63
  }): Promise<DeviceCodeRedemptionResult<DeviceCodeFields, AuthorizationContext, RedemptionContext>>;
64
+ /**
65
+ * Builds the `verification_uri` and `verification_uri_complete` for a user code,
66
+ * resolving a relative `verificationUri` against the auth base URL.
67
+ */
68
+ declare const buildVerificationUris: (verificationUri: string | undefined, baseURL: string, userCode: string) => {
69
+ verificationUri: string;
70
+ verificationUriComplete: string;
71
+ };
31
72
  //#endregion
32
- export { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, redeemDeviceCode };
73
+ export { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, assertDeviceCodePending, buildVerificationUris, decideDeviceCode, findDeviceCodeByUserCode, redeemDeviceCode };
@@ -66,6 +66,97 @@ async function findDeviceCodeByUserCode(ctx, userCode) {
66
66
  if (normalizedUserCode === userCode || !defaultUserCodePattern.test(normalizedUserCode)) return exactDeviceCode;
67
67
  return findByUserCode(normalizedUserCode);
68
68
  }
69
+ const expiredUserCode = () => new APIError("BAD_REQUEST", {
70
+ error: "expired_token",
71
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message
72
+ });
73
+ const deviceCodeAlreadyProcessed = () => new APIError("BAD_REQUEST", {
74
+ error: "invalid_request",
75
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED.message
76
+ });
77
+ /** Throws unless the device code is unexpired and still awaiting a decision. */
78
+ function assertDeviceCodePending(deviceCode) {
79
+ if (deviceCode.expiresAt < /* @__PURE__ */ new Date()) throw expiredUserCode();
80
+ if (deviceCode.status !== "pending") throw deviceCodeAlreadyProcessed();
81
+ }
82
+ /**
83
+ * Records `userId`'s approval or denial of a pending, unexpired device code,
84
+ * binding an unclaimed code to that user in the same write. The write is
85
+ * guarded, so a decision that loses a race with another decision, or outlasts
86
+ * the code, fails instead of overwriting it.
87
+ */
88
+ async function decideDeviceCode(ctx, input) {
89
+ const { deviceCode, userId, decision } = input;
90
+ if (deviceCode.userId && deviceCode.userId !== userId) throw new APIError("FORBIDDEN", {
91
+ error: "access_denied",
92
+ error_description: `You are not authorized to ${decision === "approved" ? "approve" : "deny"} this device authorization`
93
+ });
94
+ if (!await ctx.context.adapter.incrementOne({
95
+ model: "deviceCode",
96
+ where: [
97
+ {
98
+ field: "id",
99
+ value: deviceCode.id
100
+ },
101
+ {
102
+ field: "status",
103
+ value: "pending"
104
+ },
105
+ {
106
+ field: "userId",
107
+ operator: "eq",
108
+ value: deviceCode.userId ?? null
109
+ },
110
+ {
111
+ field: "expiresAt",
112
+ operator: "gt",
113
+ value: /* @__PURE__ */ new Date()
114
+ }
115
+ ],
116
+ increment: {},
117
+ set: {
118
+ ...input.fields,
119
+ userId,
120
+ status: decision
121
+ }
122
+ })) throw deviceCode.expiresAt <= /* @__PURE__ */ new Date() ? expiredUserCode() : deviceCodeAlreadyProcessed();
123
+ }
124
+ /**
125
+ * Finds the code a standalone endpoint (`/device`, `/device/approve`,
126
+ * `/device/deny`) acts on, refusing codes that a grant approves itself.
127
+ */
128
+ async function findStandaloneDeviceCode(ctx, grant, userCode) {
129
+ const deviceCode = await findDeviceCodeByUserCode(ctx, userCode);
130
+ if (!deviceCode) throw new APIError("BAD_REQUEST", {
131
+ error: "invalid_request",
132
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message
133
+ });
134
+ await grant?.assertStandaloneUse({
135
+ ctx,
136
+ deviceCode,
137
+ use: "approve"
138
+ });
139
+ return deviceCode;
140
+ }
141
+ /** Handles `/device/approve` and `/device/deny` for a code the user claimed. */
142
+ async function recordDeviceCodeDecision(ctx, grant, userCode, decision) {
143
+ const session = await getSessionFromCtx(ctx);
144
+ if (!session) throw new APIError("UNAUTHORIZED", {
145
+ error: "unauthorized",
146
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED.message
147
+ });
148
+ const deviceCodeRecord = await findStandaloneDeviceCode(ctx, grant, userCode);
149
+ assertDeviceCodePending(deviceCodeRecord);
150
+ if (!deviceCodeRecord.userId) throw new APIError("BAD_REQUEST", {
151
+ error: "invalid_request",
152
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_NOT_CLAIMED.message
153
+ });
154
+ await decideDeviceCode(ctx, {
155
+ deviceCode: deviceCodeRecord,
156
+ userId: session.user.id,
157
+ decision
158
+ });
159
+ }
69
160
  const deviceCodeBodySchema = z.object({
70
161
  client_id: z.string().meta({ description: "The client ID of the application" }),
71
162
  user_id: z.string().meta({ description: "The user ID to which the device code should be pre-bound." }).optional(),
@@ -299,6 +390,19 @@ async function redeemDeviceCode(input) {
299
390
  error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE.message
300
391
  });
301
392
  const authorization = await input.authorizeRedemption(deviceCodeRecord);
393
+ if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) {
394
+ await input.ctx.context.adapter.delete({
395
+ model: "deviceCode",
396
+ where: [{
397
+ field: "id",
398
+ value: deviceCodeRecord.id
399
+ }]
400
+ });
401
+ throw new APIError("BAD_REQUEST", {
402
+ error: "expired_token",
403
+ error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_DEVICE_CODE.message
404
+ });
405
+ }
302
406
  if (deviceCodeRecord.lastPolledAt && deviceCodeRecord.pollingInterval) {
303
407
  if (Date.now() - new Date(deviceCodeRecord.lastPolledAt).getTime() < deviceCodeRecord.pollingInterval) throw new APIError("BAD_REQUEST", {
304
408
  error: "slow_down",
@@ -313,19 +417,6 @@ async function redeemDeviceCode(input) {
313
417
  }],
314
418
  update: { lastPolledAt: /* @__PURE__ */ new Date() }
315
419
  });
316
- if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) {
317
- await input.ctx.context.adapter.delete({
318
- model: "deviceCode",
319
- where: [{
320
- field: "id",
321
- value: deviceCodeRecord.id
322
- }]
323
- });
324
- throw new APIError("BAD_REQUEST", {
325
- error: "expired_token",
326
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_DEVICE_CODE.message
327
- });
328
- }
329
420
  if (deviceCodeRecord.status === "pending") throw new APIError("BAD_REQUEST", {
330
421
  error: "authorization_pending",
331
422
  error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHORIZATION_PENDING.message
@@ -438,9 +529,10 @@ Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#secti
438
529
  error: "invalid_grant",
439
530
  error_description: "Client ID mismatch"
440
531
  });
441
- await grant?.assertSessionRedemption({
532
+ await grant?.assertStandaloneUse({
442
533
  ctx,
443
- deviceCode: deviceCodeRecord
534
+ deviceCode: deviceCodeRecord,
535
+ use: "redeem"
444
536
  });
445
537
  return {
446
538
  ownershipWhere: {
@@ -508,23 +600,15 @@ const deviceVerify = (grant) => createAuthEndpoint("/device", {
508
600
  scope: {
509
601
  type: "string",
510
602
  description: "The requested scopes, returned only to the authenticated user who owns this request"
511
- },
512
- ...grant?.verificationOpenAPIProperties
603
+ }
513
604
  }
514
605
  } } }
515
606
  } }
516
607
  } }
517
608
  }, async (ctx) => {
518
609
  const { user_code } = ctx.query;
519
- const deviceCodeRecord = await findDeviceCodeByUserCode(ctx, user_code);
520
- if (!deviceCodeRecord) throw new APIError("BAD_REQUEST", {
521
- error: "invalid_request",
522
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message
523
- });
524
- if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
525
- error: "expired_token",
526
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message
527
- });
610
+ const deviceCodeRecord = await findStandaloneDeviceCode(ctx, grant, user_code);
611
+ if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) throw expiredUserCode();
528
612
  const session = await getSessionFromCtx(ctx);
529
613
  if (session?.user?.id && !deviceCodeRecord.userId && deviceCodeRecord.status === "pending") {
530
614
  if (await ctx.context.adapter.incrementOne({
@@ -549,149 +633,50 @@ const deviceVerify = (grant) => createAuthEndpoint("/device", {
549
633
  })) deviceCodeRecord.userId = session.user.id;
550
634
  }
551
635
  const canReviewRequest = session?.user.id !== void 0 && deviceCodeRecord.userId === session.user.id;
552
- const grantContext = canReviewRequest ? grant?.getVerificationContext(deviceCodeRecord) : void 0;
553
- const response = {
554
- ...canReviewRequest ? grantContext : void 0,
636
+ return ctx.json({
555
637
  user_code,
556
638
  status: deviceCodeRecord.status,
557
639
  ...canReviewRequest ? {
558
640
  client_id: deviceCodeRecord.clientId,
559
641
  scope: deviceCodeRecord.scope
560
642
  } : {}
561
- };
562
- return ctx.json(response);
563
- });
564
- const deviceApprove = createAuthEndpoint("/device/approve", {
565
- method: "POST",
566
- body: z.object({ userCode: z.string().meta({ description: "The user code to approve" }) }),
567
- error: z.object({
568
- error: z.enum([
569
- "invalid_request",
570
- "expired_token",
571
- "device_code_already_processed",
572
- "unauthorized",
573
- "access_denied"
574
- ]).meta({ description: "Error code" }),
575
- error_description: z.string().meta({ description: "Detailed error description" })
576
- }),
577
- requireHeaders: true,
578
- metadata: { openapi: {
579
- description: "Approve device authorization",
580
- responses: { 200: {
581
- description: "Success",
582
- content: { "application/json": { schema: {
583
- type: "object",
584
- properties: { success: { type: "boolean" } }
585
- } } }
586
- } }
587
- } }
588
- }, async (ctx) => {
589
- const session = await getSessionFromCtx(ctx);
590
- if (!session) throw new APIError("UNAUTHORIZED", {
591
- error: "unauthorized",
592
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED.message
593
- });
594
- const { userCode } = ctx.body;
595
- const deviceCodeRecord = await findDeviceCodeByUserCode(ctx, userCode);
596
- if (!deviceCodeRecord) throw new APIError("BAD_REQUEST", {
597
- error: "invalid_request",
598
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message
599
643
  });
600
- if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
601
- error: "expired_token",
602
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message
603
- });
604
- if (deviceCodeRecord.status !== "pending") throw new APIError("BAD_REQUEST", {
605
- error: "invalid_request",
606
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED.message
607
- });
608
- if (!deviceCodeRecord.userId) throw new APIError("BAD_REQUEST", {
609
- error: "invalid_request",
610
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_NOT_CLAIMED.message
611
- });
612
- if (deviceCodeRecord.userId !== session.user.id) throw new APIError("FORBIDDEN", {
613
- error: "access_denied",
614
- error_description: "You are not authorized to approve this device authorization"
615
- });
616
- await ctx.context.adapter.update({
617
- model: "deviceCode",
618
- where: [{
619
- field: "id",
620
- value: deviceCodeRecord.id
621
- }],
622
- update: {
623
- status: "approved",
624
- userId: session.user.id
625
- }
626
- });
627
- return ctx.json({ success: true });
628
644
  });
629
- const deviceDeny = createAuthEndpoint("/device/deny", {
630
- method: "POST",
631
- body: z.object({ userCode: z.string().meta({ description: "The user code to deny" }) }),
632
- error: z.object({
633
- error: z.enum([
634
- "invalid_request",
635
- "expired_token",
636
- "unauthorized",
637
- "access_denied"
638
- ]).meta({ description: "Error code" }),
639
- error_description: z.string().meta({ description: "Detailed error description" })
640
- }),
641
- requireHeaders: true,
642
- metadata: { openapi: {
643
- description: "Deny device authorization",
644
- responses: { 200: {
645
- description: "Success",
646
- content: { "application/json": { schema: {
647
- type: "object",
648
- properties: { success: { type: "boolean" } }
649
- } } }
645
+ /** `/device/approve` and `/device/deny`, which differ only in the decision. */
646
+ const deviceDecisionEndpoint = (path, grant) => {
647
+ const decision = path === "/device/approve" ? "approved" : "denied";
648
+ const action = decision === "approved" ? "Approve" : "Deny";
649
+ return createAuthEndpoint(path, {
650
+ method: "POST",
651
+ body: z.object({ userCode: z.string().meta({ description: `The user code to ${action.toLowerCase()}` }) }),
652
+ error: z.object({
653
+ error: z.enum([
654
+ "invalid_request",
655
+ "expired_token",
656
+ "unauthorized",
657
+ "access_denied"
658
+ ]).meta({ description: "Error code" }),
659
+ error_description: z.string().meta({ description: "Detailed error description" })
660
+ }),
661
+ requireHeaders: true,
662
+ metadata: { openapi: {
663
+ description: `${action} device authorization`,
664
+ responses: { 200: {
665
+ description: "Success",
666
+ content: { "application/json": { schema: {
667
+ type: "object",
668
+ properties: { success: { type: "boolean" } }
669
+ } } }
670
+ } }
650
671
  } }
651
- } }
652
- }, async (ctx) => {
653
- const session = await getSessionFromCtx(ctx);
654
- if (!session) throw new APIError("UNAUTHORIZED", {
655
- error: "unauthorized",
656
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED.message
657
- });
658
- const { userCode } = ctx.body;
659
- const deviceCodeRecord = await findDeviceCodeByUserCode(ctx, userCode);
660
- if (!deviceCodeRecord) throw new APIError("BAD_REQUEST", {
661
- error: "invalid_request",
662
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message
663
- });
664
- if (deviceCodeRecord.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
665
- error: "expired_token",
666
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message
667
- });
668
- if (deviceCodeRecord.status !== "pending") throw new APIError("BAD_REQUEST", {
669
- error: "invalid_request",
670
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED.message
671
- });
672
- if (!deviceCodeRecord.userId) throw new APIError("BAD_REQUEST", {
673
- error: "invalid_request",
674
- error_description: DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_NOT_CLAIMED.message
675
- });
676
- if (deviceCodeRecord.userId !== session.user.id) throw new APIError("FORBIDDEN", {
677
- error: "access_denied",
678
- error_description: "You are not authorized to deny this device authorization"
679
- });
680
- await ctx.context.adapter.update({
681
- model: "deviceCode",
682
- where: [{
683
- field: "id",
684
- value: deviceCodeRecord.id
685
- }],
686
- update: {
687
- status: "denied",
688
- userId: session.user.id
689
- }
672
+ }, async (ctx) => {
673
+ await recordDeviceCodeDecision(ctx, grant, ctx.body.userCode, decision);
674
+ return ctx.json({ success: true });
690
675
  });
691
- return ctx.json({ success: true });
692
- });
676
+ };
693
677
  /**
694
- * @internal
678
+ * Builds the `verification_uri` and `verification_uri_complete` for a user code,
679
+ * resolving a relative `verificationUri` against the auth base URL.
695
680
  */
696
681
  const buildVerificationUris = (verificationUri, baseURL, userCode) => {
697
682
  const uri = verificationUri || "/device";
@@ -722,4 +707,4 @@ const defaultGenerateUserCode = (length) => {
722
707
  return Array.from(crypto.getRandomValues(chars)).map((byte) => defaultCharset[byte % 32]).join("");
723
708
  };
724
709
  //#endregion
725
- export { deviceApprove, deviceCode, deviceDeny, deviceToken, deviceVerify, redeemDeviceCode };
710
+ export { assertDeviceCodePending, buildVerificationUris, decideDeviceCode, deviceCode, deviceDecisionEndpoint, deviceToken, deviceVerify, findDeviceCodeByUserCode, redeemDeviceCode };
@@ -15,7 +15,7 @@ import { captcha } from "./captcha/index.mjs";
15
15
  import { CustomSessionPluginOptions, customSession } from "./custom-session/index.mjs";
16
16
  import { TimeString, ms, sec } from "../utils/time.mjs";
17
17
  import { DeviceCode } from "./device-authorization/schema.mjs";
18
- import { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, redeemDeviceCode } from "./device-authorization/routes.mjs";
18
+ import { DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, assertDeviceCodePending, buildVerificationUris, decideDeviceCode, findDeviceCodeByUserCode, redeemDeviceCode } from "./device-authorization/routes.mjs";
19
19
  import { DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, deviceAuthorization, deviceAuthorizationOptionsSchema } from "./device-authorization/index.mjs";
20
20
  import { EmailOTPOptions } from "./email-otp/types.mjs";
21
21
  import { emailOTP } from "./email-otp/index.mjs";
@@ -66,4 +66,4 @@ import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
66
66
  import { UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, username } from "./username/index.mjs";
67
67
  import { hasPermission } from "./organization/has-permission.mjs";
68
68
  import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization/organization.mjs";
69
- export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestAuthOptions, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, ValidateRequestContext, VercelBotIdOptions, YandexOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
69
+ export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestAuthOptions, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, ValidateRequestContext, VercelBotIdOptions, YandexOptions, admin, anonymous, assertDeviceCodePending, auth0, backupCode2fa, bearer, buildVerificationUris, captcha, createAccessControl, createJwk, customSession, decideDeviceCode, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, findDeviceCodeByUserCode, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
@@ -11,7 +11,7 @@ import { anonymous } from "./anonymous/index.mjs";
11
11
  import { bearer } from "./bearer/index.mjs";
12
12
  import { captcha } from "./captcha/index.mjs";
13
13
  import { customSession } from "./custom-session/index.mjs";
14
- import { redeemDeviceCode } from "./device-authorization/routes.mjs";
14
+ import { assertDeviceCodePending, buildVerificationUris, decideDeviceCode, findDeviceCodeByUserCode, redeemDeviceCode } from "./device-authorization/routes.mjs";
15
15
  import { deviceAuthorization, deviceAuthorizationOptionsSchema } from "./device-authorization/index.mjs";
16
16
  import { emailOTP } from "./email-otp/index.mjs";
17
17
  import { auth0 } from "./generic-oauth/providers/auth0.mjs";
@@ -46,4 +46,4 @@ import { siwe } from "./siwe/index.mjs";
46
46
  import { testUtils } from "./test-utils/index.mjs";
47
47
  import { twoFactor } from "./two-factor/index.mjs";
48
48
  import { username } from "./username/index.mjs";
49
- export { MULTI_SESSION_ERROR_CODES as ERROR_CODES, HIDE_METADATA, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, POPUP_MARKER_COOKIE, TWO_FACTOR_ERROR_CODES, USERNAME_ERROR_CODES, admin, anonymous, auth0, bearer, captcha, createAccessControl, createJwk, customSession, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, generateExportedKeyPair, genericOAuth, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, microsoftEntraId, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, signJWT, siwe, slack, testUtils, toExpJWT, twoFactor, twoFactorClient, username, verifyJWT, yandex };
49
+ export { MULTI_SESSION_ERROR_CODES as ERROR_CODES, HIDE_METADATA, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, POPUP_MARKER_COOKIE, TWO_FACTOR_ERROR_CODES, USERNAME_ERROR_CODES, admin, anonymous, assertDeviceCodePending, auth0, bearer, buildVerificationUris, captcha, createAccessControl, createJwk, customSession, decideDeviceCode, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, findDeviceCodeByUserCode, generateExportedKeyPair, genericOAuth, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, microsoftEntraId, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, signJWT, siwe, slack, testUtils, toExpJWT, twoFactor, twoFactorClient, username, verifyJWT, yandex };
@@ -9,6 +9,7 @@ const lastLoginMethod = (userConfig) => {
9
9
  const path = ctx.path;
10
10
  if (!path) return null;
11
11
  if (path.startsWith("/callback/")) return ctx.params?.id || path.split("/").pop();
12
+ if (path === "/one-tap/callback") return "google";
12
13
  if (path === "/sign-in/email" || path === "/sign-up/email") return "email";
13
14
  if (path.includes("siwe")) return "siwe";
14
15
  if (path.includes("/passkey/verify-authentication")) return "passkey";
@@ -81,7 +81,7 @@ interface GoogleOneTapOptions {
81
81
  /**
82
82
  * Cancel the flow when the user taps outside the prompt
83
83
  *
84
- * Note: To use this option, disable `promptOptions.fedCM`
84
+ * Note: This may have no effect, as FedCM lets the browser manage dismissal
85
85
  */
86
86
  cancelOnTapOutside?: boolean | undefined;
87
87
  /**
@@ -119,7 +119,9 @@ interface GoogleOneTapOptions {
119
119
  */
120
120
  maxAttempts?: number;
121
121
  /**
122
- * Whether to support FedCM (Federated Credential Management) support.
122
+ * Whether to call `navigator.credentials.preventSilentAccess()` on
123
+ * sign-out to clear the browser's FedCM credential state. FedCM for
124
+ * the prompt itself is managed by Google and cannot be disabled.
123
125
  *
124
126
  * @see {@link https://developer.chrome.com/docs/identity/fedcm/overview}
125
127
  * @default true
@@ -9,10 +9,7 @@ function isFedCMSupported() {
9
9
  * Reasons that should NOT trigger a retry.
10
10
  * @see https://developers.google.com/identity/gsi/web/reference/js-reference
11
11
  */
12
- const noRetryReasons = {
13
- dismissed: ["credential_returned", "cancel_called"],
14
- skipped: ["user_cancel", "tap_outside"]
15
- };
12
+ const noRetryReasons = { dismissed: ["credential_returned", "cancel_called"] };
16
13
  const oneTapClient = (options) => {
17
14
  return {
18
15
  id: "one-tap",
@@ -58,9 +55,9 @@ const oneTapClient = (options) => {
58
55
  if (isSafeUrlScheme(target)) window.location.href = target;
59
56
  }
60
57
  }
61
- const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
62
- const contextValue = context ?? options.context ?? "signin";
63
- const useFedCM = options.promptOptions?.fedCM !== false;
58
+ const autoSelect = opts?.autoSelect ?? options.autoSelect;
59
+ const cancelOnTapOutside = opts?.cancelOnTapOutside ?? options.cancelOnTapOutside;
60
+ const contextValue = opts?.context ?? options.context ?? "signin";
64
61
  window.google?.accounts.id.initialize({
65
62
  client_id: options.clientId,
66
63
  callback: async (response) => {
@@ -76,7 +73,6 @@ const oneTapClient = (options) => {
76
73
  ux_mode: opts?.uxMode || "popup",
77
74
  nonce: opts?.nonce,
78
75
  itp_support: true,
79
- use_fedcm_for_prompt: useFedCM,
80
76
  ...options.additionalOptions
81
77
  });
82
78
  window.google?.accounts.id.renderButton(container, opts.button.config ?? { type: "icon" });
@@ -97,8 +93,9 @@ const oneTapClient = (options) => {
97
93
  if (isSafeUrlScheme(target)) window.location.href = target;
98
94
  }
99
95
  }
100
- const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
101
- const contextValue = context ?? options.context ?? "signin";
96
+ const autoSelect = opts?.autoSelect ?? options.autoSelect;
97
+ const cancelOnTapOutside = opts?.cancelOnTapOutside ?? options.cancelOnTapOutside;
98
+ const contextValue = opts?.context ?? options.context ?? "signin";
102
99
  isRequestInProgress = true;
103
100
  try {
104
101
  await loadGoogleScript();
@@ -106,7 +103,6 @@ const oneTapClient = (options) => {
106
103
  let isResolved = false;
107
104
  const baseDelay = options.promptOptions?.baseDelay ?? 1e3;
108
105
  const maxAttempts = options.promptOptions?.maxAttempts ?? 5;
109
- const useFedCM = options.promptOptions?.fedCM !== false;
110
106
  window.google?.accounts.id.initialize({
111
107
  client_id: options.clientId,
112
108
  callback: async (response) => {
@@ -128,7 +124,6 @@ const oneTapClient = (options) => {
128
124
  * @see {@link https://developers.google.com/identity/gsi/web/guides/overview}
129
125
  */
130
126
  itp_support: true,
131
- use_fedcm_for_prompt: useFedCM,
132
127
  ...options.additionalOptions
133
128
  });
134
129
  const handlePrompt = (attempt) => {
@@ -150,19 +145,8 @@ const oneTapClient = (options) => {
150
145
  resolve();
151
146
  }
152
147
  } else if (notification.isSkippedMoment?.()) {
153
- const reason = notification.getSkippedReason?.();
154
- if (!reason || noRetryReasons.skipped.includes(reason)) {
155
- opts?.onPromptNotification?.(notification);
156
- resolve();
157
- return;
158
- }
159
- if (attempt < maxAttempts) {
160
- const delay = Math.pow(2, attempt) * baseDelay;
161
- setTimeout(() => handlePrompt(attempt + 1), delay);
162
- } else {
163
- opts?.onPromptNotification?.(notification);
164
- resolve();
165
- }
148
+ opts?.onPromptNotification?.(notification);
149
+ resolve();
166
150
  } else if (notification.isNotDisplayed?.()) {
167
151
  opts?.onPromptNotification?.(notification);
168
152
  resolve();
@@ -1,5 +1,7 @@
1
1
  import { parseUserOutput } from "../../db/schema.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
+ import { getAwaitableValue } from "../../context/helpers.mjs";
4
+ import { toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
3
5
  import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/errors.mjs";
4
6
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
5
7
  import { APIError as APIError$1 } from "../../api/index.mjs";
@@ -58,16 +60,32 @@ const oneTap = (options) => ({
58
60
  ctx.context.logger.error(`Google One Tap sign-in rejected: id token hosted domain (hd) "${payload.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${configuredHostedDomain}".`);
59
61
  throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
60
62
  }
61
- const { email: rawEmail, email_verified, name, picture, sub } = payload;
63
+ const googleOAuthProvider = await getAwaitableValue(ctx.context.socialProviders, { value: "google" });
64
+ const providerResult = googleOAuthProvider ? await googleOAuthProvider.getUserInfo({ idToken }) : {
65
+ user: {
66
+ name: payload.name,
67
+ email: payload.email,
68
+ image: payload.picture,
69
+ emailVerified: toBoolean(payload.email_verified)
70
+ },
71
+ data: payload
72
+ };
73
+ if (!providerResult?.user) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
74
+ const providerUser = providerResult.user;
75
+ const { sub } = payload;
76
+ const { email: rawEmail, name, image } = providerUser;
62
77
  if (typeof rawEmail !== "string" || !rawEmail) throw new APIError$1("BAD_REQUEST", { message: "Email not available in token" });
63
78
  if (typeof sub !== "string" || !sub) throw new APIError$1("BAD_REQUEST", { message: "invalid id token" });
79
+ const email = rawEmail.toLowerCase();
80
+ const emailVerified = toBoolean(providerUser.emailVerified);
64
81
  const result = await handleOAuthUserInfo(ctx, {
65
82
  userInfo: {
83
+ ...providerUser,
66
84
  id: sub,
67
- email: rawEmail.toLowerCase(),
68
- emailVerified: typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified),
85
+ email,
86
+ emailVerified,
69
87
  name: typeof name === "string" ? name : "",
70
- image: typeof picture === "string" ? picture : void 0
88
+ image: typeof image === "string" ? image : void 0
71
89
  },
72
90
  account: {
73
91
  providerId: "google",
@@ -76,11 +94,12 @@ const oneTap = (options) => ({
76
94
  scope: "openid,profile,email"
77
95
  },
78
96
  disableSignUp: options?.disableSignup || googleProvider?.disableSignUp,
97
+ overrideUserInfo: googleProvider?.overrideUserInfoOnSignIn,
79
98
  source: {
80
99
  method: "oauth",
81
100
  oauth: {
82
101
  providerId: "google",
83
- profile: payload
102
+ profile: toOAuthProfileRecord(providerResult.data)
84
103
  }
85
104
  }
86
105
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.7.9",
3
+ "version": "1.8.0-beta.0",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -221,6 +221,11 @@
221
221
  "types": "./dist/plugins/bearer/index.d.mts",
222
222
  "default": "./dist/plugins/bearer/index.mjs"
223
223
  },
224
+ "./plugins/captcha": {
225
+ "dev-source": "./src/plugins/captcha/index.ts",
226
+ "types": "./dist/plugins/captcha/index.d.mts",
227
+ "default": "./dist/plugins/captcha/index.mjs"
228
+ },
224
229
  "./plugins/custom-session": {
225
230
  "dev-source": "./src/plugins/custom-session/index.ts",
226
231
  "types": "./dist/plugins/custom-session/index.d.mts",
@@ -276,6 +281,16 @@
276
281
  "types": "./dist/plugins/one-time-token/index.d.mts",
277
282
  "default": "./dist/plugins/one-time-token/index.mjs"
278
283
  },
284
+ "./plugins/one-tap": {
285
+ "dev-source": "./src/plugins/one-tap/index.ts",
286
+ "types": "./dist/plugins/one-tap/index.d.mts",
287
+ "default": "./dist/plugins/one-tap/index.mjs"
288
+ },
289
+ "./plugins/open-api": {
290
+ "dev-source": "./src/plugins/open-api/index.ts",
291
+ "types": "./dist/plugins/open-api/index.d.mts",
292
+ "default": "./dist/plugins/open-api/index.mjs"
293
+ },
279
294
  "./plugins/phone-number": {
280
295
  "dev-source": "./src/plugins/phone-number/index.ts",
281
296
  "types": "./dist/plugins/phone-number/index.d.mts",
@@ -355,6 +370,9 @@
355
370
  "api": [
356
371
  "./dist/api/index.d.mts"
357
372
  ],
373
+ "test": [
374
+ "./dist/test-utils/index.d.mts"
375
+ ],
358
376
  "db": [
359
377
  "./dist/db/index.d.mts"
360
378
  ],
@@ -415,6 +433,9 @@
415
433
  "plugins/bearer": [
416
434
  "./dist/plugins/bearer/index.d.mts"
417
435
  ],
436
+ "plugins/captcha": [
437
+ "./dist/plugins/captcha/index.d.mts"
438
+ ],
418
439
  "plugins/custom-session": [
419
440
  "./dist/plugins/custom-session/index.d.mts"
420
441
  ],
@@ -433,12 +454,21 @@
433
454
  "plugins/one-time-token": [
434
455
  "./dist/plugins/one-time-token/index.d.mts"
435
456
  ],
457
+ "plugins/one-tap": [
458
+ "./dist/plugins/one-tap/index.d.mts"
459
+ ],
460
+ "plugins/open-api": [
461
+ "./dist/plugins/open-api/index.d.mts"
462
+ ],
436
463
  "plugins/jwt": [
437
464
  "./dist/plugins/jwt/index.d.mts"
438
465
  ],
439
466
  "plugins/magic-link": [
440
467
  "./dist/plugins/magic-link/index.d.mts"
441
468
  ],
469
+ "plugins/multi-session": [
470
+ "./dist/plugins/multi-session/index.d.mts"
471
+ ],
442
472
  "plugins/organization": [
443
473
  "./dist/plugins/organization/index.d.mts"
444
474
  ],
@@ -473,13 +503,13 @@
473
503
  "kysely": "^0.28.17 || ^0.29.0",
474
504
  "nanostores": "^1.3.0",
475
505
  "zod": "^4.5.4",
476
- "@better-auth/core": "1.7.9",
477
- "@better-auth/drizzle-adapter": "1.7.9",
478
- "@better-auth/kysely-adapter": "1.7.9",
479
- "@better-auth/memory-adapter": "1.7.9",
480
- "@better-auth/mongo-adapter": "1.7.9",
481
- "@better-auth/prisma-adapter": "1.7.9",
482
- "@better-auth/telemetry": "1.7.9"
506
+ "@better-auth/core": "1.8.0-beta.0",
507
+ "@better-auth/drizzle-adapter": "1.8.0-beta.0",
508
+ "@better-auth/kysely-adapter": "1.8.0-beta.0",
509
+ "@better-auth/memory-adapter": "1.8.0-beta.0",
510
+ "@better-auth/mongo-adapter": "1.8.0-beta.0",
511
+ "@better-auth/prisma-adapter": "1.8.0-beta.0",
512
+ "@better-auth/telemetry": "1.8.0-beta.0"
483
513
  },
484
514
  "devDependencies": {
485
515
  "@lynx-js/react": "^0.121.2",