better-auth 1.7.15 → 1.7.16

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 (64) hide show
  1. package/dist/api/index.d.mts +2 -1
  2. package/dist/api/index.mjs +3 -2
  3. package/dist/api/routes/account.mjs +14 -6
  4. package/dist/api/routes/email-verification.mjs +29 -37
  5. package/dist/api/routes/sign-in.mjs +2 -2
  6. package/dist/api/routes/sign-up.mjs +1 -1
  7. package/dist/api/routes/update-user.mjs +10 -4
  8. package/dist/api/state/session-source.d.mts +10 -0
  9. package/dist/api/state/session-source.mjs +28 -0
  10. package/dist/auth/trusted-origins.mjs +31 -8
  11. package/dist/client/index.d.mts +2 -1
  12. package/dist/db/internal-adapter.mjs +24 -6
  13. package/dist/index.d.mts +2 -2
  14. package/dist/index.mjs +2 -2
  15. package/dist/oauth2/link-account.d.mts +3 -5
  16. package/dist/oauth2/link-account.mjs +33 -3
  17. package/dist/oauth2/state.d.mts +6 -5
  18. package/dist/package.mjs +1 -1
  19. package/dist/plugins/admin/access/statement.d.mts +10 -10
  20. package/dist/plugins/admin/access/statement.mjs +2 -0
  21. package/dist/plugins/admin/admin.d.mts +4 -3
  22. package/dist/plugins/admin/client.d.mts +5 -4
  23. package/dist/plugins/admin/error-codes.d.mts +1 -0
  24. package/dist/plugins/admin/error-codes.mjs +1 -0
  25. package/dist/plugins/admin/routes.mjs +8 -0
  26. package/dist/plugins/anonymous/index.mjs +2 -1
  27. package/dist/plugins/device-authorization/routes.mjs +3 -3
  28. package/dist/plugins/email-otp/routes.mjs +33 -50
  29. package/dist/plugins/generic-oauth/index.mjs +54 -30
  30. package/dist/plugins/index.d.mts +2 -1
  31. package/dist/plugins/index.mjs +2 -1
  32. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  33. package/dist/plugins/one-tap/index.mjs +1 -1
  34. package/dist/plugins/organization/adapter.mjs +1 -0
  35. package/dist/plugins/organization/client.d.mts +1 -0
  36. package/dist/plugins/organization/error-codes.d.mts +1 -0
  37. package/dist/plugins/organization/error-codes.mjs +1 -0
  38. package/dist/plugins/organization/has-permission.d.mts +2 -3
  39. package/dist/plugins/organization/has-permission.mjs +62 -23
  40. package/dist/plugins/organization/index.d.mts +2 -1
  41. package/dist/plugins/organization/index.mjs +2 -1
  42. package/dist/plugins/organization/organization.d.mts +2 -2
  43. package/dist/plugins/organization/organization.mjs +1 -3
  44. package/dist/plugins/organization/permission.mjs +3 -3
  45. package/dist/plugins/organization/roles.d.mts +8 -0
  46. package/dist/plugins/organization/roles.mjs +26 -0
  47. package/dist/plugins/organization/routes/crud-access-control.mjs +2 -1
  48. package/dist/plugins/organization/routes/crud-invites.mjs +22 -11
  49. package/dist/plugins/organization/routes/crud-members.mjs +10 -14
  50. package/dist/plugins/organization/routes/crud-org.mjs +12 -3
  51. package/dist/plugins/phone-number/client.d.mts +2 -0
  52. package/dist/plugins/phone-number/error-codes.d.mts +2 -0
  53. package/dist/plugins/phone-number/error-codes.mjs +2 -0
  54. package/dist/plugins/phone-number/index.d.mts +25 -1
  55. package/dist/plugins/phone-number/index.mjs +22 -6
  56. package/dist/plugins/phone-number/routes.mjs +34 -35
  57. package/dist/plugins/two-factor/index.d.mts +9 -2
  58. package/dist/plugins/two-factor/index.mjs +73 -26
  59. package/dist/plugins/two-factor/verify-two-factor.mjs +2 -2
  60. package/dist/plugins/username/index.mjs +1 -1
  61. package/dist/state.d.mts +6 -0
  62. package/dist/state.mjs +5 -2
  63. package/dist/utils/index.d.mts +2 -2
  64. package/package.json +9 -9
@@ -1,3 +1,4 @@
1
+ import { toBoolean } from "../../utils/boolean.mjs";
1
2
  import { PACKAGE_VERSION } from "../../version.mjs";
2
3
  import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs";
3
4
  import { auth0 } from "./providers/auth0.mjs";
@@ -31,6 +32,17 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 1e4;
31
32
  * plugin may replace `socialProviders`.
32
33
  */
33
34
  const getPendingDiscovery = (ctx) => ctx.genericOAuthPendingDiscovery;
35
+ /**
36
+ * Reads ID token claims, or null for a token that is not a JWS, so a
37
+ * malformed token fails the sign-in instead of throwing.
38
+ */
39
+ const readIdTokenClaims = (idToken) => {
40
+ try {
41
+ return decodeJwt(idToken);
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
34
46
  async function fetchDiscovery(url, headers, timeout) {
35
47
  const result = await betterFetch(url, {
36
48
  method: "GET",
@@ -45,17 +57,17 @@ async function fetchDiscovery(url, headers, timeout) {
45
57
  }
46
58
  return result.data;
47
59
  }
48
- async function fetchUserInfo(tokens, userInfoUrl) {
49
- if (tokens.idToken) try {
50
- const decoded = decodeJwt(tokens.idToken);
60
+ async function fetchUserInfo(tokens, userInfoUrl, idTokenClaims) {
61
+ if (tokens.idToken) {
62
+ const decoded = idTokenClaims ?? readIdTokenClaims(tokens.idToken);
51
63
  if (decoded?.sub && decoded?.email) return {
52
64
  id: decoded.sub,
53
- emailVerified: decoded.email_verified,
65
+ emailVerified: toBoolean(decoded.email_verified),
54
66
  image: decoded.picture,
55
67
  ...decoded
56
68
  };
57
- } catch {}
58
- if (!userInfoUrl) return null;
69
+ }
70
+ if (!userInfoUrl || !tokens.accessToken) return null;
59
71
  const userInfo = await betterFetch(userInfoUrl, {
60
72
  method: "GET",
61
73
  headers: { Authorization: `Bearer ${tokens.accessToken}` }
@@ -64,7 +76,7 @@ async function fetchUserInfo(tokens, userInfoUrl) {
64
76
  return {
65
77
  ...userInfo.data,
66
78
  email: userInfo.data.email,
67
- emailVerified: userInfo.data.email_verified ?? false,
79
+ emailVerified: toBoolean(userInfo.data.email_verified),
68
80
  image: userInfo.data.picture,
69
81
  name: userInfo.data.name
70
82
  };
@@ -140,6 +152,27 @@ const genericOAuth = (options) => {
140
152
  if (c.clientSecret && isSecretlessTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" cannot be combined with clientSecret`);
141
153
  if (!c.clientSecret && isClientSecretTokenEndpointAuth(tokenEndpointAuth)) throw new Error(`Provider "${c.providerId}": tokenEndpointAuth.method "${tokenEndpointAuth?.method}" requires clientSecret`);
142
154
  if (!c.clientSecret && !tokenEndpointAuth && c.authentication === "basic") throw new Error(`Provider "${c.providerId}": authentication "basic" requires clientSecret`);
155
+ const resolveUserInfo = async (tokens, idTokenClaims) => {
156
+ const raw = c.getUserInfo ? await c.getUserInfo(tokens) : await fetchUserInfo(tokens, userInfoUrl, idTokenClaims);
157
+ if (!raw) return null;
158
+ const requiresSubjectMatch = !c.getUserInfo || raw.sub != null;
159
+ if (idTokenClaims?.sub !== void 0 && requiresSubjectMatch && String(raw.sub) !== idTokenClaims.sub) return null;
160
+ const mapped = c.mapProfileToUser ? await c.mapProfileToUser(raw) : {};
161
+ const user = {
162
+ email: raw.email,
163
+ emailVerified: toBoolean(raw.emailVerified),
164
+ image: raw.image,
165
+ name: raw.name,
166
+ ...mapped
167
+ };
168
+ return {
169
+ user: {
170
+ ...user,
171
+ image: user.image ?? void 0
172
+ },
173
+ data: raw
174
+ };
175
+ };
143
176
  const accountSubject = c.accountSubject;
144
177
  const provider = {
145
178
  id: c.providerId,
@@ -151,7 +184,9 @@ const genericOAuth = (options) => {
151
184
  tokens,
152
185
  profile: genericProfile
153
186
  });
154
- return isOidc ? genericProfile.sub ?? "" : genericProfile.id ?? "";
187
+ if (!isOidc) return genericProfile.id ?? "";
188
+ if (idTokenConfig && tokens.idToken) return readIdTokenClaims(tokens.idToken)?.sub ?? "";
189
+ return genericProfile.sub ?? "";
155
190
  },
156
191
  idToken: idTokenConfig,
157
192
  requiresIdTokenNonce: idTokenConfig !== void 0 && c.disableIdTokenNonceBinding !== true,
@@ -225,31 +260,20 @@ const genericOAuth = (options) => {
225
260
  });
226
261
  return applyDefaultAccessTokenExpiry(tokens, c.accessTokenExpiresIn);
227
262
  },
263
+ async getUserInfoFromIdToken(idToken) {
264
+ const claims = provider.idToken ? readIdTokenClaims(idToken) : void 0;
265
+ if (claims === null) return null;
266
+ return resolveUserInfo({ idToken }, claims);
267
+ },
228
268
  async getUserInfo(tokens) {
229
269
  const { expectedIdTokenNonce, ...oauthTokens } = tokens;
230
- if (oauthTokens.idToken && provider.idToken) {
231
- if (!await verifyProviderIdToken(provider, oauthTokens.idToken, expectedIdTokenNonce)) {
232
- ctx.logger.error(`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`);
233
- return null;
234
- }
270
+ if (!oauthTokens.idToken || !provider.idToken) return resolveUserInfo(oauthTokens);
271
+ const claims = await verifyProviderIdToken(provider, oauthTokens.idToken, expectedIdTokenNonce) ? readIdTokenClaims(oauthTokens.idToken) : null;
272
+ if (!claims) {
273
+ ctx.logger.error(`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`);
274
+ return null;
235
275
  }
236
- const raw = c.getUserInfo ? await c.getUserInfo(oauthTokens) : await fetchUserInfo(oauthTokens, userInfoUrl);
237
- if (!raw) return null;
238
- const mapped = c.mapProfileToUser ? await c.mapProfileToUser(raw) : {};
239
- const user = {
240
- email: raw.email,
241
- emailVerified: raw.emailVerified,
242
- image: raw.image,
243
- name: raw.name,
244
- ...mapped
245
- };
246
- return {
247
- user: {
248
- ...user,
249
- image: user.image ?? void 0
250
- },
251
- data: raw
252
- };
276
+ return resolveUserInfo(oauthTokens, claims);
253
277
  },
254
278
  async refreshAccessToken(refreshToken, refreshCtx) {
255
279
  if (!tokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
@@ -67,6 +67,7 @@ import { twoFactor } from "./two-factor/index.mjs";
67
67
  import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
68
68
  import { UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, username } from "./username/index.mjs";
69
69
  import { hasPermission } from "./organization/has-permission.mjs";
70
- import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization/organization.mjs";
70
+ import { parseRoles } from "./organization/roles.mjs";
71
+ import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization } from "./organization/organization.mjs";
71
72
  import "./organization/index.mjs";
72
73
  export { AccessControl, type AdminOptions, type AnonymousOptions, type AnonymousSession, ArrayElement, type Auth0Options, AuthorizeResponse, type BackupCodeOptions, type BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, type BotIdVerification, type CaptchaFoxOptions, type CaptchaOptions, type CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, type DeviceCode, type DeviceCodeRedemptionAuthorization, type DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, type EmailOTPOptions, ExactRoleStatements, type FieldSchema, type GenericOAuthConfig, type GenericOAuthOptions, type GenericOAuthUserInfo, type GoogleRecaptchaOptions, type GumroadOptions, type HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, type HubSpotOptions, type InferAdminRolesFromOption, type InferInvitation, type InferMember, InferOptionSchema, type InferOrganization, type InferOrganizationRolesFromOption, type InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, type InferTeam, type Invitation, type InvitationInput, type InvitationStatus, type JWKOptions, type JWSAlgorithms, type Jwk, type JwtOptions, type KeycloakOptions, LastLoginMethodOptions, type LineOptions, type LoginResult, MagicLinkOptions, type Member, type MemberInput, type MicrosoftEntraIdOptions, MultiSessionConfig, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, type OAuthPopupData, type OAuthPopupMessage, OAuthProxyOptions, type OTPOptions, type OktaOptions, OneTapOptions, OneTimeTokenOptions, type OpenAPIModelSchema, OpenAPIOptions, type OpenAPIParameter, type OpenAPISchema, type Organization, OrganizationCreator, OrganizationEndpoints, type OrganizationInput, type OrganizationOptions, OrganizationPlugin, type OrganizationRole, type OrganizationSchema, POPUP_MARKER_COOKIE, type Path, type PatreonOptions, type PhoneNumberOptions, type Provider, type ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, type SessionWithImpersonatedBy, type SlackOptions, Statements, SubArray, Subset, type TOTPOptions, TWO_FACTOR_ERROR_CODES, type Team, TeamEndpoints, type TeamInput, type TeamMember, type TeamMemberInput, type TestAuthOptions, type TestCookie, type TestHelpers, type TestUtilsOptions, type TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, type UserWithAnonymous, type UserWithPhoneNumber, type UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, type ValidateRequestContext, type VercelBotIdOptions, type YandexOptions, admin, anonymous, auth0, type backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, type defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, type encodeBackupCodes, type generateBackupCodes, generateExportedKeyPair, type generator, genericOAuth, type getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, type invitationSchema, type invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, type memberSchema, microsoftEntraId, type ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, type organizationRoleSchema, type organizationSchema, type otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, type roleSchema, type sec, signJWT, siwe, slack, type teamMemberSchema, type teamSchema, testUtils, toExpJWT, type totp2fa, twoFactor, twoFactorClient, username, type verifyBackupCode, verifyJWT, yandex };
@@ -3,6 +3,7 @@ import { createAccessControl, role } from "./access/access.mjs";
3
3
  import { MULTI_SESSION_ERROR_CODES } from "./multi-session/error-codes.mjs";
4
4
  import { OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_MESSAGE_TYPE, POPUP_MARKER_COOKIE } from "./oauth-popup/constants.mjs";
5
5
  import { OAUTH_POPUP_ERROR_CODES } from "./oauth-popup/error-codes.mjs";
6
+ import { parseRoles } from "./organization/roles.mjs";
6
7
  import { TWO_FACTOR_ERROR_CODES } from "./two-factor/error-code.mjs";
7
8
  import { twoFactorClient } from "./two-factor/client.mjs";
8
9
  import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
@@ -40,7 +41,7 @@ import { oneTimeToken } from "./one-time-token/index.mjs";
40
41
  import { openAPI } from "./open-api/index.mjs";
41
42
  import { getOrgAdapter } from "./organization/adapter.mjs";
42
43
  import { hasPermission } from "./organization/has-permission.mjs";
43
- import { organization, parseRoles } from "./organization/organization.mjs";
44
+ import { organization } from "./organization/organization.mjs";
44
45
  import { phoneNumber } from "./phone-number/index.mjs";
45
46
  import { siwe } from "./siwe/index.mjs";
46
47
  import { testUtils } from "./test-utils/index.mjs";
@@ -4,10 +4,10 @@ import { originCheck } from "../../api/middlewares/origin-check.mjs";
4
4
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
5
5
  import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
6
6
  import { setSessionCookie } from "../../cookies/index.mjs";
7
+ import { derivePurposeKey } from "../../crypto/purpose.mjs";
7
8
  import { redirectOnError } from "../../oauth2/errors.mjs";
8
9
  import { handleOAuthUserInfo, linkOAuthAccount } from "../../oauth2/link-account.mjs";
9
10
  import { setOAuthState } from "../../api/state/oauth.mjs";
10
- import { derivePurposeKey } from "../../crypto/purpose.mjs";
11
11
  import { getAuthStateVerificationIdentifier, parseGenericState } from "../../state.mjs";
12
12
  import { exchangeOAuthCallback } from "../../oauth2/callback.mjs";
13
13
  import { PACKAGE_VERSION } from "../../version.mjs";
@@ -3,10 +3,10 @@ import { setSessionCookie } from "../../cookies/index.mjs";
3
3
  import { getAwaitableValue } from "../../context/helpers.mjs";
4
4
  import { toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
5
5
  import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/errors.mjs";
6
+ import { toBoolean } from "../../utils/boolean.mjs";
6
7
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
7
8
  import { APIError as APIError$1 } from "../../api/index.mjs";
8
9
  import { PACKAGE_VERSION } from "../../version.mjs";
9
- import { toBoolean } from "../../utils/boolean.mjs";
10
10
  import { BASE_ERROR_CODES } from "@better-auth/core/error";
11
11
  import { isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "@better-auth/core/social-providers";
12
12
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -735,6 +735,7 @@ const getOrgAdapter = (context, options) => {
735
735
  return await adapter.create({
736
736
  model: "invitation",
737
737
  data: {
738
+ ...context.options.advanced?.database?.generateId === void 0 && { id: context.generateId({ model: "invitation" }) },
738
739
  status: "pending",
739
740
  expiresAt,
740
741
  createdAt: /* @__PURE__ */ new Date(),
@@ -241,6 +241,7 @@ export declare const organizationClient: <CO extends OrganizationClientOptions>(
241
241
  EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION">;
242
242
  EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION">;
243
243
  YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION">;
244
+ YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS">;
244
245
  INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION: import("@better-auth/core/utils/error-codes").RawError<"INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION">;
245
246
  YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE">;
246
247
  FAILED_TO_RETRIEVE_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"FAILED_TO_RETRIEVE_INVITATION">;
@@ -25,6 +25,7 @@ export declare const ORGANIZATION_ERROR_CODES: {
25
25
  EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION">;
26
26
  EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION">;
27
27
  YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION">;
28
+ YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS">;
28
29
  INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION: import("@better-auth/core/utils/error-codes").RawError<"INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION">;
29
30
  YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE">;
30
31
  FAILED_TO_RETRIEVE_INVITATION: import("@better-auth/core/utils/error-codes").RawError<"FAILED_TO_RETRIEVE_INVITATION">;
@@ -26,6 +26,7 @@ const ORGANIZATION_ERROR_CODES = defineErrorCodes({
26
26
  EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION: "Email verification required before accepting or rejecting invitation",
27
27
  EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION: "Email verification required to view or list invitations for the session email",
28
28
  YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION: "You are not allowed to cancel this invitation",
29
+ YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS: "You are not allowed to list invitations for this organization",
29
30
  INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION: "Inviter is no longer a member of the organization",
30
31
  YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE: "You are not allowed to invite a user with this role",
31
32
  FAILED_TO_RETRIEVE_INVITATION: "Failed to retrieve invitation",
@@ -4,9 +4,8 @@ import { GenericEndpointContext } from "@better-auth/core";
4
4
  export declare const hasPermission: (input: {
5
5
  organizationId: string;
6
6
  /**
7
- * If true, will use the in-memory cache of the roles.
8
- * Keep in mind to use this in a stateless mindset, the purpose of this is to avoid unnecessary database calls when running multiple
9
- * hasPermission calls in a row.
7
+ * Reuse roles already loaded for this organization in the current request.
8
+ * The cache ends with the request.
10
9
  *
11
10
  * @default false
12
11
  */
@@ -1,34 +1,73 @@
1
1
  import { APIError } from "../../api/index.mjs";
2
2
  import { defaultRoles } from "./access/statement.mjs";
3
- import { cacheAllRoles, hasPermissionFn } from "./permission.mjs";
3
+ import { parseRoleList } from "./roles.mjs";
4
+ import { hasPermissionFn } from "./permission.mjs";
5
+ import { defineRequestState, hasRequestState } from "@better-auth/core/context";
4
6
  import * as z from "zod";
5
7
  //#region src/plugins/organization/has-permission.ts
6
8
  const rolePermissionsSchema = z.record(z.string(), z.array(z.string()));
9
+ /**
10
+ * Request-scoped role cache, isolated by adapter and organization options before
11
+ * organization id. Nested calls to another auth instance can share request state,
12
+ * but must never replace the caller's permission definitions.
13
+ */
14
+ const resolvedOrganizationRoles = defineRequestState(() => /* @__PURE__ */ new WeakMap());
15
+ async function getRequestScopedRolesCache(ctx, options) {
16
+ if (!await hasRequestState()) return;
17
+ const adapters = await resolvedOrganizationRoles.get();
18
+ const configurations = adapters.get(ctx.context.adapter) ?? /* @__PURE__ */ new WeakMap();
19
+ adapters.set(ctx.context.adapter, configurations);
20
+ const roles = configurations.get(options) ?? /* @__PURE__ */ new Map();
21
+ configurations.set(options, roles);
22
+ return roles;
23
+ }
7
24
  const hasPermission = async (input, ctx) => {
8
- let acRoles = { ...input.options.roles || defaultRoles };
9
- if (ctx && input.organizationId && input.options.dynamicAccessControl?.enabled && input.options.ac && !input.useMemoryCache) {
10
- const roles = await ctx.context.adapter.findMany({
11
- model: "organizationRole",
12
- where: [{
13
- field: "organizationId",
14
- value: input.organizationId
15
- }]
16
- });
17
- for (const { role, permission: permissionsString } of roles) {
18
- const permissions = JSON.parse(permissionsString);
19
- const result = rolePermissionsSchema.safeParse(permissions);
20
- if (!result.success) {
21
- ctx.context.logger.error("[hasPermission] Invalid permissions for role " + role, { permissions });
22
- throw new APIError("INTERNAL_SERVER_ERROR", { message: "Invalid permissions for role " + role });
23
- }
24
- const merged = { ...acRoles[role]?.statements };
25
- for (const [key, actions] of Object.entries(result.data)) merged[key] = [.../* @__PURE__ */ new Set([...merged[key] ?? [], ...actions])];
26
- acRoles[role] = input.options.ac.newRole(merged);
25
+ const acRoles = { ...input.options.roles || defaultRoles };
26
+ const ac = input.options.ac;
27
+ if (!ctx || !input.organizationId || !input.options.dynamicAccessControl?.enabled || !ac) return hasPermissionFn(input, acRoles);
28
+ const cache = await getRequestScopedRolesCache(ctx, input.options);
29
+ const cached = input.useMemoryCache ? cache?.get(input.organizationId) : void 0;
30
+ if (cached) return hasPermissionFn(input, cached);
31
+ const roles = await ctx.context.adapter.findMany({
32
+ model: "organizationRole",
33
+ where: [{
34
+ field: "organizationId",
35
+ value: input.organizationId
36
+ }]
37
+ });
38
+ for (const { role, permission: permissionsString } of roles) {
39
+ const permissions = JSON.parse(permissionsString);
40
+ const result = rolePermissionsSchema.safeParse(permissions);
41
+ if (!result.success) {
42
+ ctx.context.logger.error("[hasPermission] Invalid permissions for role " + role, { permissions });
43
+ throw new APIError("INTERNAL_SERVER_ERROR", { message: "Invalid permissions for role " + role });
27
44
  }
45
+ const merged = { ...acRoles[role]?.statements };
46
+ for (const [key, actions] of Object.entries(result.data)) merged[key] = [.../* @__PURE__ */ new Set([...merged[key] ?? [], ...actions])];
47
+ acRoles[role] = ac.newRole(merged);
28
48
  }
29
- if (input.useMemoryCache) acRoles = cacheAllRoles.get(input.organizationId) || acRoles;
30
- cacheAllRoles.set(input.organizationId, acRoles);
49
+ cache?.set(input.organizationId, acRoles);
31
50
  return hasPermissionFn(input, acRoles);
32
51
  };
52
+ /**
53
+ * Whether a member may see the organization's pending invitations.
54
+ *
55
+ * Invitation ids act as invitation links. Members who can create invitations
56
+ * can mint such links anyway; any other role, including one that may only
57
+ * cancel, must not see them.
58
+ */
59
+ const canViewInvitations = (input, ctx) => hasPermission({
60
+ ...input,
61
+ permissions: { invitation: ["create"] }
62
+ }, ctx);
63
+ /**
64
+ * Drops invitations that grant the creator role unless the member holds it,
65
+ * matching the rule that only the creator role can hand it out.
66
+ */
67
+ const withoutCreatorRoleInvitations = (invitations, input) => {
68
+ const creatorRole = input.options.creatorRole || "owner";
69
+ if (parseRoleList(input.role).includes(creatorRole)) return invitations;
70
+ return invitations.filter((invitation) => !parseRoleList(invitation.role).includes(creatorRole));
71
+ };
33
72
  //#endregion
34
- export { hasPermission };
73
+ export { canViewInvitations, hasPermission, withoutCreatorRoleInvitations };
@@ -2,5 +2,6 @@ import { OrganizationOptions } from "./types.mjs";
2
2
  import { InferInvitation, InferMember, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferTeam, Invitation, InvitationInput, InvitationStatus, Member, MemberInput, Organization, OrganizationInput, OrganizationRole, OrganizationSchema, Team, TeamInput, TeamMember, TeamMemberInput, defaultRolesSchema, invitationSchema, invitationStatus, memberSchema, organizationRoleSchema, organizationSchema, roleSchema, teamMemberSchema, teamSchema } from "./schema.mjs";
3
3
  import { getOrgAdapter } from "./adapter.mjs";
4
4
  import { hasPermission } from "./has-permission.mjs";
5
- import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization.mjs";
5
+ import { parseRoles } from "./roles.mjs";
6
+ import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization } from "./organization.mjs";
6
7
  export { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, type InferInvitation, type InferMember, type InferOrganization, type InferOrganizationRolesFromOption, type InferOrganizationZodRolesFromOption, type InferTeam, type Invitation, type InvitationInput, type InvitationStatus, type Member, type MemberInput, type Organization, OrganizationCreator, OrganizationEndpoints, type OrganizationInput, type OrganizationOptions, OrganizationPlugin, type OrganizationRole, type OrganizationSchema, type Team, TeamEndpoints, type TeamInput, type TeamMember, type TeamMemberInput, type defaultRolesSchema, getOrgAdapter, hasPermission, type invitationSchema, type invitationStatus, type memberSchema, organization, type organizationRoleSchema, type organizationSchema, parseRoles, type roleSchema, type teamMemberSchema, type teamSchema };
@@ -1,4 +1,5 @@
1
+ import { parseRoles } from "./roles.mjs";
1
2
  import { getOrgAdapter } from "./adapter.mjs";
2
3
  import { hasPermission } from "./has-permission.mjs";
3
- import { organization, parseRoles } from "./organization.mjs";
4
+ import { organization } from "./organization.mjs";
4
5
  export { getOrgAdapter, hasPermission, organization, parseRoles };
@@ -10,6 +10,7 @@ import { addMember, getActiveMember, getActiveMemberRole, leaveOrganization, lis
10
10
  import { checkOrganizationSlug, createOrganization, deleteOrganization, getFullOrganization, getOrganization, listOrganizations, setActiveOrganization, updateOrganization } from "./routes/crud-org.mjs";
11
11
  import { addTeamMember, createTeam, listOrganizationTeams, listTeamMembers, listUserTeams, removeTeam, removeTeamMember, setActiveTeam, updateTeam } from "./routes/crud-team.mjs";
12
12
  import { hasPermission } from "./has-permission.mjs";
13
+ import { parseRoles } from "./roles.mjs";
13
14
  import * as z from "zod";
14
15
  //#region src/plugins/organization/organization.d.ts
15
16
  declare module "@better-auth/core" {
@@ -51,7 +52,6 @@ export type DefaultOrganizationPlugin<Options extends OrganizationOptions> = {
51
52
  export interface OrganizationCreator {
52
53
  <Options extends OrganizationOptions>(options?: Options | undefined): DefaultOrganizationPlugin<Options>;
53
54
  }
54
- export declare function parseRoles(roles: string | string[]): string;
55
55
  export type DynamicAccessControlEndpoints<O extends OrganizationOptions> = {
56
56
  createOrgRole: ReturnType<typeof createOrgRole<O>>;
57
57
  deleteOrgRole: ReturnType<typeof deleteOrgRole<O>>;
@@ -417,4 +417,4 @@ export declare function organization<O extends OrganizationOptions & {
417
417
  };
418
418
  export declare function organization<O extends OrganizationOptions>(options?: O | undefined): DefaultOrganizationPlugin<O>;
419
419
  //#endregion
420
- export { type OrganizationOptions, hasPermission };
420
+ export { type OrganizationOptions, hasPermission, parseRoles };
@@ -2,6 +2,7 @@ import { getSessionFromCtx } from "../../api/routes/session.mjs";
2
2
  import { PACKAGE_VERSION } from "../../version.mjs";
3
3
  import { defaultRoles } from "./access/statement.mjs";
4
4
  import { ORGANIZATION_ERROR_CODES } from "./error-codes.mjs";
5
+ import { parseRoles } from "./roles.mjs";
5
6
  import { getOrgAdapter } from "./adapter.mjs";
6
7
  import { shimContext } from "../../utils/shim.mjs";
7
8
  import { orgSessionMiddleware } from "./call.mjs";
@@ -15,9 +16,6 @@ import { APIError } from "@better-auth/core/error";
15
16
  import { createAuthEndpoint } from "@better-auth/core/api";
16
17
  import * as z from "zod";
17
18
  //#region src/plugins/organization/organization.ts
18
- function parseRoles(roles) {
19
- return Array.isArray(roles) ? roles.join(",") : roles;
20
- }
21
19
  const createHasPermissionBodySchema = z.object({ organizationId: z.string().optional() }).and(z.xor([z.object({ permission: z.record(z.string(), z.array(z.string())) }), z.object({ permissions: z.record(z.string(), z.array(z.string())) })]));
22
20
  const createHasPermission = (options) => {
23
21
  return createAuthEndpoint("/organization/has-permission", {
@@ -1,7 +1,8 @@
1
+ import { parseRoleList } from "./roles.mjs";
1
2
  //#region src/plugins/organization/permission.ts
2
3
  const hasPermissionFn = (input, acRoles) => {
3
4
  if (!input.permissions) return false;
4
- const roles = input.role.split(",");
5
+ const roles = parseRoleList(input.role);
5
6
  const creatorRole = input.options.creatorRole || "owner";
6
7
  const isCreator = roles.includes(creatorRole);
7
8
  const allowCreatorsAllPermissions = input.allowCreatorAllPermissions || false;
@@ -9,6 +10,5 @@ const hasPermissionFn = (input, acRoles) => {
9
10
  for (const role of roles) if ((acRoles[role]?.authorize(input.permissions))?.success) return true;
10
11
  return false;
11
12
  };
12
- const cacheAllRoles = /* @__PURE__ */ new Map();
13
13
  //#endregion
14
- export { cacheAllRoles, hasPermissionFn };
14
+ export { hasPermissionFn };
@@ -0,0 +1,8 @@
1
+ //#region src/plugins/organization/roles.d.ts
2
+ /**
3
+ * Normalizes a role value into the comma separated string that is stored in
4
+ * the database. Whitespace around each role name is removed and empty entries
5
+ * are dropped.
6
+ */
7
+ export declare function parseRoles(roles: string | string[]): string;
8
+ //#endregion
@@ -0,0 +1,26 @@
1
+ //#region src/plugins/organization/roles.ts
2
+ /**
3
+ * Splits a role value into a list of trimmed, non-empty role names.
4
+ *
5
+ * Roles are stored as a comma separated string (`"admin,owner"`), and may be
6
+ * submitted either as that string or as an array. Any whitespace around a
7
+ * role name is removed so that `"admin, owner"`, `["admin", " owner"]` and
8
+ * `"admin,owner"` all resolve to the same roles.
9
+ *
10
+ * Every role comparison and every write of a role column must go through this
11
+ * helper (or `parseRoles`), so a padded role name can never be stored in a
12
+ * form that one check matches and another does not.
13
+ */
14
+ function parseRoleList(roles) {
15
+ return (Array.isArray(roles) ? roles : [roles]).flatMap((role) => role.split(",")).map((role) => role.trim()).filter(Boolean);
16
+ }
17
+ /**
18
+ * Normalizes a role value into the comma separated string that is stored in
19
+ * the database. Whitespace around each role name is removed and empty entries
20
+ * are dropped.
21
+ */
22
+ function parseRoles(roles) {
23
+ return parseRoleList(roles).join(",");
24
+ }
25
+ //#endregion
26
+ export { parseRoleList, parseRoles };
@@ -1,5 +1,6 @@
1
1
  import { toZodSchema } from "../../../db/to-zod.mjs";
2
2
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
3
+ import { parseRoleList } from "../roles.mjs";
3
4
  import { orgSessionMiddleware } from "../call.mjs";
4
5
  import { hasPermission } from "../has-permission.mjs";
5
6
  import { APIError } from "@better-auth/core/error";
@@ -260,7 +261,7 @@ const deleteOrgRole = (options) => {
260
261
  operator: "contains"
261
262
  }]
262
263
  })).find((member) => {
263
- return member.role.split(",").map((r) => r.trim()).includes(roleToDelete);
264
+ return parseRoleList(member.role).includes(roleToDelete);
264
265
  })) {
265
266
  ctx.context.logger.error(`[Dynamic Access Control] Cannot delete a role that is assigned to members.`, {
266
267
  role: existingRoleInDB.role,
@@ -4,10 +4,10 @@ import { toZodSchema } from "../../../db/to-zod.mjs";
4
4
  import { getSessionFromCtx } from "../../../api/routes/session.mjs";
5
5
  import { defaultRoles } from "../access/statement.mjs";
6
6
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
7
+ import { parseRoleList, parseRoles } from "../roles.mjs";
7
8
  import { getOrgAdapter } from "../adapter.mjs";
8
9
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
9
- import { hasPermission } from "../has-permission.mjs";
10
- import { parseRoles } from "../organization.mjs";
10
+ import { canViewInvitations, hasPermission, withoutCreatorRoleInvitations } from "../has-permission.mjs";
11
11
  import { runWithTransaction } from "@better-auth/core/context";
12
12
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
13
13
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -99,8 +99,9 @@ const createInvitation = (option) => {
99
99
  organizationId
100
100
  }, ctx)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION);
101
101
  const creatorRole = ctx.context.orgOptions.creatorRole || "owner";
102
- const roles = parseRoles(ctx.body.role);
103
- const rolesArray = roles.split(",").map((r) => r.trim()).filter(Boolean);
102
+ const rolesArray = parseRoleList(ctx.body.role);
103
+ if (rolesArray.length === 0) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND);
104
+ const roles = parseRoles(rolesArray);
104
105
  const defaults = Object.keys(defaultRoles);
105
106
  const customRoles = Object.keys(ctx.context.orgOptions.roles || {});
106
107
  const validStaticRoles = /* @__PURE__ */ new Set([...defaults, ...customRoles]);
@@ -122,7 +123,7 @@ const createInvitation = (option) => {
122
123
  if (stillInvalid.length > 0) throw new APIError("BAD_REQUEST", { message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND}: ${stillInvalid.join(", ")}` });
123
124
  } else throw new APIError("BAD_REQUEST", { message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND}: ${unknownRoles.join(", ")}` });
124
125
  }
125
- if (!member.role.split(",").map((r) => r.trim()).includes(creatorRole) && roles.split(",").includes(creatorRole)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE);
126
+ if (!parseRoleList(member.role).includes(creatorRole) && rolesArray.includes(creatorRole)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE);
126
127
  if (await adapter.findMemberByEmail({
127
128
  email,
128
129
  organizationId
@@ -134,8 +135,9 @@ const createInvitation = (option) => {
134
135
  if (alreadyInvited.length && !ctx.body.resend && !ctx.context.orgOptions.cancelPendingInvitationsOnReInvite) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION);
135
136
  const organization = await adapter.findOrganizationById(organizationId);
136
137
  if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
137
- if (alreadyInvited.length && ctx.body.resend) {
138
- const existingInvitation = alreadyInvited[0];
138
+ const existingInvitation = alreadyInvited[0];
139
+ if (existingInvitation && ctx.body.resend) {
140
+ if (!parseRoleList(member.role).includes(creatorRole) && parseRoleList(existingInvitation.role).includes(creatorRole)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE);
139
141
  const newExpiresAt = getDate(ctx.context.orgOptions.invitationExpiresIn || 172800, "sec");
140
142
  await ctx.context.adapter.update({
141
143
  model: "invitation",
@@ -327,7 +329,7 @@ const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-i
327
329
  const createdMember = await adapter.createMember({
328
330
  organizationId: acceptedI.organizationId,
329
331
  userId: session.user.id,
330
- role: acceptedI.role,
332
+ role: parseRoles(acceptedI.role),
331
333
  createdAt: /* @__PURE__ */ new Date()
332
334
  });
333
335
  await adapter.setActiveOrganization(session.session.token, acceptedI.organizationId, ctx);
@@ -536,12 +538,21 @@ const listInvitations = (options) => createAuthEndpoint("/organization/list-invi
536
538
  const orgId = ctx.query?.organizationId || session.session.activeOrganizationId;
537
539
  if (!orgId) throw APIError.fromStatus("BAD_REQUEST", { message: "Organization ID is required" });
538
540
  const adapter = getOrgAdapter(ctx.context, options);
539
- if (!await adapter.findMemberByOrgId({
541
+ const isMember = await adapter.findMemberByOrgId({
540
542
  userId: session.user.id,
541
543
  organizationId: orgId
542
- })) throw APIError.fromStatus("FORBIDDEN", { message: "You are not a member of this organization" });
544
+ });
545
+ if (!isMember) throw APIError.fromStatus("FORBIDDEN", { message: "You are not a member of this organization" });
546
+ if (!await canViewInvitations({
547
+ role: isMember.role,
548
+ options: ctx.context.orgOptions,
549
+ organizationId: orgId
550
+ }, ctx)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_INVITATIONS);
543
551
  const invitations = await adapter.listInvitations({ organizationId: orgId });
544
- return ctx.json(invitations);
552
+ return ctx.json(withoutCreatorRoleInvitations(invitations, {
553
+ role: isMember.role,
554
+ options: ctx.context.orgOptions
555
+ }));
545
556
  });
546
557
  /**
547
558
  * List all invitations a user has received