diff --git a/.changeset/provider-scoped-account-identity.md b/.changeset/provider-scoped-account-identity.md new file mode 100644 index 0000000000..ea16f175e4 --- /dev/null +++ b/.changeset/provider-scoped-account-identity.md @@ -0,0 +1,7 @@ +--- +"@better-auth/core": patch +"@better-auth/sso": patch +"better-auth": patch +--- + +Preserve the Better Auth 1.6 `(providerId, accountId)` account identity by default. This compatibility path does not add or persist an issuer. Applications can opt into issuer-scoped identity with `account.identityStrategy: "issuer"`; the schema, runtime account lookup, and public types then use `(issuer, accountId)`. diff --git a/demo/nextjs/lib/auth.ts b/demo/nextjs/lib/auth.ts index 0bd3b1a81e..f68fbcc50f 100644 --- a/demo/nextjs/lib/auth.ts +++ b/demo/nextjs/lib/auth.ts @@ -129,6 +129,7 @@ const authOptions = { }, }, account: { + identityStrategy: "issuer", accountLinking: { trustedProviders: [ "email-password", diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index c0e18c96c0..a72a4bc8d1 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -412,7 +412,7 @@ export const sessionTableFields = [ Table Name: `account` -An account represents one authentication method linked to a user. Better Auth recognizes the provider-side identity by the unique pair of `issuer` and `accountId`, while `id` identifies the local account row. Use `id` when an account API asks for an `accountId`. +An account represents one authentication method linked to a user. By default, Better Auth recognizes the provider-side identity by the unique pair of `providerId` and `accountId`, while `id` identifies the local account row. Use `id` when an account API asks for an `accountId`. export const accountTableFields = [ { @@ -432,12 +432,13 @@ export const accountTableFields = [ name: "issuer", type: "string", description: - "The trusted authority that issued the provider account identifier", + "The trusted authority that issued the provider account identifier. Available when account.identityStrategy is set to issuer", + isOptional: true, }, { name: "accountId", type: "string", - description: "The stable account identifier within the issuer namespace", + description: "The stable account identifier within the provider namespace", }, { name: "providerId", @@ -501,7 +502,7 @@ export const accountTableFields = [ -The database enforces a unique compound index on `issuer` and `accountId`. Providers with a trusted issuer use that authority, including the verified issuer for OpenID Connect. OAuth providers without an issuer use the synthetic `local:oauth:` namespace, with the provider ID segment percent-encoded. Credential accounts use `local:credential` and the linked user's stable `id`. +The database enforces a unique compound index on `providerId` and `accountId`. Set [`account.identityStrategy`](/docs/reference/options#account) to `"issuer"` to use issuer-scoped identity instead. That option adds the `issuer` field and changes the compound index to `issuer` and `accountId`. ### Verification diff --git a/docs/content/docs/concepts/oauth.mdx b/docs/content/docs/concepts/oauth.mdx index a7383c8923..6303ebcd41 100644 --- a/docs/content/docs/concepts/oauth.mdx +++ b/docs/content/docs/concepts/oauth.mdx @@ -464,7 +464,7 @@ export const auth = betterAuth({ * **Microsoft Entra ID**: because `email` is tenant-mutable and never verified, use `profile.oid` (immutable, stable within the tenant) as the identity anchor; treat `email` as a profile attribute only. Microsoft's [claims validation guidance](https://learn.microsoft.com/en-us/entra/identity-platform/claims-validation) explicitly warns never to use `email`, `preferred_username`, or `unique_name` for authorization decisions. * **Facebook**: without a per-email verification flag, treat every Facebook email as unverified unless you run your own verification challenge. -Better Auth recognizes a returning OAuth account by its stable `(issuer, accountId)` key, but the linked Better Auth user still requires an email address. Support for users without an email is tracked in [#9124](https://github.com/better-auth/better-auth/issues/9124). +Better Auth recognizes a returning OAuth account by `(providerId, accountId)` by default, or by `(issuer, accountId)` when issuer-scoped identity is enabled. The linked Better Auth user still requires an email address. Support for users without an email is tracked in [#9124](https://github.com/better-auth/better-auth/issues/9124). ## Provider Options diff --git a/docs/content/docs/concepts/users-accounts.mdx b/docs/content/docs/concepts/users-accounts.mdx index 39f5e99e47..1e97242f6f 100644 --- a/docs/content/docs/concepts/users-accounts.mdx +++ b/docs/content/docs/concepts/users-accounts.mdx @@ -400,11 +400,13 @@ export const auth = betterAuth({ Better Auth supports multiple authentication methods through providers such as email and password, Google, or an enterprise identity provider. Each method linked to a user is stored as an account. -An account has a local record ID and a provider identity. `id` identifies the Better Auth account record and is the value to pass as `accountId` to account-management APIs. The pair of `issuer` and `accountId` identifies the external account: `issuer` names the trusted authority, and `accountId` is the stable identifier that authority assigned. The `providerId` identifies the provider configuration Better Auth uses for protocol operations. +An account has a local record ID and a provider identity. `id` identifies the Better Auth account record and is the value to pass as `accountId` to account-management APIs. By default, `(providerId, accountId)` identifies the provider account, matching Better Auth 1.6. `providerId` identifies the configured provider, and `accountId` is the stable identifier that provider assigned. -OAuth providers without a trusted issuer use `local:oauth:` as their account namespace, with the provider ID segment percent-encoded. Credential accounts use `local:credential`; do not use that credential namespace for an OAuth provider. +Set `account.identityStrategy` to `"issuer"` to identify accounts by `(issuer, accountId)` instead. In that mode, `issuer` names the trusted authority. OAuth providers without a trusted issuer use `local:oauth:`, with the provider ID segment percent-encoded, while credential accounts use `local:credential`. -This separation allows multiple provider configurations for the same issuer to deduplicate the same external identity without treating an identifier from another issuer as the same person. Provider aliases share one account row and token set; they do not have independent grants or provider lifecycle records. See the [account schema](/docs/concepts/database#account) for the complete set of fields. +Issuer-scoped identity allows multiple provider configurations for the same issuer to deduplicate the same external identity without treating an identifier from another issuer as the same person. Provider aliases then share one account row and token set; they do not have independent grants or provider lifecycle records. See the [account schema](/docs/concepts/database#account) for the complete set of fields. + +The 1.7 default is `"provider-id"`, and this path does not add or persist an `issuer` field. Keep the selected strategy consistent across every application instance and database migration. If a database already contains issuer-scoped account rows, configure `"issuer"` explicitly before starting 1.7. See the [1.7 upgrade guide](/docs/guides/1-7-upgrade-guide#preserve-16-provider-scoped-account-identity) before changing the strategy on existing data. ### List User Accounts diff --git a/docs/content/docs/guides/1-7-upgrade-guide.mdx b/docs/content/docs/guides/1-7-upgrade-guide.mdx index dc2d0d1aa0..466eb590c9 100644 --- a/docs/content/docs/guides/1-7-upgrade-guide.mdx +++ b/docs/content/docs/guides/1-7-upgrade-guide.mdx @@ -42,19 +42,19 @@ These features change the schema: | Authorization-code replay | An indexed `authorizationCodeId` column on both token tables | No | | Back-channel logout | Logout-URL and revoked columns | No | | Requested user-info claims | A requested-claims column on the token and consent tables | No | -| Account identity | An issuer column and a compound index with the existing provider-account column | **Yes, backfill** | +| Account identity | No schema change by default; an issuer column and compound index when issuer-scoped identity is enabled | **Only when opting into issuer-scoped identity** | | SCIM | Seven provisioning models, plus three optional managed-catalog models, with layouts that replace the legacy SCIM models | **Yes, reprovision** | | Organization team counters | `team.memberCount` and `teamMember.membershipKey` columns | No | | Provider client store | `oauthApplication` becomes `oauthClient`, plus new token tables | **Yes, move client data** | | Device Authorization | Unique indexes on `deviceCode` and `userCode`; the optional OAuth grant also adds `oauthClientId` and `resources` | **Yes, deduplicate; MySQL/SQL Server also bound strings** | - Prepare account identities, OAuth clients, and SCIM data before applying the generated 1.7 schema. The CLI adds tables, columns, and indexes, but it does not choose issuers, copy OAuth clients, or convert legacy SCIM data. + Do not apply an issuer-scoped schema over a populated 1.6 `account` table before backfilling trusted issuers and resolving collisions. OAuth client and SCIM changes also require their manual data steps. Use this order: -1. Complete the manual preparation in [Account identity](#account-identity-is-scoped-by-issuer), [OAuth client records](#migrate-oauth-client-records), [SCIM](#scim-requires-full-reprovisioning), and [Device Authorization](#device-authorization) when those sections apply. +1. Complete the manual preparation in [Account identity](#choose-account-identity-strategy), [OAuth client records](#migrate-oauth-client-records), [SCIM](#scim-requires-full-reprovisioning), and [Device Authorization](#device-authorization) when those sections apply. 2. Apply the 1.7 schema. With the built-in Kysely adapter, run `npx auth migrate`. With Drizzle, Prisma, or a custom schema workflow, run `npx auth generate`, review the output, and apply it with your migration tooling. 3. Deploy the 1.7 packages and configuration changes together. 4. Complete any post-deployment work, including SCIM reprovisioning. @@ -65,9 +65,41 @@ Use this order: This covers email and password, social login, the generic OAuth plugin, One Tap, and consuming SSO. -### Account identity is scoped by issuer +### Choose account identity strategy -Better Auth now recognizes an external account by the unique pair of `issuer` and `accountId`. The `providerId` remains the local provider configuration, while `account.id` identifies the Better Auth account row and `account.accountId` remains the stable identifier assigned by the provider. The account schema adds the required `issuer` field and creates a unique compound index across both identity fields without renaming `accountId`. +#### Preserve 1.6 provider-scoped account identity + +Better Auth 1.7 preserves the 1.6 account identity by default. When `account.identityStrategy` is omitted, an external account remains unique by `providerId` and `accountId`, and the account schema does not add or persist an issuer. You can also make this compatibility policy explicit: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + account: { + identityStrategy: "provider-id", + }, + // ... +}); +``` + +Use the same strategy on every application instance that reads or writes the database. Renaming a provider changes its account namespace, and 2 provider aliases for the same OpenID Connect issuer remain separate identities, matching the provider-scoped behavior of 1.6. + +#### Adopt issuer-scoped account identity + +Set `account.identityStrategy` to `"issuer"` when you deliberately want an external account to be unique by `issuer` and `accountId`: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + account: { + identityStrategy: "issuer", + }, + // ... +}); +``` + +The issuer-scoped account schema adds the required `issuer` field and creates a unique compound index across `issuer` and `accountId`. The `providerId` remains the local provider configuration, while `account.id` identifies the Better Auth account row and `account.accountId` remains the stable identifier assigned by the provider. Use a maintenance window and stop authentication writes, including background jobs and admin APIs that insert into `account` directly, before changing the account schema. The generated migration cannot choose trusted issuers or resolve identity collisions for you. @@ -102,12 +134,12 @@ Better Auth now recognizes an external account by the unique pair of `issuer` an 3. Populate both fields according to the account type: | Account type | `issuer` | `accountId` | - | -------------------------------------- | ------------------------------------------ | ---------------------------------------- | - | Credential | `local:credential` | Stable `id` from the linked `user` row | - | SIWE wallet, `providerId` `siwe` | `local:siwe` | Existing `
:` value | - | Google One Tap, `providerId` `google` | `https://accounts.google.com` | Existing Google `sub` | - | Provider with an issuer | Exact trusted issuer used by the provider | Existing provider account identifier | - | OAuth provider without an issuer | `local:oauth:` | Existing provider account identifier | + | ------------------------------------- | ----------------------------------------- | -------------------------------------- | + | Credential | `local:credential` | Stable `id` from the linked `user` row | + | SIWE wallet, `providerId` `siwe` | `local:siwe` | Existing `
:` value | + | Google One Tap, `providerId` `google` | `https://accounts.google.com` | Existing Google `sub` | + | Provider with an issuer | Exact trusted issuer used by the provider | Existing provider account identifier | + | OAuth provider without an issuer | `local:oauth:` | Existing provider account identifier | The synthetic issuer percent-encodes its provider ID segment exactly as `encodeURIComponent(providerId)`; for example, `local:oauth:github` and `local:oauth:team%2Fgithub`. Build an explicit `providerId`-to-issuer map for your deployment. Multiple provider configurations that represent the same OpenID Connect authority must use the same issuer. Do not derive an issuer from email, display name, an unverified request value, or a mutable authorization endpoint. SIWE and Google One Tap are not OAuth providers, so the synthetic `local:oauth:` namespace never applies to them. 4. Find collisions before creating the unique index. Adapt this query to your physical table and field names: diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index be673d7ddd..d8a213c7ad 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -91,7 +91,7 @@ A redirect URL will be automatically generated using the provider ID. For instan When you register an OIDC provider, Better Auth automatically fetches and validates the IdP's [OIDC discovery document](#oidc-discovery). Most endpoint fields are optional — see [OIDC Discovery](#oidc-discovery) for details on auto-discovered fields and possible registration errors. -Better Auth identifies an OIDC account by the configured `issuer` and the verified `sub` claim. The `mapping` option maps profile fields such as email, name, and image, but it cannot replace the account subject. Provider aliases that return the same issuer and subject resolve to one external identity; this identity deduplication does not create independent grant or provider lifecycle records for each alias. +Better Auth identifies an OIDC account by the provider configuration and verified `sub` claim by default. With `account.identityStrategy: "issuer"`, it uses the configured `issuer` and verified `sub` instead. The `mapping` option maps profile fields such as email, name, and image, but it cannot replace the account subject. Under issuer-scoped identity, provider aliases that return the same issuer and subject resolve to one external identity; this deduplication does not create independent grant or provider lifecycle records for each alias. #### Example @@ -373,10 +373,17 @@ const auth = betterAuth({ await validateSAMLAttributes(input.providerAttributes); } + const accountIdentity = "issuer" in input.accountKey + ? { + issuer: input.accountKey.issuer, + subject: input.accountKey.accountId, + } + : { + providerId: input.accountKey.providerId, + subject: input.accountKey.accountId, + }; const provisionedUser = await findProvisionedUserByAccountKey({ - providerId: input.providerId, - issuer: input.accountKey.issuer, - subject: input.accountKey.accountId, + ...accountIdentity, database: context.database, }); @@ -398,11 +405,11 @@ const auth = betterAuth({ }); ``` -For OIDC, `accountKey.issuer` and `accountKey.accountId` come from the validated ID Token's exact `iss` and `sub` claims. `verifiedIdTokenClaims` contains the complete verified ID Token payload, while `providerClaims` contains the protocol-accepted raw claims used for the profile. For SAML, the account key is the verified IdP entity ID and signed `NameID`; `providerAttributes` contains the verified assertion attributes without coercing multi-valued attributes into scalars. Subject and `NameID` matching is case-sensitive. +`accountKey` follows the configured account identity strategy. The default form contains `providerId` and `accountId`; the issuer-scoped form contains `issuer` and `accountId`. For OIDC, the issuer-scoped values come from the validated ID Token's exact `iss` and `sub` claims. For SAML, they come from the verified identity provider entity ID and signed `NameID`. `verifiedIdTokenClaims` contains the complete verified ID Token payload, while `providerClaims` and `providerAttributes` contain the protocol-accepted claims used for the profile. Subject and `NameID` matching is case-sensitive. `providerUser` contains normalized profile fields for either protocol. Profile fields, OIDC UserInfo claims, and SAML attributes may still require application-specific validation before they are used for authorization. `providerReference` is an opaque reference to the provider configuration accepted for this authentication flow. It detects provider replacement or authentication-configuration changes during the flow; do not persist it as a tenant or user binding. -`findProvisionedUserByAccountKey` is application-owned in this example. It should resolve the exact provider, issuer, and subject tuple through the transaction-scoped `database` adapter. Do not fall back to matching an email address when the exact provisioned subject is absent. +`findProvisionedUserByAccountKey` is application-owned in this example. It should resolve the exact provider-and-subject or issuer-and-subject tuple through the transaction-scoped `database` adapter. Do not fall back to matching an email address when the exact provisioned subject is absent. The resolver returns one of three decisions: diff --git a/docs/content/docs/reference/errors/account_already_linked_to_different_user.mdx b/docs/content/docs/reference/errors/account_already_linked_to_different_user.mdx index 29aa8c1d73..268c6cafed 100644 --- a/docs/content/docs/reference/errors/account_already_linked_to_different_user.mdx +++ b/docs/content/docs/reference/errors/account_already_linked_to_different_user.mdx @@ -31,8 +31,7 @@ not triggered by email/password flows on their own. * Test/preview environments share the same OAuth provider configuration and database; the provider account is already linked to a different user record. * Data migration or manual database edits left a stale link pointing to the wrong user. -* You rely on email matching to decide linking, but the actual unique key is `issuer` and - `accountId`. If that identity belongs to another user, linking will be blocked. +* You rely on email matching to decide linking, but the actual unique key is `(providerId, accountId)` by default or `(issuer, accountId)` under issuer-scoped identity. If that identity belongs to another user, linking will be blocked. ### Safer patterns and prevention @@ -44,9 +43,7 @@ not triggered by email/password flows on their own. ### Debug locally -* Inspect your `account` database table. Each row points to a `userId` and is uniquely keyed by - `issuer` and `accountId`. For OpenID Connect providers, these values correspond to the - verified issuer and stable subject. +* Inspect your `account` database table. Each row points to a `userId` and is uniquely keyed by `providerId` and `accountId` by default. Under issuer-scoped identity, the key uses the verified issuer and stable subject instead. * Identify which user currently owns the provider link and decide whether to unlink, merge, or keep as-is. * Verify your app is connected to the expected database and environment (dev/staging/prod) to avoid confusion due to shared credentials or misconfigured environment variables. diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 91615e42ef..55556c4f85 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -472,6 +472,7 @@ import { betterAuth } from "better-auth"; export const auth = betterAuth({ account: { modelName: "accounts", + identityStrategy: "provider-id", fields: { userId: "user_id" }, @@ -490,6 +491,10 @@ export const auth = betterAuth({ * `modelName`: The model name for the account * `fields`: Map fields to different column names +### `identityStrategy` + +Controls the database key Better Auth uses to recognize a provider account. The default `"provider-id"` strategy uses `(providerId, accountId)` and does not add or persist an `issuer` field. Set `"issuer"` to use `(issuer, accountId)` and include the required issuer field and compound index in generated schemas. Keep the value consistent across every instance connected to the database, and follow the [1.7 upgrade guide](/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy) before changing it on existing data. + ### `encryptOAuthTokens` Encrypt OAuth tokens before storing them in the database. Default: `false`. diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index b36fc09930..ea8512043d 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -215,7 +215,7 @@ describe("account", async () => { (account) => account.id === storedAccount.id, ); assert(googleAccount, "google account should be listed"); - expect(googleAccount.issuer).toBe("https://accounts.google.com"); + expect(googleAccount).not.toHaveProperty("issuer"); expect(googleAccount.accountId).toBe("local-account-selector-subject"); expect(googleAccount.id).not.toBe(googleAccount.accountId); @@ -328,7 +328,6 @@ describe("account", async () => { account: { id: googleAccount.id, providerId: googleAccount.providerId, - issuer: googleAccount.issuer, accountId: googleAccount.accountId, }, data: expect.any(Object), @@ -505,6 +504,7 @@ describe("account", async () => { }, }, account: { + identityStrategy: "issuer", accountLinking: { allowDifferentEmails: true, }, @@ -932,6 +932,7 @@ describe("account", async () => { }, }, account: { + identityStrategy: "issuer", accountLinking: { allowDifferentEmails: true, }, @@ -2778,7 +2779,11 @@ describe("account resolution in stateless mode", async () => { refreshCache: { updateAge: 60 * 60 }, }, }, - account: { storeStateStrategy: "cookie", storeAccountCookie: true }, + account: { + identityStrategy: "issuer", + storeStateStrategy: "cookie", + storeAccountCookie: true, + }, plugins: [ genericOAuth({ config: [ diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index 597274a86f..8499170fee 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -96,7 +96,6 @@ export const listUserAccounts = createAuthEndpoint( "providerId", "createdAt", "updatedAt", - "issuer", "accountId", "userId", "scopes", @@ -327,6 +326,7 @@ export const linkSocialAccount = createAuthEndpoint( refreshToken: c.body.idToken.refreshToken, }, linkingUserInfo.data, + c.context.options.account?.identityStrategy, ); const linkedAccount = @@ -998,7 +998,7 @@ export const accountInfo = createAuthEndpoint( issuer: { type: "string" }, accountId: { type: "string" }, }, - required: ["id", "providerId", "issuer", "accountId"], + required: ["id", "providerId", "accountId"], additionalProperties: false, }, data: { diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index 4aec78d01e..d386bf1604 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -239,6 +239,7 @@ export const callbackOAuth = createAuthEndpoint( provider, tokens, providerResult.data, + c.context.options.account?.identityStrategy, ); } catch (error) { c.context.logger.error("Unable to derive provider account identity", { diff --git a/packages/better-auth/src/api/routes/password.test.ts b/packages/better-auth/src/api/routes/password.test.ts index 3d8b9ca59d..09ded9afd1 100644 --- a/packages/better-auth/src/api/routes/password.test.ts +++ b/packages/better-auth/src/api/routes/password.test.ts @@ -163,9 +163,9 @@ describe("forgot password", async () => { expect(updatedAccounts.length).toBe(1); expect(updatedAccounts[0]).toMatchObject({ providerId: "credential", - issuer: "local:credential", accountId: userId, }); + expect(updatedAccounts[0]?.issuer).toBeUndefined(); const newUpdatedAt = updatedAccounts[0]!.updatedAt; // Verify updatedAt was refreshed diff --git a/packages/better-auth/src/api/routes/password.ts b/packages/better-auth/src/api/routes/password.ts index 08f4cad220..854bcf33d6 100644 --- a/packages/better-auth/src/api/routes/password.ts +++ b/packages/better-auth/src/api/routes/password.ts @@ -1,6 +1,7 @@ import type { AuthContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { generateId } from "@better-auth/core/utils/id"; import * as z from "zod"; @@ -310,9 +311,14 @@ export const resetPassword = createAuthEndpoint( if (!account) { await ctx.context.internalAdapter.createAccount({ userId, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: user.id, + }, + ).fields, password: hashedPassword, }); } else { diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index f7f593c501..d4d9c68ebd 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import type { User } from "@better-auth/core/db"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { additionalAuthorizationParamsSchema, @@ -323,6 +324,7 @@ export const signInSocial = () => provider, oauthTokens, userInfo.data, + c.context.options.account?.identityStrategy, ); const providerProfile = toOAuthProfileRecord(userInfo.data); const data = await handleOAuthUserInfo(c, { @@ -529,12 +531,20 @@ export const signInEmail = () => email.toLowerCase(), { includeAccounts: true }, ); - const credentialIssuer = createLocalAccountIssuer("credential"); + const credentialKey = resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: userRecord?.user.id ?? "", + }, + ).key; const credentialAccount = userRecord?.accounts.find( (account) => - account.providerId === "credential" && - account.issuer === credentialIssuer && - account.accountId === userRecord.user.id, + account.accountId === userRecord.user.id && + (credentialKey.issuer !== undefined + ? account.issuer === credentialKey.issuer + : account.providerId === credentialKey.providerId), ); if (!userRecord || !credentialAccount) { diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts index d4c17d286f..d265146bad 100644 --- a/packages/better-auth/src/api/routes/sign-up.ts +++ b/packages/better-auth/src/api/routes/sign-up.ts @@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { runWithTransaction } from "@better-auth/core/context"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { isDevelopment } from "@better-auth/core/env"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { generateId } from "@better-auth/core/utils/id"; @@ -386,9 +387,14 @@ export const signUpEmail = () => } await ctx.context.internalAdapter.linkAccount({ userId: createdUser.id, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: createdUser.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: createdUser.id, + }, + ).fields, password: hash, }); const shouldSendVerificationEmail = diff --git a/packages/better-auth/src/api/routes/update-user.test.ts b/packages/better-auth/src/api/routes/update-user.test.ts index 7d1149e975..49093a6679 100644 --- a/packages/better-auth/src/api/routes/update-user.test.ts +++ b/packages/better-auth/src/api/routes/update-user.test.ts @@ -1093,9 +1093,9 @@ describe("credential identity across email changes", async () => { expect(accountsBefore).toHaveLength(1); expect(accountsBefore[0]).toMatchObject({ providerId: "credential", - issuer: "local:credential", accountId: userId, }); + expect(accountsBefore[0]?.issuer).toBeUndefined(); const changeEmail = await client.changeEmail({ newEmail: changedEmail, @@ -1122,9 +1122,9 @@ describe("credential identity across email changes", async () => { expect(accountsAfter[0]).toMatchObject({ id: accountsBefore[0]!.id, providerId: "credential", - issuer: "local:credential", accountId: userId, }); + expect(accountsAfter[0]?.issuer).toBeUndefined(); }); }); diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index 3ab7a0c083..e5bb7716b5 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -1,6 +1,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { deleteSessionCookie, setSessionCookie } from "../../cookies"; @@ -348,9 +349,14 @@ export const setPassword = createAuthEndpoint.serverOnly( if (!account) { await ctx.context.internalAdapter.linkAccount({ userId: session.user.id, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: session.user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: session.user.id, + }, + ).fields, password: passwordHash, }); return ctx.json({ diff --git a/packages/better-auth/src/db/internal-adapter.test.ts b/packages/better-auth/src/db/internal-adapter.test.ts index 51737e15ba..ea255148d1 100644 --- a/packages/better-auth/src/db/internal-adapter.test.ts +++ b/packages/better-auth/src/db/internal-adapter.test.ts @@ -195,7 +195,6 @@ describe("internal adapter test", async () => { id: "2", userId: expect.any(String), providerId: "provider", - issuer: "local:provider", accountId: "account", accessToken: null, refreshToken: null, @@ -924,7 +923,7 @@ describe("internal adapter test", async () => { }); let foundAccount = await internalAdapter.findAccountByKey({ - issuer: account.issuer, + providerId: account.providerId, accountId: account.accountId, }); expect(foundAccount).toBeDefined(); @@ -932,21 +931,28 @@ describe("internal adapter test", async () => { await internalAdapter.deleteAccount(account.id); foundAccount = await internalAdapter.findAccountByKey({ - issuer: account.issuer, + providerId: account.providerId, accountId: account.accountId, }); expect(foundAccount).toBeNull(); }); it("finds an account owner by the exact account key", async () => { - const firstUser = await internalAdapter.createUser( + const issuerOptions = { + ...opts, + account: { identityStrategy: "issuer" as const }, + database: new DatabaseSync(":memory:"), + }; + await (await getMigrations(issuerOptions)).runMigrations(); + const issuerInternalAdapter = (await init(issuerOptions)).internalAdapter; + const firstUser = await issuerInternalAdapter.createUser( { name: "First Account Key User", email: "first.account.key@example.com", }, { method: "test" }, ); - const secondUser = await internalAdapter.createUser( + const secondUser = await issuerInternalAdapter.createUser( { name: "Second Account Key User", email: "second.account.key@example.com", @@ -954,20 +960,20 @@ describe("internal adapter test", async () => { { method: "test" }, ); const accountId = "shared-provider-subject"; - const firstAccount = await internalAdapter.createAccount({ + const firstAccount = await issuerInternalAdapter.createAccount({ userId: firstUser.id, providerId: "first-provider-configuration", issuer: "https://first-issuer.example.com", accountId, }); - const secondAccount = await internalAdapter.createAccount({ + const secondAccount = await issuerInternalAdapter.createAccount({ userId: secondUser.id, providerId: "second-provider-configuration", issuer: "https://second-issuer.example.com", accountId, }); await expect( - internalAdapter.createAccount({ + issuerInternalAdapter.createAccount({ userId: secondUser.id, providerId: "another-provider-configuration", issuer: firstAccount.issuer, @@ -975,12 +981,12 @@ describe("internal adapter test", async () => { }), ).rejects.toThrow(); - await internalAdapter.updateAccount(firstAccount.id, { + await issuerInternalAdapter.updateAccount(firstAccount.id, { providerId: "renamed-provider-configuration", }); await expect( - internalAdapter.findAccountByKey({ + issuerInternalAdapter.findAccountByKey({ issuer: firstAccount.issuer, accountId, }), @@ -989,7 +995,7 @@ describe("internal adapter test", async () => { providerId: "renamed-provider-configuration", }); await expect( - internalAdapter.findAccountOwnerByKey({ + issuerInternalAdapter.findAccountOwnerByKey({ issuer: secondAccount.issuer, accountId, }), @@ -1002,7 +1008,7 @@ describe("internal adapter test", async () => { }, }); await expect( - internalAdapter.findAccountOwnerByKey({ + issuerInternalAdapter.findAccountOwnerByKey({ issuer: "https://missing-issuer.example.com", accountId, }), @@ -1027,7 +1033,7 @@ describe("internal adapter test", async () => { await expect( internalAdapter.findAccountOwnerByKey({ - issuer: account.issuer, + providerId: account.providerId, accountId: account.accountId, }), ).resolves.toMatchObject({ diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 0dc914c258..1d042d0db0 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -12,8 +12,10 @@ import { queueAfterTransactionHook, runWithTransaction, } from "@better-auth/core/context"; +import type { AccountKey } from "@better-auth/core/db"; import { createLocalAccountIssuer } from "@better-auth/core/db"; import type { DBAdapter, Where } from "@better-auth/core/db/adapter"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import type { InternalLogger } from "@better-auth/core/env"; import { APIError, BetterAuthError } from "@better-auth/core/error"; import { generateId } from "@better-auth/core/utils/id"; @@ -50,6 +52,18 @@ type ActiveSessionReference = { readonly expiresAt: number; }; +function getAccountKeyWhere(accountKey: AccountKey): Where[] { + return accountKey.issuer !== undefined + ? [ + { field: "issuer", value: accountKey.issuer }, + { field: "accountId", value: accountKey.accountId }, + ] + : [ + { field: "providerId", value: accountKey.providerId }, + { field: "accountId", value: accountKey.accountId }, + ]; +} + export const createInternalAdapter = ( adapter: DBAdapter, ctx: { @@ -999,21 +1013,12 @@ export const createInternalAdapter = ( undefined, ); }, - findAccountOwnerByKey: async ({ issuer, accountId }) => { + findAccountOwnerByKey: async (accountKey) => { const accountWithUser = await (await getCurrentAdapter(adapter)).findOne< Account & { user: User | null } >({ model: "account", - where: [ - { - field: "issuer", - value: issuer, - }, - { - field: "accountId", - value: accountId, - }, - ], + where: getAccountKeyWhere(accountKey), join: { user: true, }, @@ -1136,6 +1141,14 @@ export const createInternalAdapter = ( return user; }, updatePassword: async (userId: string, password: string) => { + const accountKey = resolveAccountIdentity( + options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: userId, + }, + ).key; await updateManyWithHooks( { password, @@ -1145,18 +1158,7 @@ export const createInternalAdapter = ( field: "userId", value: userId, }, - { - field: "providerId", - value: "credential", - }, - { - field: "issuer", - value: createLocalAccountIssuer("credential"), - }, - { - field: "accountId", - value: userId, - }, + ...getAccountKeyWhere(accountKey), ], "account", undefined, @@ -1177,33 +1179,26 @@ export const createInternalAdapter = ( return accounts; }, findCredentialAccount: async (userId: string) => { + const accountKey: AccountKey = + options.account?.identityStrategy === "issuer" + ? { + issuer: createLocalAccountIssuer("credential"), + accountId: userId, + } + : { providerId: "credential", accountId: userId }; return (await getCurrentAdapter(adapter)).findOne({ model: "account", where: [ { field: "userId", value: userId }, - { field: "providerId", value: "credential" }, - { - field: "issuer", - value: createLocalAccountIssuer("credential"), - }, - { field: "accountId", value: userId }, + ...getAccountKeyWhere(accountKey), ], }); }, - findAccountByKey: async ({ issuer, accountId }) => { + findAccountByKey: async (accountKey) => { const account = await (await getCurrentAdapter(adapter)).findOne( { model: "account", - where: [ - { - field: "issuer", - value: issuer, - }, - { - field: "accountId", - value: accountId, - }, - ], + where: getAccountKeyWhere(accountKey), }, ); return account; diff --git a/packages/better-auth/src/oauth2/account-key.test.ts b/packages/better-auth/src/oauth2/account-key.test.ts index 871e2869d7..9126169df7 100644 --- a/packages/better-auth/src/oauth2/account-key.test.ts +++ b/packages/better-auth/src/oauth2/account-key.test.ts @@ -22,13 +22,22 @@ const result = { }; describe("resolveOAuthAccountKey", () => { - it("uses a synthetic issuer for a provider without an issuer", async () => { + it("uses the provider ID by default without resolving an issuer", async () => { + const accountIssuer = vi.fn(() => { + throw new Error("issuer should not be evaluated"); + }); + await expect( - resolveOAuthAccountKey(createProvider(), tokens, result.data), + resolveOAuthAccountKey( + createProvider({ accountIssuer }), + tokens, + result.data, + ), ).resolves.toEqual({ - issuer: "local:oauth:company-oauth", + providerId: "company-oauth", accountId: "provider-subject", }); + expect(accountIssuer).not.toHaveBeenCalled(); }); it.each([ @@ -42,7 +51,7 @@ describe("resolveOAuthAccountKey", () => { result.data, ), ).resolves.toEqual({ - issuer: "local:oauth:company-oauth", + providerId: "company-oauth", accountId, }); }); @@ -53,6 +62,7 @@ describe("resolveOAuthAccountKey", () => { createProvider({ accountIssuer: "https://idp.example.com" }), tokens, result.data, + "issuer", ), ).resolves.toEqual({ issuer: "https://idp.example.com", @@ -71,6 +81,7 @@ describe("resolveOAuthAccountKey", () => { createProvider({ accountIssuer }), tokens, result.data, + "issuer", ), ).resolves.toEqual({ issuer: "https://login.example.com/acme", @@ -82,6 +93,25 @@ describe("resolveOAuthAccountKey", () => { }); }); + it("uses the provider ID without evaluating a dynamic issuer when selected explicitly", async () => { + const accountIssuer = vi.fn(() => { + throw new Error("issuer should not be evaluated"); + }); + + await expect( + resolveOAuthAccountKey( + createProvider({ accountIssuer }), + tokens, + result.data, + "provider-id", + ), + ).resolves.toEqual({ + providerId: "company-oauth", + accountId: "provider-subject", + }); + expect(accountIssuer).not.toHaveBeenCalled(); + }); + it("produces the same identity key for provider aliases of one issuer", async () => { const web = createProvider({ id: "company-web", @@ -93,9 +123,9 @@ describe("resolveOAuthAccountKey", () => { }); await expect( - resolveOAuthAccountKey(web, tokens, result.data), + resolveOAuthAccountKey(web, tokens, result.data, "issuer"), ).resolves.toEqual( - await resolveOAuthAccountKey(mobile, tokens, result.data), + await resolveOAuthAccountKey(mobile, tokens, result.data, "issuer"), ); }); @@ -139,6 +169,7 @@ describe("resolveOAuthAccountKey", () => { createProvider({ accountIssuer: " " }), tokens, result.data, + "issuer", ), ).rejects.toThrow("OAUTH_ACCOUNT_ISSUER_INVALID"); }); @@ -157,6 +188,7 @@ describe("resolveOAuthAccountKey", () => { }), tokens, result.data, + "issuer", ), ).rejects.toThrow("OAUTH_ACCOUNT_ISSUER_INVALID"); }); diff --git a/packages/better-auth/src/oauth2/account-key.ts b/packages/better-auth/src/oauth2/account-key.ts index 842e41422e..465033b9c6 100644 --- a/packages/better-auth/src/oauth2/account-key.ts +++ b/packages/better-auth/src/oauth2/account-key.ts @@ -1,3 +1,4 @@ +import type { BetterAuthOptions } from "@better-auth/core"; import type { AccountKey } from "@better-auth/core/db"; import { createOAuthAccountIssuer } from "@better-auth/core/db"; import { @@ -7,6 +8,10 @@ import { } from "@better-auth/core/error"; import type { OAuth2Tokens, OAuthProvider } from "@better-auth/core/oauth2"; +type AccountIdentityStrategy = NonNullable< + BetterAuthOptions["account"] +>["identityStrategy"]; + /** * Exposes a provider-declared profile as the raw claim record used by * provisioning hooks. Provider profile interfaces are object-shaped but do @@ -23,6 +28,7 @@ export async function resolveOAuthAccountKey( provider: OAuthProvider, tokens: OAuth2Tokens, profile: Profile, + identityStrategy?: AccountIdentityStrategy, ): Promise { const accountKeyContext = { tokens, profile }; const accountSubject = provider.accountSubject; @@ -38,6 +44,10 @@ export async function resolveOAuthAccountKey( throw new BetterAuthError("OAUTH_ACCOUNT_SUBJECT_INVALID"); } + if (identityStrategy !== "issuer") { + return { providerId: provider.id, accountId }; + } + const accountIssuer = provider.accountIssuer; const issuer = accountIssuer === undefined @@ -68,9 +78,15 @@ export async function resolveOAuthAccountKeyForAPI( provider: OAuthProvider, tokens: OAuth2Tokens, profile: Profile, + identityStrategy?: AccountIdentityStrategy, ): Promise { try { - return await resolveOAuthAccountKey(provider, tokens, profile); + return await resolveOAuthAccountKey( + provider, + tokens, + profile, + identityStrategy, + ); } catch { throw APIError.from( "UNAUTHORIZED", diff --git a/packages/better-auth/src/oauth2/link-account.test.ts b/packages/better-auth/src/oauth2/link-account.test.ts index 58abd775b5..693e624d02 100644 --- a/packages/better-auth/src/oauth2/link-account.test.ts +++ b/packages/better-auth/src/oauth2/link-account.test.ts @@ -1738,6 +1738,7 @@ describe("oauth2 - link-social uses issuer-scoped account lookup", async () => { enabled: true, }, account: { + identityStrategy: "issuer", accountLinking: { enabled: true, trustedProviders: ["google", "github"], diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 4f16bf0c00..00f2899f4d 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -6,6 +6,7 @@ import { queueAfterTransactionHook, runWithTransaction, } from "@better-auth/core/context"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { isDevelopment } from "@better-auth/core/env"; import { APIError } from "@better-auth/core/error"; import { createEmailVerificationToken } from "../api"; @@ -41,8 +42,30 @@ export async function handleOAuthUserInfo( requireExactAccountBinding?: boolean | undefined; }, ) { - const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = - opts; + const { + userInfo, + account: inputAccount, + callbackURL, + disableSignUp, + overrideUserInfo, + } = opts; + const { issuer: inputIssuer, ...accountWithoutIssuer } = inputAccount; + const resolvedIdentity = resolveAccountIdentity( + c.context.options.account?.identityStrategy, + { + providerId: inputAccount.providerId, + accountId: inputAccount.accountId, + issuer: inputIssuer, + }, + ); + const account = { ...accountWithoutIssuer, ...resolvedIdentity.fields }; + const accountKey = resolvedIdentity.key; + const accountIssuer = + "issuer" in resolvedIdentity.fields + ? resolvedIdentity.fields.issuer + : undefined; + const accountIdentityFields = + accountIssuer === undefined ? {} : { issuer: accountIssuer }; const source = opts.source ?? { method: "oauth", oauth: { providerId: account.providerId }, @@ -51,10 +74,7 @@ export async function handleOAuthUserInfo( !!opts.selectedUser || opts.requireExactAccountBinding === true; let pendingAccountCookie: Account | null = null; const accountOwner = await c.context.internalAdapter - .findAccountOwnerByKey({ - issuer: account.issuer, - accountId: account.accountId, - }) + .findAccountOwnerByKey(accountKey) .catch((e) => { c.context.logger.error( "Better auth was unable to query your database.\nError: ", @@ -145,7 +165,10 @@ export async function handleOAuthUserInfo( dbUser.linkedAccount ?? dbUser.accounts.find( (acc) => - acc.issuer === account.issuer && acc.accountId === account.accountId, + (accountIssuer === undefined + ? acc.providerId === account.providerId + : acc.issuer === accountIssuer) && + acc.accountId === account.accountId, ); if (!linkedAccount) { const accountLinking = c.context.options.account?.accountLinking; @@ -186,7 +209,7 @@ export async function handleOAuthUserInfo( }); const createdAccount = await c.context.internalAdapter.linkAccount({ providerId: account.providerId, - issuer: account.issuer, + ...accountIdentityFields, accountId: account.accountId, userId: dbUser.user.id, accessToken: await setTokenUtil(account.accessToken, c.context), @@ -204,7 +227,8 @@ export async function handleOAuthUserInfo( } if ( requireExactAccountBinding && - (createdAccount.issuer !== account.issuer || + ((accountIssuer !== undefined && + createdAccount.issuer !== accountIssuer) || createdAccount.accountId !== account.accountId || createdAccount.providerId !== account.providerId || createdAccount.userId !== dbUser.user.id) @@ -305,7 +329,8 @@ export async function handleOAuthUserInfo( } if ( requireExactAccountBinding && - (updatedAccount.issuer !== account.issuer || + ((accountIssuer !== undefined && + updatedAccount.issuer !== accountIssuer) || updatedAccount.accountId !== account.accountId || updatedAccount.providerId !== account.providerId || updatedAccount.userId !== dbUser.user.id) @@ -410,7 +435,7 @@ export async function handleOAuthUserInfo( refreshTokenExpiresAt: account.refreshTokenExpiresAt, scope: account.scope, providerId: account.providerId, - issuer: account.issuer, + ...accountIdentityFields, accountId: account.accountId, }; const { createdUser, createdAccount } = await runWithTransaction( @@ -435,7 +460,8 @@ export async function handleOAuthUserInfo( ); if ( requireExactAccountBinding && - (createdAccount.issuer !== account.issuer || + ((accountIssuer !== undefined && + createdAccount.issuer !== accountIssuer) || createdAccount.accountId !== account.accountId || createdAccount.providerId !== account.providerId || createdAccount.userId !== createdUser.id) diff --git a/packages/better-auth/src/plugins/admin/admin.test.ts b/packages/better-auth/src/plugins/admin/admin.test.ts index 34f810929e..542a85d1c5 100644 --- a/packages/better-auth/src/plugins/admin/admin.test.ts +++ b/packages/better-auth/src/plugins/admin/admin.test.ts @@ -1380,7 +1380,6 @@ describe("Admin plugin", async () => { ).resolves.toMatchObject({ userId, providerId: "credential", - issuer: "local:credential", accountId: userId, }); diff --git a/packages/better-auth/src/plugins/admin/routes.ts b/packages/better-auth/src/plugins/admin/routes.ts index 128f939d86..44c01e0e56 100644 --- a/packages/better-auth/src/plugins/admin/routes.ts +++ b/packages/better-auth/src/plugins/admin/routes.ts @@ -6,6 +6,7 @@ import type { Session } from "@better-auth/core/db"; import { createLocalAccountIssuer } from "@better-auth/core/db"; import type { Where } from "@better-auth/core/db/adapter"; import { whereOperators } from "@better-auth/core/db/adapter"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { getAuthoritativeSessionFromCtx, getSessionFromCtx } from "../../api"; @@ -460,9 +461,14 @@ export const createUser = (opts: O) => ctx.body.password, ); await ctx.context.internalAdapter.linkAccount({ - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: user.id, + }, + ).fields, password: hashedPassword, userId: user.id, }); @@ -1739,9 +1745,14 @@ export const setUserPassword = (opts: AdminOptions) => } else { await ctx.context.internalAdapter.createAccount({ userId, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: user.id, + }, + ).fields, password: hashedPassword, }); } diff --git a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts index f3be623d3e..c28d475503 100644 --- a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts +++ b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts @@ -438,7 +438,6 @@ describe("email-otp", async () => { ).resolves.toMatchObject({ userId, providerId: "credential", - issuer: "local:credential", accountId: userId, }); }); diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts index 4fe738c69c..54cf8fa2a7 100644 --- a/packages/better-auth/src/plugins/email-otp/routes.ts +++ b/packages/better-auth/src/plugins/email-otp/routes.ts @@ -1,6 +1,7 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { deprecate } from "@better-auth/core/utils/deprecate"; import * as z from "zod"; @@ -974,9 +975,14 @@ export const resetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) => if (!account) { await ctx.context.internalAdapter.createAccount({ userId: user.user.id, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: user.user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: user.user.id, + }, + ).fields, password: passwordHash, }); } else { diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index daf5277f5c..72502355d0 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -2136,6 +2136,7 @@ describe("oauth2", async () => { it("recognizes one OIDC account across provider aliases when mutable profile fields change", async () => { const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ + account: { identityStrategy: "issuer" }, plugins: [ genericOAuth({ config: [ diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index dbdf4bc53c..43955a9d75 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -520,6 +520,7 @@ export const oAuthProxy = (opts?: O) => { provider, tokens, userInfoResult.data, + ctx.context.options.account?.identityStrategy, ); } catch (error) { ctx.context.logger.error( diff --git a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts index c177cd81f8..c9095a6579 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts @@ -552,7 +552,7 @@ describe("oauth-proxy", async () => { }; account: { providerId: string; - issuer: string; + issuer?: string; accountId: string; accessToken?: string; refreshToken?: string; @@ -566,7 +566,7 @@ describe("oauth-proxy", async () => { expect(payload.userInfo.email).toBe("user@email.com"); expect(payload.account).toBeDefined(); expect(payload.account.providerId).toBe("google"); - expect(payload.account.issuer).toBe("https://accounts.google.com"); + expect(payload.account).not.toHaveProperty("issuer"); expect(payload.account.accountId).toBe("1234567890"); expect(payload.state).toBeDefined(); expect(payload.timestamp).toBeDefined(); @@ -1213,7 +1213,6 @@ describe("oauth-proxy", async () => { expect(accounts).toContainEqual( expect.objectContaining({ providerId: "google", - issuer: "https://accounts.google.com", accountId: "1234567890", }), ); diff --git a/packages/better-auth/src/plugins/one-tap/one-tap.test.ts b/packages/better-auth/src/plugins/one-tap/one-tap.test.ts index 8883959728..0a559f235e 100644 --- a/packages/better-auth/src/plugins/one-tap/one-tap.test.ts +++ b/packages/better-auth/src/plugins/one-tap/one-tap.test.ts @@ -286,7 +286,6 @@ describe("one-tap implicit linking gate", async () => { model: "account", where: [ { field: "providerId", value: "google" }, - { field: "issuer", value: "https://accounts.google.com" }, { field: "accountId", value: verifiedPayload.sub }, ], }); @@ -331,7 +330,6 @@ describe("one-tap implicit linking gate", async () => { model: "account", where: [ { field: "providerId", value: "google" }, - { field: "issuer", value: "https://accounts.google.com" }, { field: "accountId", value: verifiedPayload.sub }, ], }); diff --git a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap index 74092872dd..b9d5a5f5d2 100644 --- a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap +++ b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap @@ -27,9 +27,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "idToken": { "type": "string", }, - "issuer": { - "type": "string", - }, "password": { "type": "string", }, @@ -56,7 +53,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` }, "required": [ "id", - "issuer", "accountId", "providerId", "userId", @@ -241,7 +237,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "required": [ "id", "providerId", - "issuer", "accountId", ], "type": "object", @@ -2249,7 +2244,6 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "providerId", "createdAt", "updatedAt", - "issuer", "accountId", "userId", "scopes", diff --git a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts index 89417aa4dc..376a5aeb2e 100644 --- a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts +++ b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts @@ -504,7 +504,6 @@ describe("phone auth flow", async () => { ).resolves.toMatchObject({ userId, providerId: "credential", - issuer: "local:credential", accountId: userId, }); const emailSignIn = await client.signIn.email({ @@ -846,7 +845,6 @@ describe("reset password flow attempts", async () => { ).resolves.toMatchObject({ userId, providerId: "credential", - issuer: "local:credential", accountId: userId, }); }); diff --git a/packages/better-auth/src/plugins/phone-number/routes.ts b/packages/better-auth/src/plugins/phone-number/routes.ts index 8a30016786..07b357dad7 100644 --- a/packages/better-auth/src/plugins/phone-number/routes.ts +++ b/packages/better-auth/src/plugins/phone-number/routes.ts @@ -1,6 +1,7 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { getSessionFromCtx } from "../../api"; @@ -823,9 +824,14 @@ export const resetPasswordPhoneNumber = (opts: RequiredPhoneNumberOptions) => if (!account) { await ctx.context.internalAdapter.createAccount({ userId: user.id, - providerId: "credential", - issuer: createLocalAccountIssuer("credential"), - accountId: user.id, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "credential", + issuer: createLocalAccountIssuer("credential"), + accountId: user.id, + }, + ).fields, password: hashedPassword, }); } else { diff --git a/packages/better-auth/src/plugins/siwe/index.ts b/packages/better-auth/src/plugins/siwe/index.ts index e972ebe1bd..e4f31e26a4 100644 --- a/packages/better-auth/src/plugins/siwe/index.ts +++ b/packages/better-auth/src/plugins/siwe/index.ts @@ -1,6 +1,7 @@ import type { BetterAuthPlugin } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { createLocalAccountIssuer } from "@better-auth/core/db"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import * as z from "zod"; import { APIError } from "../../api"; import { setSessionCookie } from "../../cookies"; @@ -382,9 +383,14 @@ export const siwe = (options: SIWEPluginOptions) => { // Create account record for wallet authentication await ctx.context.internalAdapter.createAccount({ userId: user.id, - providerId: "siwe", - issuer: createLocalAccountIssuer("siwe"), - accountId: `${walletAddress}:${chainId}`, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "siwe", + issuer: createLocalAccountIssuer("siwe"), + accountId: `${walletAddress}:${chainId}`, + }, + ).fields, createdAt: new Date(), updatedAt: new Date(), }); @@ -406,9 +412,14 @@ export const siwe = (options: SIWEPluginOptions) => { // Create account record for this new wallet+chain combination await ctx.context.internalAdapter.createAccount({ userId: user.id, - providerId: "siwe", - issuer: createLocalAccountIssuer("siwe"), - accountId: `${walletAddress}:${chainId}`, + ...resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: "siwe", + issuer: createLocalAccountIssuer("siwe"), + accountId: `${walletAddress}:${chainId}`, + }, + ).fields, createdAt: new Date(), updatedAt: new Date(), }); diff --git a/packages/better-auth/src/plugins/siwe/siwe.test.ts b/packages/better-auth/src/plugins/siwe/siwe.test.ts index c53c4971a1..a63b722124 100644 --- a/packages/better-auth/src/plugins/siwe/siwe.test.ts +++ b/packages/better-auth/src/plugins/siwe/siwe.test.ts @@ -517,10 +517,10 @@ describe("siwe", async () => { expect(accounts).toContainEqual( expect.objectContaining({ providerId: "siwe", - issuer: "local:siwe", accountId: `${walletAddress}:${chainId}`, }), ); + expect(accounts[0]?.issuer).toBeUndefined(); }); it.each([ diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index d48f3f9348..8ba205acc9 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -1086,6 +1086,43 @@ describe("updateAccountOnSignIn", async () => { }); }); +it("uses provider-scoped identity through the OAuth redirect callback", async () => { + const headers = new Headers(); + const { client, cookieSetter } = await getTestInstance( + { + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { disableTestUser: true }, + ); + const authorization = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const state = + new URL(authorization.data!.url!).searchParams.get("state") ?? ""; + + await client.$fetch("/callback/google", { + query: { state, code: "test" }, + headers, + method: "GET", + onError(context) { + cookieSetter(headers)({ response: context.response }); + }, + }); + + const accounts = await client.listAccounts({ fetchOptions: { headers } }); + expect(accounts.data).toContainEqual( + expect.objectContaining({ providerId: "google" }), + ); + expect(accounts.data?.[0]).not.toHaveProperty("issuer"); +}); + /** * @see https://github.com/better-auth/better-auth/issues/4498 */ @@ -1152,6 +1189,61 @@ describe("Google Provider — multiple client IDs", async () => { expect(data.user.email).toBe("mobile-user@example.com"); }); + it("uses the provider ID as the external account namespace when configured", async () => { + const idToken = await signIdToken(webClientId); + const headers = new Headers(); + const { client, cookieSetter } = await getTestInstance( + { + account: { identityStrategy: "provider-id" }, + socialProviders: { + google: { + clientId: webClientId, + clientSecret: "test-secret", + }, + }, + }, + { disableTestUser: true }, + ); + + const res = await client.signIn.social({ + provider: "google", + idToken: { token: idToken }, + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + + expect(res.error).toBeNull(); + const accounts = await client.listAccounts({ fetchOptions: { headers } }); + expect(accounts.data).toContainEqual( + expect.objectContaining({ + providerId: "google", + accountId: "google-sub-999", + }), + ); + expect(accounts.data?.[0]).not.toHaveProperty("issuer"); + + const returningHeaders = new Headers(); + const returning = await client.signIn.social({ + provider: "google", + idToken: { token: idToken }, + fetchOptions: { onSuccess: cookieSetter(returningHeaders) }, + }); + if (!res.data || !("user" in res.data)) { + throw new Error("Expected the first sign-in to return a user"); + } + if (!returning.data || !("user" in returning.data)) { + throw new Error("Expected the returning sign-in to return a user"); + } + expect(returning.data.user.id).toBe(res.data.user.id); + const returningAccounts = await client.listAccounts({ + fetchOptions: { headers: returningHeaders }, + }); + expect( + returningAccounts.data?.filter( + (account) => account.providerId === "google", + ), + ).toHaveLength(1); + }); + it("rejects an id token whose audience is not configured", async () => { const idToken = await signIdToken("999-unknown.googleusercontent.com"); const { client } = await getTestInstance( diff --git a/packages/better-auth/src/types/types.test.ts b/packages/better-auth/src/types/types.test.ts index 0913da3a0c..f32d7ff4bd 100644 --- a/packages/better-auth/src/types/types.test.ts +++ b/packages/better-auth/src/types/types.test.ts @@ -1,5 +1,10 @@ import type { BetterAuthOptions, BetterAuthPlugin } from "@better-auth/core"; -import type { GoogleProfile, JoinConfig, JoinOption } from "better-auth/types"; +import type { + Account, + GoogleProfile, + JoinConfig, + JoinOption, +} from "better-auth/types"; import { describe, expect, expectTypeOf, it } from "vitest"; import { createAuthEndpoint } from "../api"; import { betterAuth } from "../auth/minimal"; @@ -53,6 +58,26 @@ declare module "@better-auth/core" { } describe("general types", async () => { + it("accepts the provider-scoped account identity strategy", () => { + const options = { + account: { identityStrategy: "provider-id" }, + } satisfies BetterAuthOptions; + + expectTypeOf( + options.account.identityStrategy, + ).toEqualTypeOf<"provider-id">(); + }); + + it("requires issuer only for explicitly issuer-scoped account types", () => { + type DefaultAccount = Account<{}>; + type IssuerAccount = Account<{ identityStrategy: "issuer" }>; + + expectTypeOf().toEqualTypeOf< + string | undefined + >(); + expectTypeOf().toEqualTypeOf(); + }); + it("should infer base session", async () => { const { auth } = await getTestInstance(); type Session = typeof auth.$Infer.Session; diff --git a/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural-duplicate-relations.txt b/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural-duplicate-relations.txt index b288354ad2..64724f791e 100644 --- a/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural-duplicate-relations.txt +++ b/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural-duplicate-relations.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey(), @@ -49,7 +43,6 @@ export const accounts = sqliteTable( "accounts", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -73,13 +66,7 @@ export const accounts = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("accounts_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("accounts_userId_idx").on(table.userId), - ], + (table) => [index("accounts_userId_idx").on(table.userId)], ); export const verifications = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural.txt b/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural.txt index c43ae45d99..f9d22b507e 100644 --- a/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural.txt +++ b/packages/cli/test/__snapshots__/auth-schema-drizzle-use-plural.txt @@ -1,12 +1,5 @@ import { relations } from "drizzle-orm"; -import { - pgTable, - text, - timestamp, - boolean, - index, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: text("id").primaryKey(), @@ -44,7 +37,6 @@ export const accounts = pgTable( "accounts", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -62,13 +54,7 @@ export const accounts = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("accounts_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("accounts_userId_idx").on(table.userId), - ], + (table) => [index("accounts_userId_idx").on(table.userId)], ); export const verifications = pgTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-duplicate-relations.txt b/packages/cli/test/__snapshots__/auth-schema-duplicate-relations.txt index 35366c50fd..8d6150c0df 100644 --- a/packages/cli/test/__snapshots__/auth-schema-duplicate-relations.txt +++ b/packages/cli/test/__snapshots__/auth-schema-duplicate-relations.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -49,7 +43,6 @@ export const account = sqliteTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -73,13 +66,7 @@ export const account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-multi-relation.txt b/packages/cli/test/__snapshots__/auth-schema-multi-relation.txt index ed2a7efede..108337515e 100644 --- a/packages/cli/test/__snapshots__/auth-schema-multi-relation.txt +++ b/packages/cli/test/__snapshots__/auth-schema-multi-relation.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -49,7 +43,6 @@ export const account = sqliteTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -73,13 +66,7 @@ export const account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt index 1e316eb462..86794a5475 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt @@ -7,7 +7,6 @@ import { boolean, mysqlEnum, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const user = mysqlTable("user", { @@ -47,8 +46,7 @@ export const account = mysqlTable( "account", { id: varchar("id", { length: 36 }).primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: varchar("user_id", { length: 36 }) .notNull() @@ -65,13 +63,7 @@ export const account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt index 10e177f8ad..47fa2577d9 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt @@ -7,7 +7,6 @@ import { boolean, int, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const custom_user = mysqlTable("custom_user", { @@ -49,8 +48,7 @@ export const custom_account = mysqlTable( "custom_account", { id: int("id").autoincrement().primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: int("user_id") .notNull() @@ -67,13 +65,7 @@ export const custom_account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt index 5e34532984..ee27259fa1 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt @@ -7,7 +7,6 @@ import { boolean, int, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const custom_user = mysqlTable("custom_user", { @@ -46,8 +45,7 @@ export const custom_account = mysqlTable( "custom_account", { id: int("id").autoincrement().primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: int("user_id") .notNull() @@ -64,13 +62,7 @@ export const custom_account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt index 660f9029e0..49cb006d0c 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt @@ -7,7 +7,6 @@ import { boolean, int, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const custom_user = mysqlTable("custom_user", { @@ -46,8 +45,7 @@ export const custom_account = mysqlTable( "custom_account", { id: varchar("id", { length: 36 }).primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: varchar("user_id", { length: 36 }) .notNull() @@ -64,13 +62,7 @@ export const custom_account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt index 9c09a02bec..802433f231 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt @@ -7,7 +7,6 @@ import { boolean, int, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const custom_user = mysqlTable("custom_user", { @@ -49,8 +48,7 @@ export const custom_account = mysqlTable( "custom_account", { id: varchar("id", { length: 36 }).primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: varchar("user_id", { length: 36 }) .notNull() @@ -67,13 +65,7 @@ export const custom_account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql.txt b/packages/cli/test/__snapshots__/auth-schema-mysql.txt index 9c09a02bec..802433f231 100644 --- a/packages/cli/test/__snapshots__/auth-schema-mysql.txt +++ b/packages/cli/test/__snapshots__/auth-schema-mysql.txt @@ -7,7 +7,6 @@ import { boolean, int, index, - uniqueIndex, } from "drizzle-orm/mysql-core"; export const custom_user = mysqlTable("custom_user", { @@ -49,8 +48,7 @@ export const custom_account = mysqlTable( "custom_account", { id: varchar("id", { length: 36 }).primaryKey(), - issuer: varchar("issuer", { length: 191 }).notNull(), - accountId: varchar("account_id", { length: 191 }).notNull(), + accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: varchar("user_id", { length: 36 }) .notNull() @@ -67,13 +65,7 @@ export const custom_account = mysqlTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = mysqlTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-number-id.txt index 71ddd8d3d3..1532e743fa 100644 --- a/packages/cli/test/__snapshots__/auth-schema-number-id.txt +++ b/packages/cli/test/__snapshots__/auth-schema-number-id.txt @@ -6,7 +6,6 @@ import { boolean, integer, index, - uniqueIndex, } from "drizzle-orm/pg-core"; export const custom_user = pgTable("custom_user", { @@ -48,7 +47,6 @@ export const custom_account = pgTable( "custom_account", { id: integer("id").generatedByDefaultAsIdentity().primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: integer("user_id") @@ -66,13 +64,7 @@ export const custom_account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = pgTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt b/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt index 96c90ac4dc..7ea874960c 100644 --- a/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt +++ b/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt @@ -1,12 +1,5 @@ import { relations } from "drizzle-orm"; -import { - pgTable, - text, - timestamp, - boolean, - index, - uniqueIndex, -} from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -45,7 +38,6 @@ export const account = pgTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -63,13 +55,7 @@ export const account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = pgTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt index 1fcabf5365..c584a3be6f 100644 --- a/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt +++ b/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt @@ -6,7 +6,6 @@ import { boolean, integer, index, - uniqueIndex, } from "drizzle-orm/pg-core"; export const custom_user = pgTable("custom_user", { @@ -45,7 +44,6 @@ export const custom_account = pgTable( "custom_account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -63,13 +61,7 @@ export const custom_account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = pgTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt index 1a25a96a66..915690126d 100644 --- a/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt +++ b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt @@ -7,7 +7,6 @@ import { integer, uuid, index, - uniqueIndex, } from "drizzle-orm/pg-core"; export const custom_user = pgTable("custom_user", { @@ -55,7 +54,6 @@ export const custom_account = pgTable( id: uuid("id") .default(sql`pg_catalog.gen_random_uuid()`) .primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: uuid("user_id") @@ -73,13 +71,7 @@ export const custom_account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = pgTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-with-schema-name.txt b/packages/cli/test/__snapshots__/auth-schema-pg-with-schema-name.txt index 7044094a2e..6c1fc0e730 100644 --- a/packages/cli/test/__snapshots__/auth-schema-pg-with-schema-name.txt +++ b/packages/cli/test/__snapshots__/auth-schema-pg-with-schema-name.txt @@ -6,7 +6,6 @@ import { boolean, integer, index, - uniqueIndex, } from "drizzle-orm/pg-core"; export const authSchema = pgSchema("auth"); @@ -50,7 +49,6 @@ export const account = authSchema.table( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -68,13 +66,7 @@ export const account = authSchema.table( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = authSchema.table( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt index fcf61f1c1d..91a8f27276 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -50,7 +44,6 @@ export const account = sqliteTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -74,13 +67,7 @@ export const account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("account_userId_idx").on(table.userId), - ], + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt index 7cb3965912..4d34318145 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const custom_user = sqliteTable("custom_user", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), @@ -54,7 +48,6 @@ export const custom_account = sqliteTable( "custom_account", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: integer("user_id") @@ -78,13 +71,7 @@ export const custom_account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt index 8d75a9c777..e35b12d217 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const custom_user = sqliteTable("custom_user", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), @@ -49,7 +43,6 @@ export const custom_account = sqliteTable( "custom_account", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: integer("user_id") @@ -73,13 +66,7 @@ export const custom_account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt index 59633cf438..14aa0d40a6 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const custom_user = sqliteTable("custom_user", { id: text("id").primaryKey(), @@ -49,7 +43,6 @@ export const custom_account = sqliteTable( "custom_account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -73,13 +66,7 @@ export const custom_account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt index 6752dda021..78d855315f 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const custom_user = sqliteTable("custom_user", { id: text("id").primaryKey(), @@ -54,7 +48,6 @@ export const custom_account = sqliteTable( "custom_account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -78,13 +71,7 @@ export const custom_account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite.txt index 6752dda021..78d855315f 100644 --- a/packages/cli/test/__snapshots__/auth-schema-sqlite.txt +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite.txt @@ -1,11 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const custom_user = sqliteTable("custom_user", { id: text("id").primaryKey(), @@ -54,7 +48,6 @@ export const custom_account = sqliteTable( "custom_account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -78,13 +71,7 @@ export const custom_account = sqliteTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = sqliteTable( diff --git a/packages/cli/test/__snapshots__/auth-schema.txt b/packages/cli/test/__snapshots__/auth-schema.txt index f40d247c2e..e4aa0c2e6c 100644 --- a/packages/cli/test/__snapshots__/auth-schema.txt +++ b/packages/cli/test/__snapshots__/auth-schema.txt @@ -6,7 +6,6 @@ import { boolean, integer, index, - uniqueIndex, } from "drizzle-orm/pg-core"; export const custom_user = pgTable("custom_user", { @@ -48,7 +47,6 @@ export const custom_account = pgTable( "custom_account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") @@ -66,13 +64,7 @@ export const custom_account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [ - uniqueIndex("custom_account_issuer_accountId_uidx").on( - table.issuer, - table.accountId, - ), - index("custom_account_userId_idx").on(table.userId), - ], + (table) => [index("custom_account_userId_idx").on(table.userId)], ); export const custom_verification = pgTable( diff --git a/packages/cli/test/__snapshots__/migrations.sql b/packages/cli/test/__snapshots__/migrations.sql index 1ef3ff9655..eb97fb6dd9 100644 --- a/packages/cli/test/__snapshots__/migrations.sql +++ b/packages/cli/test/__snapshots__/migrations.sql @@ -2,7 +2,7 @@ create table "user" ("id" text not null primary key, "name" text not null, "emai create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); -create table "account" ("id" text not null primary key, "issuer" text not null, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); +create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null); @@ -10,6 +10,4 @@ create index "session_userId_idx" on "session" ("userId"); create index "account_userId_idx" on "account" ("userId"); -create index "verification_identifier_idx" on "verification" ("identifier"); - -create unique index "account_issuer_accountId_uidx" on "account" ("issuer", "accountId"); \ No newline at end of file +create index "verification_identifier_idx" on "verification" ("identifier"); \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/schema-mongodb.prisma b/packages/cli/test/__snapshots__/schema-mongodb.prisma index bcb1e8e9db..9453ded831 100644 --- a/packages/cli/test/__snapshots__/schema-mongodb.prisma +++ b/packages/cli/test/__snapshots__/schema-mongodb.prisma @@ -46,7 +46,6 @@ model Session { model Account { id String @id @map("_id") - issuer String accountId String providerId String userId String @@ -61,7 +60,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId]) @@map("account") } diff --git a/packages/cli/test/__snapshots__/schema-mysql-custom.prisma b/packages/cli/test/__snapshots__/schema-mysql-custom.prisma index 3ff78eca8a..9714fe9847 100644 --- a/packages/cli/test/__snapshots__/schema-mysql-custom.prisma +++ b/packages/cli/test/__snapshots__/schema-mysql-custom.prisma @@ -49,8 +49,7 @@ model Session { model Account { id String @id - issuer String @db.VarChar(191) - accountId String @db.VarChar(191) + accountId String @db.Text providerId String @db.Text userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -64,7 +63,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId(length: 191)]) @@map("account") } diff --git a/packages/cli/test/__snapshots__/schema-mysql.prisma b/packages/cli/test/__snapshots__/schema-mysql.prisma index 128637799f..3909038ab0 100644 --- a/packages/cli/test/__snapshots__/schema-mysql.prisma +++ b/packages/cli/test/__snapshots__/schema-mysql.prisma @@ -46,8 +46,7 @@ model Session { model Account { id String @id - issuer String @db.VarChar(191) - accountId String @db.VarChar(191) + accountId String @db.Text providerId String @db.Text userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -61,7 +60,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId(length: 191)]) @@map("account") } diff --git a/packages/cli/test/__snapshots__/schema-numberid.prisma b/packages/cli/test/__snapshots__/schema-numberid.prisma index 59c7e69f24..a7068ff75e 100644 --- a/packages/cli/test/__snapshots__/schema-numberid.prisma +++ b/packages/cli/test/__snapshots__/schema-numberid.prisma @@ -46,7 +46,6 @@ model Session { model Account { id Int @id @default(autoincrement()) - issuer String accountId String providerId String userId Int @@ -61,7 +60,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId]) @@map("account") } diff --git a/packages/cli/test/__snapshots__/schema-prisma-use-plural.prisma b/packages/cli/test/__snapshots__/schema-prisma-use-plural.prisma index 75a90d036e..07846ae50b 100644 --- a/packages/cli/test/__snapshots__/schema-prisma-use-plural.prisma +++ b/packages/cli/test/__snapshots__/schema-prisma-use-plural.prisma @@ -41,7 +41,6 @@ model Sessions { model Accounts { id String @id - issuer String accountId String providerId String userId String @@ -56,7 +55,6 @@ model Accounts { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "accounts_issuer_accountId_uidx") @@index([userId]) @@map("accounts") } diff --git a/packages/cli/test/__snapshots__/schema-uuid.prisma b/packages/cli/test/__snapshots__/schema-uuid.prisma index 323804e8d8..df62decfff 100644 --- a/packages/cli/test/__snapshots__/schema-uuid.prisma +++ b/packages/cli/test/__snapshots__/schema-uuid.prisma @@ -46,7 +46,6 @@ model Session { model Account { id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid - issuer String accountId String providerId String userId String @db.Uuid @@ -61,7 +60,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId]) @@map("account") } diff --git a/packages/cli/test/__snapshots__/schema.prisma b/packages/cli/test/__snapshots__/schema.prisma index 4fe949a907..67eb231e0b 100644 --- a/packages/cli/test/__snapshots__/schema.prisma +++ b/packages/cli/test/__snapshots__/schema.prisma @@ -46,7 +46,6 @@ model Session { model Account { id String @id - issuer String accountId String providerId String userId String @@ -61,7 +60,6 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([issuer, accountId], map: "account_issuer_accountId_uidx") @@index([userId]) @@map("account") } diff --git a/packages/core/src/db/get-tables.ts b/packages/core/src/db/get-tables.ts index 24b3772a23..04b1fce42a 100644 --- a/packages/core/src/db/get-tables.ts +++ b/packages/core/src/db/get-tables.ts @@ -27,6 +27,7 @@ function mergeTableIndexes( } const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => { + const usesIssuerIdentity = options.account?.identityStrategy === "issuer"; const pluginSchema = (options.plugins ?? []).reduce( (acc, plugin) => { const schema = plugin.schema; @@ -251,20 +252,26 @@ const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => { account: { modelName: options.account?.modelName || "account", indexes: mergeTableIndexes( - [ - { - fields: ["issuer", "accountId"], - unique: true, - }, - ], + usesIssuerIdentity + ? [ + { + fields: ["issuer", "accountId"], + unique: true, + }, + ] + : undefined, account?.indexes, ), fields: { - issuer: { - type: "string", - required: true, - fieldName: options.account?.fields?.issuer || "issuer", - }, + ...(usesIssuerIdentity + ? { + issuer: { + type: "string" as const, + required: true, + fieldName: options.account?.fields?.issuer || "issuer", + }, + } + : {}), accountId: { type: "string", required: true, diff --git a/packages/core/src/db/internal.ts b/packages/core/src/db/internal.ts index 245fd71299..fdf0d4330e 100644 --- a/packages/core/src/db/internal.ts +++ b/packages/core/src/db/internal.ts @@ -10,3 +10,7 @@ export { resolveDatabaseTableIndexes, } from "./database-index"; export { getAuthTablesWithResolvedIndexes } from "./get-tables"; +export { + type AccountIdentityStrategy, + resolveAccountIdentity, +} from "./schema/account"; diff --git a/packages/core/src/db/schema/account.ts b/packages/core/src/db/schema/account.ts index 7548d6ff79..edd63a3a49 100644 --- a/packages/core/src/db/schema/account.ts +++ b/packages/core/src/db/schema/account.ts @@ -9,7 +9,7 @@ import { coreSchema } from "./shared"; export const accountSchema = coreSchema.extend({ providerId: z.string(), - issuer: z.string(), + issuer: z.string().optional(), accountId: z.string(), userId: z.coerce.string(), accessToken: z.string().nullish(), @@ -39,7 +39,76 @@ export const accountSchema = coreSchema.extend({ export type BaseAccount = z.infer; /** The stable provider-side key used to recognize an account. */ -export type AccountKey = Readonly>; +export type AccountKey = Readonly< + | { + providerId: string; + accountId: string; + issuer?: never; + } + | { + issuer: string; + accountId: string; + providerId?: never; + } +>; + +export type AccountIdentityStrategy = "provider-id" | "issuer"; + +type AccountIdentityInput = Readonly<{ + providerId: string; + accountId: string; + issuer?: string | undefined; +}>; + +type ResolvedAccountIdentity = Readonly< + | { + key: Extract; + fields: { + providerId: string; + accountId: string; + }; + } + | { + key: Extract; + fields: { + providerId: string; + accountId: string; + issuer: string; + }; + } +>; + +/** + * Resolves the account lookup key and database fields for the selected + * identity strategy. An omitted strategy preserves v1.6 provider identity. + */ +export function resolveAccountIdentity( + identityStrategy: AccountIdentityStrategy | undefined, + input: AccountIdentityInput, +): ResolvedAccountIdentity { + if (identityStrategy !== "issuer") { + return { + key: { providerId: input.providerId, accountId: input.accountId }, + fields: { providerId: input.providerId, accountId: input.accountId }, + }; + } + if ( + input.issuer === undefined || + input.issuer.trim().length === 0 || + input.issuer === "undefined" || + input.issuer === "null" + ) { + throw new Error("Issuer-scoped account identity requires a valid issuer"); + } + return { + key: { issuer: input.issuer, accountId: input.accountId }, + fields: { + providerId: input.providerId, + accountId: input.accountId, + issuer: input.issuer, + }, + }; +} function encodeAccountIssuerProviderId(providerId: string): string { return encodeURIComponent(providerId); @@ -68,7 +137,10 @@ export type Account< DBOptions extends BetterAuthOptions["account"] = BetterAuthOptions["account"], Plugins extends BetterAuthOptions["plugins"] = BetterAuthOptions["plugins"], > = Prettify< - BaseAccount & + Omit & + (NonNullable extends { identityStrategy: "issuer" } + ? { issuer: string } + : { issuer?: string }) & InferDBFieldsFromOptions & InferDBFieldsFromPlugins<"account", Plugins> >; diff --git a/packages/core/src/db/schema/user.ts b/packages/core/src/db/schema/user.ts index a02a62494d..ca5ed8ed9f 100644 --- a/packages/core/src/db/schema/user.ts +++ b/packages/core/src/db/schema/user.ts @@ -9,7 +9,8 @@ import { coreSchema } from "./shared"; export const userSchema = coreSchema.extend({ // TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only, // Apple subsequent sign-ins, etc.) can legitimately omit email; identity - // must key on (issuer, accountId) per OpenID Connect Core §5.7. + // must key on an immutable provider subject. Issuer-scoped identity uses the + // (issuer, accountId) pair required by OpenID Connect Core §5.7. email: z.string().transform((val) => val.toLowerCase()), emailVerified: z.boolean().default(false), name: z.string(), diff --git a/packages/core/src/db/test/get-tables.test.ts b/packages/core/src/db/test/get-tables.test.ts index ae1ea5bbc1..8f8cf6e561 100644 --- a/packages/core/src/db/test/get-tables.test.ts +++ b/packages/core/src/db/test/get-tables.test.ts @@ -4,6 +4,7 @@ import type { AccountKey } from "../schema/account"; import { createLocalAccountIssuer, createOAuthAccountIssuer, + resolveAccountIdentity, } from "../schema/account"; import type { SecondaryStorage } from "../type"; @@ -16,6 +17,49 @@ const secondaryStorageStub: SecondaryStorage = { }; describe("getAuthTables", () => { + it("uses the v1.6 provider-scoped account schema by default", () => { + const tables = getAuthTables({}); + + expect(tables.account?.fields.issuer).toBeUndefined(); + expect(tables.account?.indexes).not.toContainEqual({ + fields: ["issuer", "accountId"], + unique: true, + }); + }); + + it("omits issuer from default account identity fields", () => { + expect( + resolveAccountIdentity(undefined, { + providerId: "google", + accountId: "subject", + issuer: "https://accounts.google.com", + }), + ).toEqual({ + key: { providerId: "google", accountId: "subject" }, + fields: { providerId: "google", accountId: "subject" }, + }); + }); + + it("keeps issuer in explicitly issuer-scoped account identity fields", () => { + expect( + resolveAccountIdentity("issuer", { + providerId: "google", + accountId: "subject", + issuer: "https://accounts.google.com", + }), + ).toEqual({ + key: { + issuer: "https://accounts.google.com", + accountId: "subject", + }, + fields: { + providerId: "google", + accountId: "subject", + issuer: "https://accounts.google.com", + }, + }); + }); + it("creates a local account key without changing the provider account id", () => { const credentialAccountKey: AccountKey = { issuer: createLocalAccountIssuer("credential"), @@ -103,6 +147,7 @@ describe("getAuthTables", () => { it("defines the issuer-scoped account key with configured field names", () => { const tables = getAuthTables({ account: { + identityStrategy: "issuer", fields: { issuer: "identity_issuer", accountId: "provider_subject", diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index 01d82c899d..357be4de30 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -1158,6 +1158,16 @@ export type BetterAuthOptions = { | undefined; account?: | (BetterAuthDBOptions<"account", keyof BaseAccount> & { + /** + * Determines the stable key Better Auth uses to recognize an account. + * + * `"provider-id"` preserves the v1.6 `(providerId, accountId)` identity + * model and does not add an issuer column. `"issuer"` opts into the + * issuer-scoped identity model and its schema migration. + * + * @default "provider-id" + */ + identityStrategy?: "provider-id" | "issuer"; /** * When enabled (true), the user account data (accessToken, idToken, refreshToken, etc.) * will be updated on sign in with the latest data from the provider. diff --git a/packages/drizzle-adapter/src/drizzle-adapter.test.ts b/packages/drizzle-adapter/src/drizzle-adapter.test.ts index 9e81306d63..07cd4940f7 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.test.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.test.ts @@ -222,7 +222,10 @@ describe("drizzle-adapter", () => { const adapter = drizzleAdapter( { _: { fullSchema: { account } }, select }, { provider: "pg", schema: { account } }, - )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + )({ + secret: "test-secret-that-is-at-least-32-chars-long!!", + account: { identityStrategy: "issuer" }, + }); await expect( adapter.findOne({ @@ -248,7 +251,10 @@ describe("drizzle-adapter", () => { { provider: "pg", schema: { account } }, )({ secret: "test-secret-that-is-at-least-32-chars-long!!", - account: { fields: { issuer: "constructor" } }, + account: { + identityStrategy: "issuer", + fields: { issuer: "constructor" }, + }, }); await expect( diff --git a/packages/scim/src/scim-sso-http-e2e.test.ts b/packages/scim/src/scim-sso-http-e2e.test.ts index 6f9fcd927c..f04b551f86 100644 --- a/packages/scim/src/scim-sso-http-e2e.test.ts +++ b/packages/scim/src/scim-sso-http-e2e.test.ts @@ -490,11 +490,11 @@ describe("SCIM-provisioned SSO authentication over HTTP", () => { where: [], }); expect(account).toMatchObject({ - issuer: identityProvider.issuer.url, accountId: externalId, providerId: "workforce", userId: provisionedUserId, }); + expect(account?.issuer).toBeUndefined(); await setSCIMUserActive(provisioned.id, false); expect(await instance.db.count({ model: "session", where: [] })).toBe(0); @@ -525,13 +525,15 @@ describe("SCIM-provisioned SSO authentication over HTTP", () => { }); expect(await instance.db.count({ model: "user", where: [] })).toBe(2); expect(await instance.db.count({ model: "account", where: [] })).toBe(1); - expect( - await instance.db.findOne({ model: "account", where: [] }), - ).toMatchObject({ - issuer: identityProvider.issuer.url, + const restoredAccount = await instance.db.findOne({ + model: "account", + where: [], + }); + expect(restoredAccount).toMatchObject({ accountId: externalId, providerId: "workforce", userId: provisionedUserId, }); + expect(restoredAccount?.issuer).toBeUndefined(); }); }); diff --git a/packages/sso/src/oidc-user-resolution-http-e2e.test.ts b/packages/sso/src/oidc-user-resolution-http-e2e.test.ts index 1c86d22cc0..8ee24db72a 100644 --- a/packages/sso/src/oidc-user-resolution-http-e2e.test.ts +++ b/packages/sso/src/oidc-user-resolution-http-e2e.test.ts @@ -195,7 +195,10 @@ describe("SSO OIDC user resolution HTTP", () => { }, onTestFinished, { - account: { storeAccountCookie: true }, + account: { + identityStrategy: "issuer", + storeAccountCookie: true, + }, user: { validateUserInfo({ source }: { source: { action: string } }) { validationActions.push(source.action); @@ -286,6 +289,49 @@ describe("SSO OIDC user resolution HTTP", () => { }); }); + it("uses the provider-scoped identity in resolution hooks and stored accounts", async ({ + onTestFinished, + }) => { + subject = "provider-scoped-directory-user"; + email = "provider-scoped@example.com"; + const inputs: SSOUserResolutionInput[] = []; + const instance = await createInstance( + { + resolveUser(input) { + inputs.push(input); + return { action: "continue" }; + }, + }, + onTestFinished, + { account: { identityStrategy: "provider-id" } }, + ); + + const signIn = await completeSignIn(instance.baseURL); + expect(signIn.callback.headers.get("location")).toBe( + `${instance.baseURL}/employee`, + ); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toMatchObject({ + protocol: "oidc", + providerId: "workforce", + accountKey: { + providerId: "workforce", + accountId: subject, + }, + }); + const accounts = await instance.db.findMany({ + model: "account", + where: [], + }); + expect(accounts).toEqual([ + expect.objectContaining({ + accountId: subject, + providerId: "workforce", + }), + ]); + expect(accounts[0]?.issuer).toBeUndefined(); + }); + it("preserves an unverified selected profile on a same-email first link", async ({ onTestFinished, }) => { @@ -538,7 +584,7 @@ describe("SSO OIDC user resolution HTTP", () => { expect(sendVerificationEmail).toHaveBeenCalledTimes(1); }); - it("rejects another provider alias for an existing subject even on default resolution", async ({ + it("rejects another provider alias for an existing subject under issuer-scoped identity", async ({ onTestFinished, }) => { subject = "provider-conflict-user"; @@ -546,7 +592,7 @@ describe("SSO OIDC user resolution HTTP", () => { const instance = await createInstance( { resolveUser: () => ({ action: "continue" }) }, onTestFinished, - {}, + { account: { identityStrategy: "issuer" } }, [provider("workforce"), provider("another-workforce")], ); expect( diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index 1c53050362..a54c7a7357 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -14,6 +14,7 @@ const server = new OAuth2Server(); describe("SSO", async () => { const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = await getTestInstance({ + account: { identityStrategy: "issuer" }, trustedOrigins: ["http://localhost:8080"], plugins: [sso(), organization()], }); diff --git a/packages/sso/src/routes/saml-pipeline.ts b/packages/sso/src/routes/saml-pipeline.ts index 1614a4a367..643338c588 100644 --- a/packages/sso/src/routes/saml-pipeline.ts +++ b/packages/sso/src/routes/saml-pipeline.ts @@ -2,6 +2,7 @@ import { getCurrentAdapter, runWithTransaction, } from "@better-auth/core/context"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { isAPIError } from "@better-auth/core/utils/is-api-error"; import type { User } from "better-auth"; import { APIError } from "better-auth/api"; @@ -669,10 +670,15 @@ export async function processSAMLResponse( : undefined, emailVerified: userInfo.emailVerified, }; - const accountKey = { - issuer, - accountId: userInfo.id as string, - }; + const accountIdentity = resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: provider.providerId, + issuer, + accountId: userInfo.id as string, + }, + ); + const accountKey = accountIdentity.key; // 16. Session creation // SSO provider ids are user-controlled and share the social-provider account @@ -747,9 +753,7 @@ export async function processSAMLResponse( id: userInfo.id as string, }, account: { - providerId, - issuer: accountKey.issuer, - accountId: accountKey.accountId, + ...accountIdentity.fields, accessToken: "", refreshToken: "", }, diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 45be4b4884..0677c427f9 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -2,6 +2,7 @@ import { getCurrentAdapter, runWithTransaction, } from "@better-auth/core/context"; +import { resolveAccountIdentity } from "@better-auth/core/db/internal"; import { isAPIError } from "@better-auth/core/utils/is-api-error"; import type { PrivateKeyJwtSigningAlgorithm, @@ -1687,12 +1688,17 @@ async function handleOIDCCallback( ? userInfo.emailVerified === true : false, }; - const accountKey = { - issuer: - (verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss")) || - provider.issuer, - accountId: userInfoId, - }; + const accountIdentity = resolveAccountIdentity( + ctx.context.options.account?.identityStrategy, + { + providerId: provider.providerId, + issuer: + (verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss")) || + provider.issuer, + accountId: userInfoId, + }, + ); + const accountKey = accountIdentity.key; const isTrustedProvider = "domainVerified" in provider && (provider as { domainVerified?: boolean }).domainVerified === true && @@ -1765,9 +1771,7 @@ async function handleOIDCCallback( idToken: tokenResponse.idToken, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken, - issuer: accountKey.issuer, - accountId: userInfoId, - providerId: provider.providerId, + ...accountIdentity.fields, accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt, refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt, scope: tokenResponse.scopes?.join(","), diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts index 02356d312f..e76e271bcb 100644 --- a/packages/sso/src/saml.test.ts +++ b/packages/sso/src/saml.test.ts @@ -6565,9 +6565,7 @@ describe("SAML E2E: SP-initiated flow", () => { | Record | undefined; expect(ssoAccount).toBeDefined(); - expect(ssoAccount!.issuer).toBe( - "http://localhost:8081/api/sso/saml2/idp/metadata", - ); + expect(ssoAccount).not.toHaveProperty("issuer"); expect(ssoAccount!.accountId).toBe("test@email.com"); // 7. Verify the user exists and is linked @@ -7347,7 +7345,7 @@ describe("SAML user resolution HTTP", () => { protocol: "saml", providerId: "workforce-saml", accountKey: { - issuer: "http://localhost:8081/api/sso/saml2/idp/metadata", + providerId: "workforce-saml", accountId: "test@email.com", }, providerUser: { @@ -7390,7 +7388,6 @@ describe("SAML user resolution HTTP", () => { }); expect(accounts).toEqual([ expect.objectContaining({ - issuer: "http://localhost:8081/api/sso/saml2/idp/metadata", accountId: "test@email.com", providerId: "workforce-saml", userId: selectedUser.id, @@ -7540,9 +7537,9 @@ describe("SAML user resolution HTTP", () => { model: "account", where: [], }); + expect(accounts[0]).not.toHaveProperty("issuer"); expect(accounts).toEqual([ expect.objectContaining({ - issuer: "http://localhost:8081/api/sso/saml2/idp/metadata", accountId: "test@email.com", providerId: "workforce-saml", }), diff --git a/packages/sso/src/types.ts b/packages/sso/src/types.ts index dec30039f8..2c65fbcbff 100644 --- a/packages/sso/src/types.ts +++ b/packages/sso/src/types.ts @@ -5,6 +5,7 @@ import type { User, } from "better-auth"; import type { + AccountKey, DBFieldAttribute, FieldAttributeToObject, InferAdditionalFieldsFromPluginOptions, @@ -321,10 +322,7 @@ export interface SSOProviderReference { interface BaseSSOUserResolutionInput { providerId: string; - accountKey: { - issuer: string; - accountId: string; - }; + accountKey: AccountKey; providerUser: SSOProviderUserProfile; providerReference: SSOProviderReference; } diff --git a/packages/test-utils/src/adapter/suites/basic.ts b/packages/test-utils/src/adapter/suites/basic.ts index b922416af0..dab0684503 100644 --- a/packages/test-utils/src/adapter/suites/basic.ts +++ b/packages/test-utils/src/adapter/suites/basic.ts @@ -79,68 +79,73 @@ export const getNormalTestSuiteTests = ( expect(res).toHaveProperty("id"); expect(typeof res.id).toEqual("string"); }, - "create - should enforce the issuer-scoped account identity key": async ({ - skip, - }: { - skip: (note?: string | undefined) => never; - }) => { - if (adapter.options?.adapterConfig.adapterId === "memory") { - skip("The memory adapter does not implement database indexes"); - return; - } - const firstUser = await adapter.create({ - model: "user", - data: await generate("user"), - forceAllowId: true, - }); - const secondUser = await adapter.create({ - model: "user", - data: await generate("user"), - forceAllowId: true, - }); - const firstAccount = { - ...(await generate("account")), - userId: firstUser.id, - providerId: "issuer-key-first-alias", - issuer: "https://issuer-key.example.com", - accountId: "shared-subject", - }; - await adapter.create({ - model: "account", - data: firstAccount, - forceAllowId: true, - }); - - await expect( - adapter.create({ - model: "account", - data: { - ...(await generate("account")), - userId: secondUser.id, - providerId: "issuer-key-second-alias", - issuer: firstAccount.issuer, - accountId: firstAccount.accountId, - }, + "create - should enforce issuer-scoped account identity when enabled": { + migrateBetterAuth: { + account: { identityStrategy: "issuer" as const }, + }, + test: async ({ + skip, + }: { + skip: (note?: string | undefined) => never; + }) => { + if (adapter.options?.adapterConfig.adapterId === "memory") { + skip("The memory adapter does not implement database indexes"); + return; + } + const firstUser = await adapter.create({ + model: "user", + data: await generate("user"), forceAllowId: true, - }), - ).rejects.toThrow(); - - await expect( - adapter.create({ - model: "account", - data: { - ...(await generate("account")), - userId: secondUser.id, - providerId: "issuer-key-other-issuer", - issuer: "https://other-issuer-key.example.com", - accountId: firstAccount.accountId, - }, + }); + const secondUser = await adapter.create({ + model: "user", + data: await generate("user"), forceAllowId: true, - }), - ).resolves.toMatchObject({ - issuer: "https://other-issuer-key.example.com", - accountId: "shared-subject", - }); + }); + const firstAccount = { + ...(await generate("account")), + userId: firstUser.id, + providerId: "issuer-key-first-alias", + issuer: "https://issuer-key.example.com", + accountId: "shared-subject", + }; + await adapter.create({ + model: "account", + data: firstAccount, + forceAllowId: true, + }); + + await expect( + adapter.create({ + model: "account", + data: { + ...(await generate("account")), + userId: secondUser.id, + providerId: "issuer-key-second-alias", + issuer: firstAccount.issuer, + accountId: firstAccount.accountId, + }, + forceAllowId: true, + }), + ).rejects.toThrow(); + + await expect( + adapter.create({ + model: "account", + data: { + ...(await generate("account")), + userId: secondUser.id, + providerId: "issuer-key-other-issuer", + issuer: "https://other-issuer-key.example.com", + accountId: firstAccount.accountId, + }, + forceAllowId: true, + }), + ).resolves.toMatchObject({ + issuer: "https://other-issuer-key.example.com", + accountId: "shared-subject", + }); + }, }, "create - should use generateId if provided": async () => { const ID = (await customIdGenerator?.()) || "MOCK-ID"; diff --git a/test/unit/types/db.test.ts b/test/unit/types/db.test.ts index 16fc160879..bfc83f6ecd 100644 --- a/test/unit/types/db.test.ts +++ b/test/unit/types/db.test.ts @@ -142,7 +142,7 @@ test("Account with additionalFields", () => { >(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toHaveProperty("providerAccountId"); });