fix(auth): make issuer-scoped identity opt-in

This commit is contained in:
Gustavo Valverde
2026-08-24 12:41:12 -04:00
parent 45e6d0e889
commit 7118ef9343
85 changed files with 817 additions and 562 deletions
@@ -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)`.
+1
View File
@@ -129,6 +129,7 @@ const authOptions = {
},
},
account: {
identityStrategy: "issuer",
accountLinking: {
trustedProviders: [
"email-password",
+5 -4
View File
@@ -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 = [
<DatabaseTable name="account" fields={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:<encoded providerId>` 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
+1 -1
View File
@@ -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
@@ -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:<encoded providerId>` 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:<encoded providerId>`, 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
+43 -11
View File
@@ -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** |
<Callout type="warn">
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.
</Callout>
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.
<Callout type="warn">
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 `<address>:<chainId>` 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:<encoded providerId>` | Existing provider account identifier |
| ------------------------------------- | ----------------------------------------- | -------------------------------------- |
| Credential | `local:credential` | Stable `id` from the linked `user` row |
| SIWE wallet, `providerId` `siwe` | `local:siwe` | Existing `<address>:<chainId>` 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:<encoded providerId>` | 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:
+13 -6
View File
@@ -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.
</Callout>
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:
@@ -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.
+5
View File
@@ -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`.
@@ -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: [
@@ -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: {
@@ -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", {
@@ -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
@@ -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 {
+14 -4
View File
@@ -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 = <O extends BetterAuthOptions>() =>
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 = <O extends BetterAuthOptions>() =>
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) {
@@ -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 = <O extends BetterAuthOptions>() =>
}
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 =
@@ -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();
});
});
@@ -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({
@@ -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({
+35 -40
View File
@@ -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<BetterAuthOptions>,
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<Account>({
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<Account>(
{
model: "account",
where: [
{
field: "issuer",
value: issuer,
},
{
field: "accountId",
value: accountId,
},
],
where: getAccountKeyWhere(accountKey),
},
);
return account;
@@ -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");
});
+17 -1
View File
@@ -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<Profile extends object>(
provider: OAuthProvider<Profile>,
tokens: OAuth2Tokens,
profile: Profile,
identityStrategy?: AccountIdentityStrategy,
): Promise<AccountKey> {
const accountKeyContext = { tokens, profile };
const accountSubject = provider.accountSubject;
@@ -38,6 +44,10 @@ export async function resolveOAuthAccountKey<Profile extends object>(
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<Profile extends object>(
provider: OAuthProvider<Profile>,
tokens: OAuth2Tokens,
profile: Profile,
identityStrategy?: AccountIdentityStrategy,
): Promise<AccountKey> {
try {
return await resolveOAuthAccountKey(provider, tokens, profile);
return await resolveOAuthAccountKey(
provider,
tokens,
profile,
identityStrategy,
);
} catch {
throw APIError.from(
"UNAUTHORIZED",
@@ -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"],
+38 -12
View File
@@ -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)
@@ -1380,7 +1380,6 @@ describe("Admin plugin", async () => {
).resolves.toMatchObject({
userId,
providerId: "credential",
issuer: "local:credential",
accountId: userId,
});
@@ -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 = <O extends AdminOptions>(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,
});
}
@@ -438,7 +438,6 @@ describe("email-otp", async () => {
).resolves.toMatchObject({
userId,
providerId: "credential",
issuer: "local:credential",
accountId: userId,
});
});
@@ -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 {
@@ -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: [
@@ -520,6 +520,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
provider,
tokens,
userInfoResult.data,
ctx.context.options.account?.identityStrategy,
);
} catch (error) {
ctx.context.logger.error(
@@ -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",
}),
);
@@ -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 },
],
});
@@ -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",
@@ -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,
});
});
@@ -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 {
+17 -6
View File
@@ -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(),
});
@@ -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([
+92
View File
@@ -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(
+26 -1
View File
@@ -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<DefaultAccount["issuer"]>().toEqualTypeOf<
string | undefined
>();
expectTypeOf<IssuerAccount["issuer"]>().toEqualTypeOf<string>();
});
it("should infer base session", async () => {
const { auth } = await getTestInstance();
type Session = typeof auth.$Infer.Session;
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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");
create index "verification_identifier_idx" on "verification" ("identifier");
@@ -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")
}
@@ -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")
}
@@ -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")
}
@@ -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")
}
@@ -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")
}
@@ -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")
}
@@ -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")
}
+18 -11
View File
@@ -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,
+4
View File
@@ -10,3 +10,7 @@ export {
resolveDatabaseTableIndexes,
} from "./database-index";
export { getAuthTablesWithResolvedIndexes } from "./get-tables";
export {
type AccountIdentityStrategy,
resolveAccountIdentity,
} from "./schema/account";
+75 -3
View File
@@ -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<typeof accountSchema>;
/** The stable provider-side key used to recognize an account. */
export type AccountKey = Readonly<Pick<BaseAccount, "issuer" | "accountId">>;
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<AccountKey, { providerId: string }>;
fields: {
providerId: string;
accountId: string;
};
}
| {
key: Extract<AccountKey, { issuer: string }>;
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<BaseAccount, "issuer"> &
(NonNullable<DBOptions> extends { identityStrategy: "issuer" }
? { issuer: string }
: { issuer?: string }) &
InferDBFieldsFromOptions<DBOptions> &
InferDBFieldsFromPlugins<"account", Plugins>
>;
+2 -1
View File
@@ -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(),
@@ -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",
+10
View File
@@ -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.
@@ -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(
+7 -5
View File
@@ -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<Account>({ model: "account", where: [] }),
).toMatchObject({
issuer: identityProvider.issuer.url,
const restoredAccount = await instance.db.findOne<Account>({
model: "account",
where: [],
});
expect(restoredAccount).toMatchObject({
accountId: externalId,
providerId: "workforce",
userId: provisionedUserId,
});
expect(restoredAccount?.issuer).toBeUndefined();
});
});
@@ -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<Account>({
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(
+1
View File
@@ -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()],
});
+11 -7
View File
@@ -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: "",
},
+13 -9
View File
@@ -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(","),
+3 -6
View File
@@ -6565,9 +6565,7 @@ describe("SAML E2E: SP-initiated flow", () => {
| Record<string, any>
| 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",
}),
+2 -4
View File
@@ -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;
}
+65 -60
View File
@@ -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<User>({
model: "user",
data: await generate("user"),
forceAllowId: true,
});
const secondUser = await adapter.create<User>({
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<Account>({
model: "account",
data: firstAccount,
forceAllowId: true,
});
await expect(
adapter.create<Account>({
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<User>({
model: "user",
data: await generate("user"),
forceAllowId: true,
}),
).rejects.toThrow();
await expect(
adapter.create<Account>({
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<User>({
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<Account>({
model: "account",
data: firstAccount,
forceAllowId: true,
});
await expect(
adapter.create<Account>({
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<Account>({
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";
+1 -1
View File
@@ -142,7 +142,7 @@ test("Account with additionalFields", () => {
>();
expectTypeOf<FinalAccount["isVerified"]>().toEqualTypeOf<boolean>();
expectTypeOf<FinalAccount["providerId"]>().toEqualTypeOf<string>();
expectTypeOf<FinalAccount["issuer"]>().toEqualTypeOf<string>();
expectTypeOf<FinalAccount["issuer"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<FinalAccount["accountId"]>().toEqualTypeOf<string>();
expectTypeOf<FinalAccount>().not.toHaveProperty("providerAccountId");
});