@protobase/server 0.68.6 → 0.69.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.
Files changed (46) hide show
  1. package/dist/better-auth/auth-settings.d.ts +155 -28
  2. package/dist/better-auth/auth-settings.js +3 -2
  3. package/dist/better-auth/connections/connection-account-hooks.js +62 -0
  4. package/dist/better-auth/connections/connection-events.d.ts +11 -0
  5. package/dist/better-auth/connections/connection-events.js +16 -0
  6. package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
  7. package/dist/better-auth/connections/connections-plugin.js +98 -0
  8. package/dist/better-auth/connections/declared-connections.d.ts +21 -0
  9. package/dist/better-auth/connections/declared-connections.js +54 -0
  10. package/dist/better-auth/connections/revoke-token.js +34 -0
  11. package/dist/better-auth/create-auth.d.ts +162 -17
  12. package/dist/better-auth/create-auth.js +59 -5
  13. package/dist/better-auth/refresh-refusals.js +43 -0
  14. package/dist/better-auth/staff-checks.js +1 -0
  15. package/dist/notifications/history-routes.js +1 -1
  16. package/dist/package.js +1 -1
  17. package/dist/personal-mail/linked-accounts.js +17 -13
  18. package/dist/types.d.ts +2 -0
  19. package/dist/webhooks/attempt.js +2 -2
  20. package/dist/webhooks/table/webhook-table.js +1 -1
  21. package/dist/workflows/connections/connection-accounts.js +18 -0
  22. package/dist/workflows/connections/connection-consent.js +49 -0
  23. package/dist/workflows/connections/connection-provider.js +10 -0
  24. package/dist/workflows/connections/connection-tokens.js +54 -0
  25. package/dist/workflows/connections/connection-triggers.js +37 -0
  26. package/dist/workflows/connections/connection-wiring.js +73 -0
  27. package/dist/workflows/connections/enabler-authority.js +35 -0
  28. package/dist/workflows/connections/enabler-mailbox.js +15 -0
  29. package/dist/workflows/connections/single-flight.js +17 -0
  30. package/dist/workflows/hooks/issue-webhook.js +3 -20
  31. package/dist/workflows/may-update-workflow.js +26 -0
  32. package/dist/workflows/mcp/webhook-tools.js +10 -3
  33. package/dist/workflows/save-hook.js +7 -2
  34. package/dist/workflows/schema-sql.js +3 -1
  35. package/dist/workflows/schema.js +1 -1
  36. package/dist/workflows/triggers/plugin-call.js +19 -9
  37. package/dist/workflows/triggers/poll-job.js +7 -7
  38. package/dist/workflows/triggers/trigger-jobs.js +23 -7
  39. package/dist/workflows/triggers/trigger-start.js +16 -5
  40. package/dist/workflows/triggers/trigger-sync.js +15 -10
  41. package/dist/workflows/workflow-engine.js +20 -5
  42. package/dist/workflows/workflow-types.d.ts +7 -3
  43. package/dist/workflows/workflow-types.js +12 -12
  44. package/dist/write-shared.js +1 -0
  45. package/package.json +6 -6
  46. package/dist/better-auth/end-replaced-session.d.ts +0 -17
@@ -0,0 +1,54 @@
1
+ import { connectionProviderId } from "../../workflows/connections/connection-provider.js";
2
+ import { isIP } from "node:net";
3
+ //#region src/better-auth/connections/declared-connections.ts
4
+ const clientOf = (value) => typeof value?.clientId === "string" && value.clientId !== "" && typeof value.clientSecret === "string" && value.clientSecret !== "" ? {
5
+ clientId: value.clientId,
6
+ clientSecret: value.clientSecret
7
+ } : void 0;
8
+ const literalHost = (url) => {
9
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
10
+ return host === "localhost" || isIP(host) !== 0;
11
+ };
12
+ /**
13
+ * The connections of an app's workflow plugins, each with its provider: the sign-in provider it names, whose client
14
+ * revokes its tokens, or one of its own, whose client `clients` gives by `<plugin>:<connection>`. Startup stops on a
15
+ * client no `oauth` connection declares, and on an endpoint by address unless `allowPrivateAddresses`.
16
+ */
17
+ const resolveConnections = (declared, clients, socialProviders, allowPrivateAddresses) => {
18
+ const keys = new Set(declared.filter(({ declaration }) => "oauth" in declaration).map(({ plugin, name }) => `${plugin}:${name}`));
19
+ const unknown = Object.keys(clients).find((key) => !keys.has(key));
20
+ if (unknown !== void 0) throw new Error(`auth.connections: "${unknown}" is no workflow plugin's connection that signs in through a provider of its own`);
21
+ return declared.map((connection) => {
22
+ const { plugin, name, declaration } = connection;
23
+ const key = `${plugin}:${name}`;
24
+ const providerId = connectionProviderId(plugin, name, declaration);
25
+ const urls = "signIn" in declaration ? [declaration.revocation_url] : [
26
+ declaration.oauth.authorization_url,
27
+ declaration.oauth.token_url,
28
+ declaration.oauth.user_info_url,
29
+ declaration.oauth.revocation_url
30
+ ];
31
+ if (!allowPrivateAddresses && urls.some((url) => url !== void 0 && literalHost(url))) throw new Error(`Workflow plugin "${plugin}": the connection "${name}" names a host by its address; give its provider's name, or allow private addresses for development (workflows.allowPrivateAddresses)`);
32
+ if ("signIn" in declaration) {
33
+ const client = clientOf(socialProviders[declaration.signIn]);
34
+ return {
35
+ ...connection,
36
+ key,
37
+ providerId,
38
+ ...client && { client },
39
+ ...declaration.revocation_url && { revocationUrl: declaration.revocation_url }
40
+ };
41
+ }
42
+ const client = clientOf(clients[key]);
43
+ if (Object.hasOwn(clients, key) && !client) throw new Error(`auth.connections: "${key}" needs its clientId and clientSecret`);
44
+ return {
45
+ ...connection,
46
+ key,
47
+ providerId,
48
+ ...client && { client },
49
+ ...declaration.oauth.revocation_url && { revocationUrl: declaration.oauth.revocation_url }
50
+ };
51
+ });
52
+ };
53
+ //#endregion
54
+ export { resolveConnections };
@@ -0,0 +1,34 @@
1
+ import { AddressRefusedError, pinnedRequest } from "../../webhooks/table/pinned-request.js";
2
+ //#region src/better-auth/connections/revoke-token.ts
3
+ /** The longest a provider gets to revoke a token: disconnecting does not wait on it any longer. */
4
+ const revokeTimeoutMs = 1e4;
5
+ const unreachable = (error) => error instanceof AddressRefusedError || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError" || typeof error.code === "string");
6
+ /**
7
+ * Revokes a token at its provider (RFC 7009), through the guarded request, which refuses a private address unless the
8
+ * app allows them: whether the provider took it. A provider out of reach or refusing is false, so disconnecting still
9
+ * deletes the token; nothing of the request or its answer is logged or returned, as both carry the token.
10
+ */
11
+ const revokeToken = async ({ url, client, token, hint, allowPrivateAddresses }) => {
12
+ const init = {
13
+ method: "POST",
14
+ headers: { "content-type": "application/x-www-form-urlencoded" },
15
+ body: new URLSearchParams({
16
+ token,
17
+ token_type_hint: hint,
18
+ ...client && {
19
+ client_id: client.clientId,
20
+ client_secret: client.clientSecret
21
+ }
22
+ }).toString(),
23
+ signal: AbortSignal.timeout(revokeTimeoutMs)
24
+ };
25
+ const response = await pinnedRequest(url, init, { allowPrivateAddresses }).catch((error) => {
26
+ if (unreachable(error)) return;
27
+ throw error;
28
+ });
29
+ if (!response) return false;
30
+ await response.body?.cancel();
31
+ return response.ok;
32
+ };
33
+ //#endregion
34
+ export { revokeToken };
@@ -5,12 +5,13 @@ import { StaffSignIn } from "./staff-log.js";
5
5
  import { Person } from "./organizations/people-endpoint.js";
6
6
  import { RoleDefinitions, RolesInput } from "./organizations/role-definitions.js";
7
7
  import { ConnectedApp } from "./mcp/connected-apps.js";
8
- import { ReplacingContext } from "./end-replaced-session.js";
9
8
  import { VersionedSecret } from "./secret-versions.js";
10
9
  import { UserDeletionListener } from "./user-deletion.js";
11
10
  import { OrganizationRole } from "./organizations/store.js";
12
11
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
13
12
  import { PersonalMailProvider } from "../personal-mail/personal-mail-provider.js";
13
+ import { ConnectionClient, DeclaredConnection, ResolvedConnection } from "./connections/declared-connections.js";
14
+ import { ConnectionListener } from "./connections/connection-events.js";
14
15
  import { McpAuthOptions } from "./mcp/mcp-plugins.js";
15
16
  import { OperatorProvider } from "./operator-provider.js";
16
17
  import { PlatformSignIn } from "./platform-sign-in.js";
@@ -117,8 +118,22 @@ export type CreateAuthOptions = {
117
118
  * `socialProviders` must have: Gmail through `google`. Needs `encryptOAuthTokens`. Default none.
118
119
  */
119
120
  personalMail?: PersonalMailProviderId[];
120
- /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` needs it. Default off. */
121
+ /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` and workflow connections need it. Default off. */
121
122
  encryptOAuthTokens?: boolean;
123
+ /**
124
+ * The OAuth client of each workflow plugin's connection that signs in through a provider of its own, by
125
+ * `<plugin>:<connection>`, such as `{ 'acme:account': { clientId, clientSecret } }`; its redirect URI is
126
+ * `{baseURL}/api/auth/callback/workflow-<plugin>-<connection>`. A connection through one of `socialProviders` needs none.
127
+ */
128
+ connections?: Record<string, ConnectionClient>;
129
+ /**
130
+ * The connections the app's workflow plugins declare, which people connect accounts through (`createAdmin` passes
131
+ * them from `workflows.plugins`), and whether their providers may be at private addresses, for development and tests.
132
+ */
133
+ workflowConnections?: {
134
+ declared: DeclaredConnection[];
135
+ allowPrivateAddresses?: boolean;
136
+ };
122
137
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
123
138
  onUserCreated?: UserCreatedHook;
124
139
  /**
@@ -299,6 +314,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
299
314
  account: {
300
315
  encryptOAuthTokens?: true | undefined;
301
316
  accountLinking: {
317
+ trustedProviders?: string[] | undefined;
302
318
  allowDifferentEmails: true;
303
319
  };
304
320
  fields: {
@@ -590,17 +606,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
590
606
  userId: string;
591
607
  expiresAt: Date;
592
608
  token: string;
593
- ipAddress?
594
- /**
595
- * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
596
- * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
597
- * people mostly never set one. Only while the policy leaves both on; default `password`.
598
- */
599
- /**
600
- * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
601
- * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
602
- * people mostly never set one. Only while the policy leaves both on; default `password`.
603
- */ : string | null | undefined;
609
+ ipAddress?: string | null | undefined;
604
610
  userAgent?: string | null | undefined;
605
611
  };
606
612
  };
@@ -3419,6 +3425,102 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
3419
3425
  INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
3420
3426
  TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
3421
3427
  };
3428
+ } | {
3429
+ id: "protobase-connection-providers";
3430
+ init: (ctx: import("better-auth").AuthContext) => {
3431
+ context: {
3432
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
3433
+ };
3434
+ };
3435
+ } | {
3436
+ id: "protobase-connections";
3437
+ endpoints: {
3438
+ listConnections: import("better-call").StrictEndpoint<string, {
3439
+ method: "GET";
3440
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
3441
+ session: {
3442
+ session: Record<string, any> & {
3443
+ id: string;
3444
+ createdAt: Date;
3445
+ updatedAt: Date;
3446
+ userId: string;
3447
+ expiresAt: Date;
3448
+ token: string;
3449
+ ipAddress?: string | null | undefined;
3450
+ userAgent?: string | null | undefined;
3451
+ };
3452
+ user: Record<string, any> & {
3453
+ id: string;
3454
+ createdAt: Date;
3455
+ updatedAt: Date;
3456
+ email: string;
3457
+ emailVerified: boolean;
3458
+ name: string;
3459
+ image?: string | null | undefined;
3460
+ };
3461
+ };
3462
+ }>>[];
3463
+ }, {
3464
+ connections: {
3465
+ key: string;
3466
+ label: string;
3467
+ provider: string;
3468
+ scopes: string[];
3469
+ link_params: Record<string, string>;
3470
+ accounts: {
3471
+ id: string;
3472
+ scopes: string[];
3473
+ created_at: Date;
3474
+ needs_sign_in: boolean;
3475
+ }[];
3476
+ }[];
3477
+ }>;
3478
+ disconnectConnection: import("better-call").StrictEndpoint<string, {
3479
+ method: "POST";
3480
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
3481
+ session: {
3482
+ session: Record<string, any> & {
3483
+ id: string;
3484
+ createdAt: Date;
3485
+ updatedAt: Date;
3486
+ userId: string;
3487
+ expiresAt: Date;
3488
+ token: string;
3489
+ ipAddress?: string | null | undefined;
3490
+ userAgent?: string | null | undefined;
3491
+ };
3492
+ user: Record<string, any> & {
3493
+ id: string;
3494
+ createdAt: Date;
3495
+ updatedAt: Date;
3496
+ email: string;
3497
+ emailVerified: boolean;
3498
+ name: string;
3499
+ image?: string | null | undefined;
3500
+ };
3501
+ };
3502
+ }>>[];
3503
+ body: import("zod").ZodObject<{
3504
+ accountId: import("zod").ZodString;
3505
+ }, import("zod/v4/core").$strip>;
3506
+ }, {
3507
+ disconnected: boolean;
3508
+ revoked: boolean;
3509
+ }>;
3510
+ };
3511
+ hooks: {
3512
+ before: {
3513
+ matcher: (context: import("better-auth").HookEndpointContext) => boolean;
3514
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
3515
+ }[];
3516
+ };
3517
+ } | {
3518
+ id: "protobase-refresh-refusals";
3519
+ init: (ctx: import("better-auth").AuthContext) => {
3520
+ context: {
3521
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
3522
+ };
3523
+ };
3422
3524
  } | {
3423
3525
  id: "sso";
3424
3526
  version: string;
@@ -8978,10 +9080,40 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8978
9080
  };
8979
9081
  };
8980
9082
  session: {
8981
- create: {
8982
- after: (session: {
8983
- token: string;
8984
- }, ctx: ReplacingContext | null) => Promise<void>;
9083
+ create?: {
9084
+ before?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void | {
9085
+ data: {
9086
+ id?: string | undefined;
9087
+ createdAt?: Date | undefined;
9088
+ updatedAt?: Date | undefined;
9089
+ userId?: string | undefined;
9090
+ expiresAt?: Date | undefined;
9091
+ token?: string | undefined;
9092
+ ipAddress?: string | null | undefined;
9093
+ userAgent?: string | null | undefined;
9094
+ } & Record<string, any>;
9095
+ }>;
9096
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
9097
+ };
9098
+ update?: {
9099
+ before?: (session: Partial<import("better-auth").Session> & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void | {
9100
+ data: {
9101
+ [x: string]: any;
9102
+ id?: string | undefined;
9103
+ createdAt?: Date | undefined;
9104
+ updatedAt?: Date | undefined;
9105
+ userId?: string | undefined;
9106
+ expiresAt?: Date | undefined;
9107
+ token?: string | undefined;
9108
+ ipAddress?: string | null | undefined;
9109
+ userAgent?: string | null | undefined;
9110
+ };
9111
+ }>;
9112
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
9113
+ };
9114
+ delete?: {
9115
+ before?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void>;
9116
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
8985
9117
  };
8986
9118
  };
8987
9119
  account: {
@@ -9201,6 +9333,19 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
9201
9333
  personalMail: PersonalMailProvider[];
9202
9334
  /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
9203
9335
  personalMailer: (userId: string) => Mailer;
9336
+ /** What workflow connections can sign in with: whether tokens are stored encrypted, the sign-in providers, the connections with a client. */
9337
+ connectionSupport: {
9338
+ encrypted: boolean;
9339
+ signInProviders: string[];
9340
+ clients: string[];
9341
+ };
9342
+ /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
9343
+ connections: {
9344
+ declared: ResolvedConnection[];
9345
+ events: {
9346
+ set: (next: ConnectionListener) => undefined;
9347
+ };
9348
+ };
9204
9349
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
9205
9350
  mcp: {
9206
9351
  resource: string;
@@ -5,10 +5,16 @@ import { defaultSignInPolicy } from "./sign-in-policy.js";
5
5
  import { roleDefinitions } from "./organizations/role-definitions.js";
6
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
7
7
  import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
8
+ import { refreshRefusalsPlugin } from "./refresh-refusals.js";
8
9
  import { betterAuthLinkedAccounts } from "../personal-mail/linked-accounts.js";
9
10
  import { resolvePersonalMail } from "../personal-mail/personal-mail-providers.js";
10
11
  import { personalMailer } from "../personal-mail/personal-mailer.js";
11
12
  import { mailLinkHooks } from "../personal-mail/mail-link-hooks.js";
13
+ import { bothAccountHooks, connectionAccountHooks } from "./connections/connection-account-hooks.js";
14
+ import { connectionEvents } from "./connections/connection-events.js";
15
+ import { connectionProvidersPlugin } from "./connections/connection-oauth-provider.js";
16
+ import { connectionsPlugin } from "./connections/connections-plugin.js";
17
+ import { resolveConnections } from "./connections/declared-connections.js";
12
18
  import { endReplacedSession } from "./end-replaced-session.js";
13
19
  import { authBasePath } from "./auth-base-path.js";
14
20
  import { authLogger } from "./auth-logger.js";
@@ -69,6 +75,20 @@ const withEndedReplaced = (hooks) => ({
69
75
  after: endReplacedSession
70
76
  }
71
77
  });
78
+ const refusingConnectionSessions = (hooks, refuse) => {
79
+ if (!refuse) return hooks;
80
+ const before = hooks.create?.before;
81
+ return {
82
+ ...hooks,
83
+ create: {
84
+ ...hooks.create,
85
+ before: async (session, context) => {
86
+ await refuse(session, context);
87
+ return before?.(session, context);
88
+ }
89
+ }
90
+ };
91
+ };
72
92
  const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
73
93
  const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
74
94
  const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
@@ -101,7 +121,9 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
101
121
  * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
102
122
  * sign-in provider already resolved.
103
123
  */
104
- const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], ...plugins }) => {
124
+ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], connections, ...plugins }) => {
125
+ const connectionHooks = connections && connections.resolved.length > 0 ? connectionAccountHooks(connections.resolved, connections.events) : void 0;
126
+ const ownProviders = (connections?.resolved ?? []).filter(({ declaration }) => "oauth" in declaration).map(({ providerId }) => providerId);
105
127
  const secure = options.baseURL.startsWith("https://");
106
128
  const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
107
129
  const hosts = passkeyHosts([options.baseURL, ...origins]);
@@ -201,7 +223,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
201
223
  verification: coreNames.verification,
202
224
  account: {
203
225
  ...coreNames.account,
204
- accountLinking: { allowDifferentEmails: true },
226
+ accountLinking: {
227
+ allowDifferentEmails: true,
228
+ ...ownProviders.length > 0 && { trustedProviders: ownProviders }
229
+ },
205
230
  ...options.encryptOAuthTokens && { encryptOAuthTokens: true }
206
231
  },
207
232
  plugins: [
@@ -238,6 +263,12 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
238
263
  defaultPolicy: signInPolicy
239
264
  })] : [],
240
265
  ...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
266
+ ...connections && connections.resolved.length > 0 ? [connectionProvidersPlugin(connections.resolved, { allowPrivateAddresses: connections.allowPrivateAddresses }), connectionsPlugin({
267
+ connections: connections.resolved,
268
+ events: connections.events,
269
+ allowPrivateAddresses: connections.allowPrivateAddresses
270
+ })] : [],
271
+ refreshRefusalsPlugin(),
241
272
  ...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
242
273
  signInPolicyPlugin(policy),
243
274
  passwordChecksPlugin(),
@@ -320,8 +351,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
320
351
  },
321
352
  update: { before: async (user) => ({ data: confirmedOnUpdate(user, /* @__PURE__ */ new Date()) }) }
322
353
  },
323
- session: withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {}),
324
- account: mailLinkHooks(resolvePersonalMail(options.personalMail ?? [], Object.keys(options.socialProviders ?? {}), options.encryptOAuthTokens === true))
354
+ session: refusingConnectionSessions(withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {}), connectionHooks?.session.create?.before),
355
+ account: ((mail) => connectionHooks ? bothAccountHooks(mail, connectionHooks.account) : mail)(mailLinkHooks(resolvePersonalMail(options.personalMail ?? [], Object.keys(options.socialProviders ?? {}), options.encryptOAuthTokens === true)))
325
356
  },
326
357
  rateLimit: {
327
358
  enabled: true,
@@ -408,6 +439,13 @@ const createAuth = (options, plugins = {}) => {
408
439
  const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
409
440
  const saml = resolveSamlProviders(options.sso?.saml ?? [], [...socialProviders, ...platformSignIn ? [platformSignIn.provider] : []]);
410
441
  const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders, options.encryptOAuthTokens === true);
442
+ if (Object.keys(options.connections ?? {}).length > 0 && options.encryptOAuthTokens !== true) throw new Error("auth.connections: the connected accounts' tokens would be stored in plain text; turn on auth.encryptOAuthTokens");
443
+ const allowPrivateAddresses = options.workflowConnections?.allowPrivateAddresses === true;
444
+ const resolvedConnections = resolveConnections(options.workflowConnections?.declared ?? [], options.connections ?? {}, options.socialProviders ?? {}, allowPrivateAddresses);
445
+ const clash = resolvedConnections.find(({ declaration, providerId }) => "oauth" in declaration && socialProviders.includes(providerId));
446
+ if (clash) throw new Error(`Workflow plugin "${clash.plugin}": its connection "${clash.name}" signs in as "${clash.providerId}", which is a sign-in provider's id too`);
447
+ const connectionClients = resolvedConnections.filter(({ declaration, client }) => "oauth" in declaration && client).map(({ key }) => key);
448
+ const events = connectionEvents();
411
449
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
412
450
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
413
451
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions);
@@ -436,7 +474,12 @@ const createAuth = (options, plugins = {}) => {
436
474
  signInPolicy,
437
475
  ...organizations && { organizations },
438
476
  ...mcp && { mcp },
439
- context
477
+ context,
478
+ connections: {
479
+ resolved: resolvedConnections,
480
+ events,
481
+ allowPrivateAddresses
482
+ }
440
483
  });
441
484
  assertNoEndpointKeyClashes(authOptions.plugins);
442
485
  const auth = betterAuth(authOptions);
@@ -503,6 +546,17 @@ const createAuth = (options, plugins = {}) => {
503
546
  personalMail,
504
547
  /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
505
548
  personalMailer: (userId) => personalMailer(betterAuthLinkedAccounts(auth), personalMail, userId),
549
+ /** What workflow connections can sign in with: whether tokens are stored encrypted, the sign-in providers, the connections with a client. */
550
+ connectionSupport: {
551
+ encrypted: options.encryptOAuthTokens === true,
552
+ signInProviders: socialProviders,
553
+ clients: connectionClients
554
+ },
555
+ /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
556
+ connections: {
557
+ declared: resolvedConnections,
558
+ events: { set: events.set }
559
+ },
506
560
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
507
561
  mcp: mcp && {
508
562
  resource: mcp.resource,
@@ -0,0 +1,43 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/better-auth/refresh-refusals.ts
3
+ /**
4
+ * The token endpoint's error (RFC 6749, section 5.2) that refuses the refresh token itself, as for a revoked grant or
5
+ * an expired refresh token: the link no longer signs in, and only connecting it again helps.
6
+ */
7
+ const refusal = "invalid_grant";
8
+ /** Whether a provider's refresh threw for a refused grant: Better Auth's own fetch's error, or a connection provider's. */
9
+ const refreshRefused = (error) => typeof error === "object" && error !== null && error.error === refusal;
10
+ const watched = new AsyncLocalStorage();
11
+ /** Runs `work` and tells whether a provider refused a refresh token in it, whether `work` resolved or threw. */
12
+ const watchRefusals = async (work) => {
13
+ const outcome = { refused: false };
14
+ return {
15
+ ...await watched.run(outcome, () => work().then((value) => ({ value }), (error) => ({ error }))),
16
+ refused: outcome.refused
17
+ };
18
+ };
19
+ const marking = (provider) => {
20
+ const refresh = provider.refreshAccessToken;
21
+ if (!refresh) return provider;
22
+ return {
23
+ ...provider,
24
+ refreshAccessToken: async (...args) => {
25
+ try {
26
+ return await refresh(...args);
27
+ } catch (error) {
28
+ if (refreshRefused(error)) {
29
+ const outcome = watched.getStore();
30
+ if (outcome) outcome.refused = true;
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ };
36
+ };
37
+ /** Has every provider's refresh mark a refused grant for `watchRefusals`; after the plugins that add providers. */
38
+ const refreshRefusalsPlugin = () => ({
39
+ id: "protobase-refresh-refusals",
40
+ init: (ctx) => ({ context: { socialProviders: ctx.socialProviders.map(marking) } })
41
+ });
42
+ //#endregion
43
+ export { refreshRefusalsPlugin, refreshRefused, watchRefusals };
@@ -46,6 +46,7 @@ const accountChanges = [
46
46
  "/delete-user",
47
47
  "/link-social",
48
48
  "/unlink-account",
49
+ "/connections/disconnect",
49
50
  "/revoke-sessions",
50
51
  "/revoke-other-sessions",
51
52
  "/two-factor/enable",
@@ -1,7 +1,7 @@
1
1
  import { badRequest } from "../problem.js";
2
+ import { ownSignInUser } from "./caller.js";
2
3
  import { historyIdPattern } from "./history.js";
3
4
  import "./history-store.js";
4
- import { ownSignInUser } from "./caller.js";
5
5
  import { historyCursor, historyPosition } from "./history-cursor.js";
6
6
  import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
7
7
  import { historyUrl } from "./history-url.js";
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.68.6";
2
+ var version = "0.69.0";
3
3
  //#endregion
4
4
  export { version };
@@ -1,3 +1,4 @@
1
+ import { watchRefusals } from "../better-auth/refresh-refusals.js";
1
2
  import { PersonalMailError } from "./personal-mail-error.js";
2
3
  import { APIError } from "better-auth/api";
3
4
  //#region src/personal-mail/linked-accounts.ts
@@ -5,7 +6,7 @@ const storedScopes = (scope) => (scope ?? "").split(/[\s,]+/).filter(Boolean);
5
6
  /**
6
7
  * The accounts Better Auth keeps, with the tokens it stores, decrypted when `encryptOAuthTokens` is on. Its
7
8
  * `getAccessToken` refreshes a token within five seconds of expiry with the provider's refresh token and stores the new
8
- * one; when the provider refuses the refresh, as for a revoked grant, that is `link_expired`.
9
+ * one; when the provider refuses the refresh token, as for a revoked grant, that is `link_expired`.
9
10
  */
10
11
  const betterAuthLinkedAccounts = (auth) => ({
11
12
  accounts: async (userId) => {
@@ -17,21 +18,24 @@ const betterAuthLinkedAccounts = (auth) => ({
17
18
  }));
18
19
  },
19
20
  accessToken: async (userId, account, provider) => {
20
- const accessToken = await validAccessToken(auth, userId, account.id, provider);
21
+ const accessToken = await accountAccessToken(auth, userId, account.id);
21
22
  if (!accessToken) throw new PersonalMailError("link_expired", provider);
22
23
  return accessToken;
23
24
  }
24
25
  });
25
- const validAccessToken = async (auth, userId, accountId, provider) => {
26
- try {
27
- return (await auth.api.getAccessToken({ body: {
28
- accountId,
29
- userId
30
- } })).accessToken;
31
- } catch (error) {
32
- if (error instanceof APIError && error.body?.code === "FAILED_TO_GET_ACCESS_TOKEN") throw new PersonalMailError("link_expired", provider);
33
- throw error;
34
- }
26
+ /**
27
+ * A valid access token of the account, refreshed first when it expired; undefined when the provider refused the refresh
28
+ * token (`invalid_grant`) or the account holds none. A refresh that failed otherwise, as when the provider was down,
29
+ * throws, so the caller tries again later rather than asking the person to connect the account again.
30
+ */
31
+ const accountAccessToken = async (auth, userId, accountId) => {
32
+ const answer = await watchRefusals(() => auth.api.getAccessToken({ body: {
33
+ accountId,
34
+ userId
35
+ } }));
36
+ if ("value" in answer) return answer.value.accessToken || void 0;
37
+ if (answer.refused && answer.error instanceof APIError && answer.error.body?.code === "FAILED_TO_GET_ACCESS_TOKEN") return;
38
+ throw answer.error;
35
39
  };
36
40
  //#endregion
37
- export { betterAuthLinkedAccounts, storedScopes };
41
+ export { accountAccessToken, betterAuthLinkedAccounts, storedScopes };
package/dist/types.d.ts CHANGED
@@ -76,6 +76,8 @@ export type WriteEvent = {
76
76
  tenant?: TenantValue;
77
77
  /** The writer is a visitor without a token, through a public form. */
78
78
  anonymous?: true;
79
+ /** The writer's session as the API resolved it, for a hook that allows a write only for someone signed in as themselves (`ownSignInUser`). */
80
+ session: Session;
79
81
  before?: Row;
80
82
  after?: Row;
81
83
  };
@@ -1,9 +1,9 @@
1
+ import { AddressRefusedError, pinnedRequest } from "./table/pinned-request.js";
2
+ import { readLimited } from "./table/read-limited.js";
1
3
  import { invokeFunction } from "../functions/define-function.js";
2
4
  import { retryAfterMs } from "./retry-schedule.js";
3
5
  import { standardWebhookHeaders } from "./standard-webhooks.js";
4
6
  import { subscriberSession } from "./subscriber-view.js";
5
- import { AddressRefusedError, pinnedRequest } from "./table/pinned-request.js";
6
- import { readLimited } from "./table/read-limited.js";
7
7
  //#region src/webhooks/attempt.ts
8
8
  /** How long a receiver has to answer. */
9
9
  const attemptTimeoutMs = 15e3;
@@ -1,5 +1,5 @@
1
- import { betterAuthOwnerRoles } from "./owner-roles.js";
2
1
  import { checkWebhookUrl } from "./address-check.js";
2
+ import { betterAuthOwnerRoles } from "./owner-roles.js";
3
3
  import { webhooksSchema } from "./options.js";
4
4
  import { tableSubscriptions } from "./table-subscriptions.js";
5
5
  //#region src/webhooks/table/webhook-table.ts
@@ -0,0 +1,18 @@
1
+ import { accountAccessToken, storedScopes } from "../../personal-mail/linked-accounts.js";
2
+ //#region src/workflows/connections/connection-accounts.ts
3
+ /** The accounts Better Auth keeps, read through its internal adapter, with its refreshing `getAccessToken`. */
4
+ const betterAuthConnectionAccounts = (auth) => ({
5
+ find: async (userId, accountId) => {
6
+ const account = (await (await auth.$context).internalAdapter.findAccounts(userId)).find((candidate) => candidate.id === accountId);
7
+ if (!account) return;
8
+ return {
9
+ id: account.id,
10
+ providerId: account.providerId,
11
+ scopes: storedScopes(account.scope),
12
+ refreshToken: account.refreshToken ?? null
13
+ };
14
+ },
15
+ accessToken: (userId, accountId) => accountAccessToken(auth, userId, accountId)
16
+ });
17
+ //#endregion
18
+ export { betterAuthConnectionAccounts };