# 单点登录（SSO） (/docs/plugins/sso)

将单点登录（SSO）集成到您的应用程序中。



`OIDC` `OAuth2` `SSO` `SAML`

单点登录（SSO）允许用户使用一套凭证登录多个应用。本插件支持 OpenID Connect（OIDC）、OAuth2 提供者和 SAML 2.0。

<Callout>
  需要自助式 SSO，让您的客户可以自行配置 SSO 连接？[联系企业版](/enterprise)。
</Callout>

## 安装 [#安装]

<Steps>
  <Step>
    ### 安装插件 [#安装插件]

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @better-auth/sso
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Step>

  <Step>
    ### 将插件添加到服务器 [#将插件添加到服务器]

    ```ts title="auth.ts"
    import { betterAuth } from "better-auth"
    import { sso } from "@better-auth/sso"; // [!code highlight]

    const auth = betterAuth({
        plugins: [
            sso() // [!code highlight]
        ]
    })
    ```
  </Step>

  <Step>
    ### 迁移数据库 [#迁移数据库]

    运行迁移或生成架构以向数据库添加必要的字段和表。

    <Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
      <Tab value="migrate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth migrate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>

      <Tab value="generate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth generate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>
    </Tabs>

    请参阅 [架构](#schema) 部分以手动添加字段。
  </Step>

  <Step>
    ### 添加客户端插件 [#添加客户端插件]

    ```ts title="auth-client.ts"
    import { createAuthClient } from "better-auth/client"
    import { ssoClient } from "@better-auth/sso/client" // [!code highlight]

    const authClient = createAuthClient({
        plugins: [
            ssoClient() // [!code highlight]
        ]
    })
    ```
  </Step>
</Steps>

## 使用方法 [#使用方法]

### 注册 OIDC 提供者 [#注册-oidc-提供者]

要注册一个 OIDC 提供者，请使用 `registerSSOProvider` 接口并提供相应的配置。

重定向 URL 会自动使用提供者 ID 生成。例如，若提供者 ID 是 `hydra`，则回调 URL 为 `{baseURL}/api/auth/sso/callback/hydra`。注意 `/api/auth` 根据您的基础路径配置可能会有所不同。

<Callout type="info">
  如果您需要所有提供者共享单个回调 URL（例如从其他身份验证提供程序迁移时），请参阅[共享重定向 URI](#shared-redirect-uri)。
</Callout>

<Callout type="info">
  注册 OIDC 提供程序时，Better Auth 会自动获取并验证提供者的 [OIDC 发现文档](#oidc-discovery)。大多数端点字段是可选的——有关自动发现字段和可能的注册错误的详细信息，请参阅[OIDC 发现](#oidc-discovery)。
</Callout>

Better Auth 会根据提供者 ID 和经过验证的 `sub` 声明识别 OIDC 账号。`mapping` 选项用于映射电子邮件、名称和图片等资料字段，但不能替代账号主体。

映射的 email 声明包含地址列表时，Better Auth 会使用第一个地址。Azure AD B2C 用户流程只会在 `emails` 列表中发送地址，因此需为其设置 `mapping: { email: "emails" }`。B2C 不会发送 `email_verified`，所以此类用户创建时邮箱不会被标记为已验证。

#### 示例 [#示例]

<Tabs items="[&#x22;客户端&#x22;, &#x22;服务端&#x22;]">
  <Tab value="client">
    ```ts title="register-oidc-provider.ts"
    import { authClient } from "@/lib/auth-client";

    // 使用 OIDC 配置注册
    await authClient.sso.register({
        providerId: "example-provider",
        issuer: "https://idp.example.com",
        domain: "example.com",
        oidcConfig: {
            clientId: "client-id",
            clientSecret: "client-secret",
            authorizationEndpoint: "https://idp.example.com/authorize",
            tokenEndpoint: "https://idp.example.com/token",
            jwksEndpoint: "https://idp.example.com/jwks",
            discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
            scopes: ["openid", "email", "profile"],
            pkce: true,
            mapping: {
                email: "email",
                emailVerified: "email_verified",
                name: "name",
                image: "picture",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    });
    ```
  </Tab>

  <Tab value="server">
    ```ts title="register-oidc-provider.ts"
    const { headers } = await signInWithTestUser();
    await auth.api.registerSSOProvider({
        body: {
            providerId: "example-provider",
            issuer: "https://idp.example.com",
            domain: "example.com",
            oidcConfig: {
                clientId: "your-client-id",
                clientSecret: "your-client-secret",
                authorizationEndpoint: "https://idp.example.com/authorize",
                tokenEndpoint: "https://idp.example.com/token",
                jwksEndpoint: "https://idp.example.com/jwks",
                discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
                scopes: ["openid", "email", "profile"],
                pkce: true,
                mapping: {
                    email: "email",
                    emailVerified: "email_verified",
                    name: "name",
                    image: "picture",
                    extraFields: {
                        department: "department",
                        role: "role"
                    }
                }
            }
        },
        headers,
    });
    ```
  </Tab>
</Tabs>

### OIDC 发现 [#oidc-发现]

Better Auth 会自动从以下地址获取并验证提供者的 [OpenID Connect 发现文档](https://openid.net/specs/openid-connect-discovery-1_0.html)：

```
{issuer}/.well-known/openid-configuration
```

这使得 `oidcConfig` 中的大多数端点字段变为**可选**，它们会从身份提供者（IdP）自动填充。

**Endpoint:** `POST /sso/register`

> **Note:** 最小 OIDC 配置 — 端点会根据 issuer 自动发现。

### Client Side

```ts
const { data, error } = await authClient.sso.register({
    providerId: "okta", // required, 提供者的唯一标识符。 不得与已配置的社交登录提供者、`accountLinking.trustedProviders` 条目或保留的内置 id （例如 `credential`）冲突。否则注册将被拒绝（422），因为 SSO 提供者 id 与账号关联提供者命名空间共享，冲突可能会继承原本属于该提供者的信任。
    issuer: "https://your-org.okta.com", // required, OIDC 发行者 URL。发现文档将从 `{issuer}/.well-known/openid-configuration` 获取
    domain: "yourcompany.com", // required, 此提供者的裸邮箱域名，或以逗号分隔的多个裸邮箱域名
    oidcConfig: { // required, OIDC 配置（大多数字段会自动发现）
        clientId: "your-client-id", // required, 来自 IdP 的 OAuth 客户端 ID
        clientSecret: "your-client-secret", // required, 来自 IdP 的 OAuth 客户端密钥
    },
});
```

### Server Side

```ts
const data = await auth.api.registerSSOProvider({
    body: {
        providerId: "okta", // required, 提供者的唯一标识符。 不得与已配置的社交登录提供者、`accountLinking.trustedProviders` 条目或保留的内置 id （例如 `credential`）冲突。否则注册将被拒绝（422），因为 SSO 提供者 id 与账号关联提供者命名空间共享，冲突可能会继承原本属于该提供者的信任。
        issuer: "https://your-org.okta.com", // required, OIDC 发行者 URL。发现文档将从 `{issuer}/.well-known/openid-configuration` 获取
        domain: "yourcompany.com", // required, 此提供者的裸邮箱域名，或以逗号分隔的多个裸邮箱域名
        oidcConfig: { // required, OIDC 配置（大多数字段会自动发现）
            clientId: "your-client-id", // required, 来自 IdP 的 OAuth 客户端 ID
            clientSecret: "your-client-secret", // required, 来自 IdP 的 OAuth 客户端密钥
        },
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type registerSSOProvider = {
    /**
     * 提供者的唯一标识符。
     *
     * 不得与已配置的社交登录提供者、`accountLinking.trustedProviders` 条目或保留的内置 id
     *（例如 `credential`）冲突。否则注册将被拒绝（422），因为
     * SSO 提供者 id 与账号关联提供者命名空间共享，冲突可能会继承原本属于该提供者的信任。
     */
    providerId: string = "okta"
    /**
     * OIDC 发行者 URL。发现文档将从 `{issuer}/.well-known/openid-configuration` 获取
     */
    issuer: string = "https://your-org.okta.com"
    /**
     * 此提供者的裸邮箱域名，或以逗号分隔的多个裸邮箱域名
     */
    domain: string = "yourcompany.com"
    /**
     * OIDC 配置（大多数字段会自动发现）
     */
    oidcConfig: {
        /**
         * 来自 IdP 的 OAuth 客户端 ID
         */
        clientId: string = "your-client-id"
        /**
         * 来自 IdP 的 OAuth 客户端密钥
         */
        clientSecret: string = "your-client-secret"
    }
}
```

#### 自动发现字段 [#自动发现字段]

如果未显式提供，Better Auth 会根据 IdP 的发现文档填充以下字段：

* `authorizationEndpoint`
* `tokenEndpoint`
* `jwksEndpoint`
* `userInfoEndpoint`
* `discoveryEndpoint`
* `tokenEndpointAuthentication`（用于令牌端点客户端身份验证的方法）

根据规范，我们的发现流程要求所有 URL 都必须有效且为绝对 URL。也支持相对路径，它们会相对于发行者的基础 URL 解析，并保留路径（如有）。

相对端点以及没有基础路径的发行者示例：

* `issuer`: `"https://your-org.okta.com"`
* `token_endpoint`: `"/v1/tokens"`
* 规范化的 `token_endpoint`: `"https://your-org.okta.com/v1/tokens"`

具有基础路径的发行者和相对端点示例：

* `issuer`: `"https://your-org.okta.com/v1"`
* `token_endpoint`: `"/tokens"`
* 规范化的 `token_endpoint`: `"https://your-org.okta.com/v1/tokens"`

<Callout type="info">
  如果在 `oidcConfig` 中显式设置了这些字段，您的值将覆盖发现到的值。
  当需要使用不完整的模拟服务器或覆盖 IdP 的发布元数据时，这非常有用。
</Callout>

#### 受信任的来源 [#受信任的来源]

发现端点及发现过程中解析到的任意 URL 都必须符合您应用的 [`trustedOrigins`](/docs/reference/security#受信任来源) 配置。
如果未明确添加，将导致发现失败，并返回错误代码 `discovery_untrusted_origin`。

```ts
trustedOrigins: ["https://your-org.okta.com"],
```

如果需要支持多个已知的 IdP（例如 Okta），我们建议：

1. 事先注册一批已知的 IdP 源：

```ts
trustedOrigins: [
    "https://your-org.okta.com",
    "https://accounts.google.com",
    "https://login.microsoftonline.com",
    "https://auth0.com",
    "https://idp.example.com"
];
```

2. 或者通过回调函数动态计算 `trustedOrigins`：

```ts
trustedOrigins: async (request) => {
    // 初始化和 auth.api 调用时 request 可能为 undefined
    if (!request) {
        return ["https://my-frontend.com"];
    }

    // SSO 注册接口的受信列表
    if (request.url.endsWith("/sso/register")) {
        const trustedOrigins = await fetchOriginList();
        return trustedOrigins;
    }

    // 其他请求使用正常的来源列表
    return [];
}
```

更多详细信息请参阅 [`trustedOrigins`](/docs/reference/security#受信任来源) 文档。

#### 重定向 OIDC 端点 [#重定向-oidc-端点]

Better Auth 不会为服务端 OIDC 请求跟随重定向。请为发现、令牌、用户信息和 JWKS 端点配置最终的规范 URL。

如果其中某个端点返回 `3xx` 响应，注册或登录将失败，并返回 `oidc_endpoint_redirect`。这样可以确保端点验证始终绑定到 Better Auth 实际获取的确切 URL。

#### 发现失败的原因 [#发现失败的原因]

Better Auth 会在允许注册之前验证 IdP 元数据是否正确完整，以避免登录或令牌校验时出现细微故障。

<Callout type="info">
  Better Auth **不**支持仅隐式的 OIDC 流。
  因此，即使 OIDC 规范允许仅隐式提供者省略 `token_endpoint`，`token_endpoint` 和 `jwks_uri` 仍是必需的。
</Callout>

#### 发现错误 [#发现错误]

如果身份提供者配置错误或无法访问，注册将失败并返回结构化错误：

| 错误代码                            | 含义                                                           |
| ------------------------------- | ------------------------------------------------------------ |
| `issuer_mismatch`               | IdP 的发现文档报告的 `issuer` 与您配置的不同                                |
| `discovery_incomplete`          | 缺少必需字段（`authorization_endpoint`、`token_endpoint`、`jwks_uri`） |
| `discovery_not_found`           | 发现文档端点返回了 404                                                |
| `discovery_timeout`             | IdP 未在超时窗口内响应（默认：10 秒）                                       |
| `discovery_invalid_url`         | 发现 URL 格式错误或使用了不受支持的协议                                       |
| `discovery_untrusted_origin`    | 发现 URL 或在此过程中发现的某个 URL 未被应用的受信来源配置信任                         |
| `discovery_invalid_json`        | 发现响应为空或不是有效的 JSON                                            |
| `oidc_endpoint_redirect`        | 发现、令牌、用户信息或 JWKS 端点返回了重定向；请改为配置最终端点 URL                      |
| `unsupported_token_auth_method` | IdP 仅支持 Better Auth 不支持的令牌身份验证方法                             |

**支持的令牌身份验证方法：**

* `client_secret_basic`
* `client_secret_post`

<Callout type="warning">
  如果您的 IdP 仅宣传不支持的方法（例如 `private_key_jwt`、`tls_client_auth` 或公共客户端的 `"none"`），您可以通过以下方式显式覆盖该方法：

  ```ts
  oidcConfig: {
      clientId: "your-client-id",
      clientSecret: "your-client-secret",
      tokenEndpointAuthentication: "client_secret_basic", // 覆盖发现结果
  }
  ```

  这在使用模拟 OIDC 服务器或仅宣传 `"none"` 作为支持方法的开发 IdP 时很常见。
</Callout>

#### 总结 [#总结]

* Better Auth 在注册时自动执行 OIDC 发现
* `oidcConfig` 中的大多数端点设置变为可选
* 显式用户配置始终会覆盖发现结果
* 如果 IdP 配置错误，注册会快速失败
* 发现错误是结构化且定义明确的
* 公共客户端 IdP 或模拟服务器可能需要覆盖 `tokenEndpointAuthentication`。

### 解析 SSO 用户 [#解析-sso-用户]

当 OIDC 或 SAML 身份必须映射到现有的 Better Auth 用户，而不是依赖电子邮件匹配时，请使用 `resolveUser`。在协议身份验证完成后，解析器会在每次 SSO 登录时运行。

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";
import {
    findProvisionedUserByAccountKey,
    validateOIDCClaims,
    validateSAMLAttributes,
} from "@/lib/provisioning";

const auth = betterAuth({
    plugins: [
        sso({
            resolveUser: async (input, context) => {
                if (input.providerId !== "workforce") {
                    return { action: "continue" };
                }

                if (input.protocol === "oidc") {
                    await validateOIDCClaims(input.verifiedIdTokenClaims);
                } else {
                    await validateSAMLAttributes(input.providerAttributes);
                }

                const provisionedUser = await findProvisionedUserByAccountKey({
                    providerId: input.providerId,
                    issuer: input.accountKey.issuer,
                    subject: input.accountKey.accountId,
                    database: context.database,
                });

                if (!provisionedUser) {
                    return {
                        action: "reject",
                        code: "PROVISIONED_USER_NOT_FOUND",
                    };
                }

                return {
                    action: "link",
                    userId: provisionedUser.userId,
                    profile: "preserve",
                };
            },
        }),
    ],
});
```

对于 OIDC，`accountKey.issuer` 和 `accountKey.accountId` 来自经过验证的 ID Token 中确切的 `iss` 和 `sub` 声明。`verifiedIdTokenClaims` 包含完整的、经过验证的 ID Token 负载，而 `providerClaims` 包含协议接受的、用于资料的原始声明。对于 SAML，账号密钥是经过验证的 IdP 实体 ID 和已签名的 `NameID`；`providerAttributes` 包含经过验证的断言属性，不会将多值属性强制转换为标量。主体和 `NameID` 匹配区分大小写。

`providerUser` 包含适用于任一协议的规范化资料字段。资料字段、OIDC UserInfo 声明和 SAML 属性在用于授权之前，可能仍需要应用特定的验证。`providerReference` 是对本次身份验证流程所接受的提供者配置的不透明引用。它用于检测流程期间的提供者替换或身份验证配置更改；不要将其持久化为租户或用户绑定。

此示例中的 `findProvisionedUserByAccountKey` 由应用程序负责实现。它应通过事务范围内的 `database` 适配器解析确切的提供者、发行者和主体元组。当确切的预置主体不存在时，不要退回到匹配电子邮件地址。

解析器会返回三种决策之一：

* `{ action: "continue" }` 继续正常的 SSO 登录行为
* `{ action: "link", userId, profile }` 将确切的 SSO 账号关联到该用户。使用 `profile: "preserve"` 保留本地资料，或使用 `profile: "update"` 根据提供者资料更新资料
* `{ action: "reject", code, message? }` 使用应用程序定义的错误拒绝登录

回调会接收活动事务适配器作为 `context.database`。解析器操作应保持数据库本地化和幂等，因为新的 SSO 身份验证尝试可能会再次调用它；已经使用过的 OIDC 回调或 SAML 断言不会被重放。在调用解析器之前，Better Auth 要求使用原生数据库事务，并支持事务异步上下文。如果配置了辅助存储，请设置 `session.storeSessionInDatabase: true`，并将 `session.preserveSessionInDatabase` 保持未设置或设为 `false`，以便数据库继续作为会话回退存储。

通过事务适配器在解析、账号关联、资料更新和数据库会话创建期间执行的数据库写入会一起提交或回滚。解析器或数据库钩子执行的网络调用和其他外部副作用无法回滚。账号和会话 Cookie 仅会在提交后发布。辅助存储中的会话镜像及其他延迟钩子会在提交后运行；即使这些操作失败，也只会记录错误，不会撤销已提交的数据库会话或抑制其 Cookie。

### 注册 SAML 提供者 [#注册-saml-提供者]

使用带有 SAML 配置的 `registerSSOProvider` 接口注册 SAML 提供者。该提供者作为服务提供者（SP）与您的身份提供者（IdP）集成。

Better Auth 会根据提供者 ID 和已签名的 `NameID` 识别 SAML 账号。`issuer` 字段用于标识您的服务提供者，而 IdP 实体 ID 来自 `idpMetadata.metadata`，或在手动配置中显式指定的 `idpMetadata.entityID`。属性映射可以填充资料字段，但不能替代已签名的 `NameID` 账号主体。

需要在存储 SAML 连接时固定 IdP 权威的应用程序，可以通过 SAML 身份验证所使用的同一元数据解析器派生该值：

```ts title="saml-authority.ts"
import { deriveSAMLIdentityProviderEntityID } from "@better-auth/sso";

const identityProviderEntityID =
    deriveSAMLIdentityProviderEntityID(samlConfig);
```

<Tabs items="[&#x22;client&#x22;, &#x22;server&#x22;]">
  <Tab value="client">
    ```ts title="register-saml-provider.ts"
    import { authClient } from "@/lib/auth-client";

    await authClient.sso.register({
        providerId: "saml-provider",
        issuer: "https://yourapp.com/saml",
        domain: "example.com",
        samlConfig: {
            entryPoint: "https://idp.example.com/sso",
            audience: "https://yourapp.com",
            wantAssertionsSigned: true,
            signatureAlgorithm: "sha256",
            digestAlgorithm: "sha256",
            identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
            idpMetadata: {
                metadata: "<!-- IdP 元数据 XML -->",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-encryption-key-password"
            },
            spMetadata: {
                metadata: "<!-- SP 元数据 XML -->",
                binding: "post",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-sp-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-encryption-key-password"
            },
            mapping: {
                email: "email",
                name: "displayName",
                firstName: "givenName",
                lastName: "surname",
                emailVerified: "email_verified",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    });
    ```
  </Tab>

  <Tab value="server">
    ```ts title="register-saml-provider.ts"
    const { headers } = await signInWithTestUser();
    await auth.api.registerSSOProvider({
        body: {
            providerId: "saml-provider",
            issuer: "https://yourapp.com/saml",
            domain: "example.com",
            samlConfig: {
                entryPoint: "https://idp.example.com/sso",
                audience: "https://yourapp.com",
                wantAssertionsSigned: true,
                signatureAlgorithm: "sha256",
                digestAlgorithm: "sha256",
                identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
                idpMetadata: {
                    metadata: "<!-- IdP 元数据 XML -->",
                    privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    privateKeyPass: "your-private-key-password",
                    isAssertionEncrypted: true,
                    encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    encPrivateKeyPass: "your-encryption-key-password"
                },
                spMetadata: {
                    metadata: "<!-- SP 元数据 XML -->",
                    binding: "post",
                    privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    privateKeyPass: "your-sp-private-key-password",
                    isAssertionEncrypted: true,
                    encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    encPrivateKeyPass: "your-encryption-key-password"
                },
                mapping: {
                    email: "email",
                    name: "displayName",
                    firstName: "givenName",
                    lastName: "surname",
                    emailVerified: "email_verified",
                    extraFields: {
                        department: "department",
                        role: "role"
                    }
                }
            }
        },
        headers,
    });
    ```
  </Tab>
</Tabs>

### IdP 发起的 SSO [#idp-发起的-sso]

对于在发送 `state` 参数的情况下发起登录的 OIDC 提供者，请在提供者的 `oidcConfig` 上设置 `allowIdpInitiated: true`。当无状态回调到达 `/sso/callback/:providerId` 时，IdP 签发的 code 会被丢弃，服务端会使用全新的 `state` 和 PKCE 验证器启动新的 OAuth 流程。CSRF 防护仍然有效。此标志默认为 `false`。

<Callout type="warn">
  在 OIDC 提供者上启用 `allowIdpInitiated` 时，请设置全局 `baseURL` 选项。IdP 发起的回调不包含发起请求的请求体，因此跳转会使用 `baseURL` 作为登录后的目标地址。如果未设置 `baseURL`，跳转将失败并返回 `CALLBACK_URL_REQUIRED`。
</Callout>

对于 IdP 发起的流程（例如通过 Okta 控制面板），如果默认处理程序不支持 SAML POST 之后的 `GET` 请求，您的框架可能需要显式的路由处理程序来管理重定向。

<Tabs items="[&#x22;Next.js App Router&#x22;]">
  <Tab value="next-js-app-router">
    创建此文件以防止出现 404 错误：

    ```ts title="app/api/auth/sso/saml2/sp/acs/[providerId]/route.ts"
    import { auth } from "@/lib/auth";
    import { NextResponse } from "next/server";

    export async function POST(req: Request) {
        return auth.handler(req);
    }

    export async function GET(req: Request) {
        // 必需：IdP 发起的流程在 POST 之后会重定向到此 URL
        return NextResponse.redirect(new URL("/", req.url));
    }
    ```
  </Tab>
</Tabs>

### 获取服务提供者元数据 [#获取服务提供者元数据]

对于 SAML 提供者，您可以获取需要配置到 IdP 的服务提供者（SP）元数据 XML：

```ts title="get-sp-metadata.ts"
const response = await auth.api.spMetadata({
    query: {
        providerId: "saml-provider",
        format: "xml" // 可选 "xml" 或 "json"
    }
});

const metadataXML = await response.text();
console.log(metadataXML);
```

### 使用 SSO 登录 [#使用-sso-登录]

调用 `signIn.sso` 即可使用 SSO 提供者登录。

您可以用带域名的邮箱登录：

```ts title="sign-in.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    email: "user@example.com",
    callbackURL: "/dashboard",
});
```

或者您可以单独指定域名：

```ts title="sign-in-domain.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    domain: "example.com",
    callbackURL: "/dashboard",
});
```

如果提供者与组织关联，还可以用组织 slug 登录：

```ts title="sign-in-org.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    organizationSlug: "example-org",
    callbackURL: "/dashboard",
});
```

也可以使用提供者 ID 登录：

```ts title="sign-in-provider-id.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    providerId: "example-provider-id",
    callbackURL: "/dashboard",
});
```

也可以传递登录提示（例如邮箱或其他标识符），用于预填或引导身份提供者：

```ts title="sign-in-with-login-hint.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    providerId: "example-provider-id",
    loginHint: "user@example.com",
    callbackURL: "/dashboard",
});
```

使用服务端 API 可调用 `signInSSO`：

```ts title="sign-in-org.ts"
import { authClient } from "@/lib/auth-client"

const res = await auth.api.signInSSO({
    body: {
        organizationSlug: "example-org",
        callbackURL: "/dashboard",
    }
});
```

#### 完整方法 [#完整方法]

**Endpoint:** `POST /sign-in/sso`

### Client Side

```ts
const { data, error } = await authClient.signIn.sso({
    email: "john@example.com", // 用于登录的电子邮件地址。这用于识别要登录的发行者。如果提供了发行者，则是可选的。
    organizationSlug: "example-org", // 要登录的组织 slug
    providerId: "example-provider", // 要登录的提供者 ID。可以代替电子邮件或发行者提供。
    domain: "example.com", // 提供者的域名
    callbackURL: "https://example.com/callback", // required, 登录后重定向的 URL
    errorCallbackURL: "https://example.com/callback", // 登录后出错的回调 URL
    newUserCallbackURL: "https://example.com/new-user", // 如果用户是新用户，重定向到的 URL
    scopes: ["openid", "email", "profile", "offline_access"], // 从提供者请求的范围
    loginHint: "user@example.com", // 发送到身份提供者的登录提示（例如邮箱或其他标识符）
    requestSignUp: true, // 明确请求注册。当提供者的 disableImplicitSignUp 为 true 时很有用。
});
```

### Server Side

```ts
const data = await auth.api.signInSSO({
    body: {
        email: "john@example.com", // 用于登录的电子邮件地址。这用于识别要登录的发行者。如果提供了发行者，则是可选的。
        organizationSlug: "example-org", // 要登录的组织 slug
        providerId: "example-provider", // 要登录的提供者 ID。可以代替电子邮件或发行者提供。
        domain: "example.com", // 提供者的域名
        callbackURL: "https://example.com/callback", // required, 登录后重定向的 URL
        errorCallbackURL: "https://example.com/callback", // 登录后出错的回调 URL
        newUserCallbackURL: "https://example.com/new-user", // 如果用户是新用户，重定向到的 URL
        scopes: ["openid", "email", "profile", "offline_access"], // 从提供者请求的范围
        loginHint: "user@example.com", // 发送到身份提供者的登录提示（例如邮箱或其他标识符）
        requestSignUp: true, // 明确请求注册。当提供者的 disableImplicitSignUp 为 true 时很有用。
    },
});
```

### Type Definition

```ts
type signInSSO = {
    /**
     * 用于登录的电子邮件地址。这用于识别要登录的发行者。如果提供了发行者，则是可选的。
     */
    email?: string = "john@example.com"
    /**
     * 要登录的组织 slug
     */
    organizationSlug?: string = "example-org"
    /**
     * 要登录的提供者 ID。可以代替电子邮件或发行者提供。
     */
    providerId?: string = "example-provider"
    /**
     * 提供者的域名
     */
    domain?: string = "example.com"
    /**
     * 登录后重定向的 URL
     */
    callbackURL: string = "https://example.com/callback"
    /**
     * 登录后出错的回调 URL
     */
    errorCallbackURL?: string = "https://example.com/callback"
    /**
     * 如果用户是新用户，重定向到的 URL
     */
    newUserCallbackURL?: string = "https://example.com/new-user"
    /**
     * 从提供者请求的范围
     */
    scopes?: string[] = ["openid", "email", "profile", "offline_access"]
    /**
     * 发送到身份提供者的登录提示（例如邮箱或其他标识符）
     */
    loginHint?: string = "user@example.com"
    /**
     * 明确请求注册。当提供者的 disableImplicitSignUp 为 true 时很有用。
     */
    requestSignUp?: boolean = true
}
```

注意：如果提供了电子邮件但未指定登录提示，电子邮件将自动作为登录提示发送给 OIDC 提供者。SAML 流不支持登录提示。

当用户通过身份验证后，如果用户不存在，将使用 `provisionUser` 函数创建用户。默认情况下，`provisionUser` 仅在新用户注册时运行。如果您希望每次登录都运行它（例如同步上游身份提供者的资料变更），请将 `provisionUserOnEveryLogin` 设置为 `true`。如果启用了组织预置且提供者与组织关联，用户将被加入该组织。

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async (user) => {
                // 用户预置逻辑
            },
            provisionUserOnEveryLogin: true, // 可选，默认值：false
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async (user) => {
                    // 需要时获取角色
                },
            },
        }),
    ],
});
```

## 预置（Provisioning） [#预置provisioning]

SSO 插件提供强大的预置功能，可在用户通过 SSO 登录时自动设置用户并管理其组织成员身份。

### 用户预置 [#用户预置]

用户预置允许你在用户通过 SSO 提供者登录时运行自定义逻辑。默认情况下，`provisionUser` 仅在新用户注册时运行。若要在每次登录时都运行它，请将 `provisionUserOnEveryLogin` 设为 `true`。这在以下场景很有用：

* 设置带有 SSO 提供者额外数据的用户资料
* 将用户属性同步到外部系统
* 创建特定于用户的资源
* 记录 SSO 登录事件
* 从 SSO 提供者更新用户信息

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async ({ user, userInfo, token, provider }) => {
                // 使用 SSO 数据更新用户资料
                await updateUserProfile(user.id, {
                    department: userInfo.attributes?.department,
                    jobTitle: userInfo.attributes?.jobTitle,
                    manager: userInfo.attributes?.manager,
                    lastSSOLogin: new Date(),
                });

                // 创建用户专属工作区
                await createUserWorkspace(user.id);

                // 与 CRM 同步用户
                await syncUserWithCRM(user.id, userInfo);

                // 记录 SSO 登录事件
                await auditLog.create({
                    userId: user.id,
                    action: 'sso_signin',
                    provider: provider.providerId,
                    metadata: {
                        email: userInfo.email,
                        ssoProvider: provider.issuer,
                    },
                });
            },
        }),
    ],
});
```

`provisionUser` 函数接收：

* **user**：数据库中的用户对象

* **userInfo**：SSO 提供者返回的用户信息（包含属性、邮箱、姓名等）

* **token**：OAuth2 token（仅 OIDC 提供者，SAML 可能未定义）

* **provider**：SSO 提供者配置

* **user**：数据库中的用户对象

* **userInfo**：SSO 提供者返回的用户信息（包含属性、邮箱、姓名等）

* **token**：OAuth2 token（仅 OIDC 提供者，SAML 可能未定义）

* **provider**：SSO 提供者配置

### 组织预置 [#组织预置]

* 企业 SSO 场景，一个公司/域对应一个组织

* 基于 SSO 属性自动分配角色

* 通过 SSO 管理团队成员身份

* 企业 SSO 场景，一个公司/域对应一个组织

* 基于 SSO 属性自动分配角色

* 通过 SSO 管理团队成员身份

#### 基础组织预置 [#基础组织预置]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,           // 开启组织预置
                defaultRole: "member",     // 新成员默认角色
            },
        }),
    ],
});
```

#### 进阶组织预置与自定义角色 [#进阶组织预置与自定义角色]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async ({ user, userInfo, provider }) => {
                    // 根据 SSO 属性分配角色
                    const department = userInfo.attributes?.department;
                    const jobTitle = userInfo.attributes?.jobTitle;
                    
                    // 基于职位判断管理员
                    if (jobTitle?.toLowerCase().includes('manager') || 
                        jobTitle?.toLowerCase().includes('director') ||
                        jobTitle?.toLowerCase().includes('vp')) {
                        return "admin";
                    }
                    
                    // IT 部门特殊角色
                    if (department?.toLowerCase() === 'it') {
                        return "admin";
                    }
                    
                    // 其它默认成员权限
                    return "member";
                },
            },
        }),
    ],
});
```

#### 将 SSO 提供者关联到组织 [#将-sso-提供者关联到组织]

注册 SSO 提供者时可指定所属组织：

<Callout type="info">
  当启用 `organization` 插件并提供 `organizationId` 时，调用者必须是组织的 `owner` 或 `admin`。普通成员会收到 `403 FORBIDDEN`。未启用 `organization` 插件的 SSO 部署将保持先前的行为（仅查找成员身份）。
</Callout>

```ts title="register-org-provider.ts"
import { auth } from "@/lib/auth"

await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp-saml",
        issuer: "https://yourapp.com/saml/acme-corp",
        domain: "acmecorp.com",
        organizationId: "org_acme_corp_id", // 关联组织
        samlConfig: {
            // SAML 配置...
        },
    },
    headers: await headers() // 包含用户会话令牌的头
});
```

通过此提供者登录的用户会自动加入“Acme
Corp”组织，因为该提供者已明确关联到该组织。通过其他登录方式基于域名进行的分配，则需要域名验证、已验证并存储的用户邮箱，以及明确的组织匹配项。

<Callout type="info">
  自动 SSO 预置不会接受或取消待处理的邀请。受邀用户必须先完成现有的邀请流程，之后 Better Auth 才会为同一组织创建自动成员身份。
</Callout>

#### 自助 SSO 管理面板 [#自助-sso-管理面板]

使用 [Better Auth Infrastructure](https://dash.better-auth.com/sign-in) 可访问自助 SSO 面板，简化企业客户接入流程。组织管理员可生成分享链接，指导企业 IT 配置身份提供者，无需手动交换 SAML 元数据和证书。

地址：

```
https://dash.better-auth.com/[project]/organization/[orgId]/enterprise
```

面板功能：

* **为企业客户生成接入链接**，以便其自助配置 SAML 提供者
* **监控每个组织的 SSO 连接状态**
* **无需编写代码即可管理提供者配置**

极大缩短企业 SSO 上线时间，从数天缩短至几分钟。

#### 多组织示例 [#多组织示例]

可为多个组织配置不同 SSO 提供者：

```ts title="multi-org-setup.ts"
import { auth } from "@/lib/auth"

// Acme Corp SAML 提供者
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp",
        issuer: "https://yourapp.com/saml/acme-corp",
        domain: "acmecorp.com",
        organizationId: "org_acme_id",
        samlConfig: { /* ... */ },
    },
    headers,
});

// TechStart OIDC 提供者
await auth.api.registerSSOProvider({
    body: {
        providerId: "techstart-google",
        issuer: "https://accounts.google.com",
        domain: "techstart.io",
        organizationId: "org_techstart_id",
        oidcConfig: { /* ... */ },
    },
    headers,
});
```

#### 组织预置流程 [#组织预置流程]

1. 用户使用关联组织的 SSO 提供者登录
2. 用户认证成功，在数据库中找到或创建
3. 检查组织成员身份 — 如未加入关联组织
4. 通过 `defaultRole` 或 `getRole` 函数确定角色
5. 将用户加入组织并赋予角色
6. 如配置，执行用户预置以进一步设置

### 预置最佳实践 [#预置最佳实践]

#### 1. 幂等操作 [#1-幂等操作]

如果启用 `provisionUserOnEveryLogin`，请确保预置函数可以安全地多次运行：

```ts
provisionUser: async ({ user, userInfo }) => {
    // 判断是否已预置
    const existingProfile = await getUserProfile(user.id);
    if (!existingProfile.ssoProvisioned) {
        await createUserResources(user.id);
        await markAsProvisioned(user.id);
    }
    
    // 总是更新属性（可能发生变更）
    await updateUserAttributes(user.id, userInfo.attributes);
},
```

#### 2. 错误处理 [#2-错误处理]

优雅地处理错误，避免阻塞用户登录：

```ts
provisionUser: async ({ user, userInfo }) => {
    try {
        await syncWithExternalSystem(user, userInfo);
    } catch (error) {
        // 记录错误但不抛出 — 用户仍能登录
        console.error('同步外部系统失败:', error);
        await logProvisioningError(user.id, error);
    }
},
```

#### 3. 条件预置 [#3-条件预置]

仅在需要时运行某些预置步骤：

```ts
organizationProvisioning: {
    disabled: false,
    getRole: async ({ user, userInfo, provider }) => {
        // 仅为特定提供者处理角色分配
        if (provider.providerId.includes('enterprise')) {
            return determineEnterpriseRole(userInfo);
        }
        return "member";
    },
},
```

## SAML 配置 [#saml-配置]

### 默认 SSO 提供者 [#默认-sso-提供者]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            defaultSSO: [
                {
                    providerId: "default-saml", // 默认提供者 ID
                    domain: "http://your-app.com",
                    samlConfig: {
                        issuer: "https://your-app.com",
                        entryPoint: "https://idp.example.com/sso",
                        cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
                        idpMetadata: {
                            entityID: "https://idp.example.com",
                        },
                        spMetadata: {
                            entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
                            metadata: "<!-- 你的 SP 元数据 XML -->",
                        }
                    }
                }
            ]
        })
    ]
});
```

在以下情况下会使用 defaultSSO 提供者：

1. 数据库中未找到匹配的提供者

这样无需数据库配置即可测试 SAML 认证。defaultSSO 支持与普通 SAML 提供者相同的配置选项。

### 服务提供者配置 [#服务提供者配置]

注册 SAML 提供者时须提供服务提供者（SP）元数据：

* **metadata**：服务提供者的 XML 元数据
* **binding**：绑定方法，通常为 "post" 或 "redirect"
* **privateKey**：用于签名 AuthnRequests 的私钥
* **privateKeyPass**：私钥密码
* **isAssertionEncrypted**：断言是否应加密
* **encPrivateKey**：用于解密的私钥（如果启用了加密）
* **encPrivateKeyPass**：加密私钥密码

### 签名 AuthnRequests [#签名-authnrequests]

部分企业 IdP（如 Okta、Azure AD、ADFS）要求签名的 AuthnRequests。可通过以下配置启用：

```ts
samlConfig: {
    // ... 其他配置
    authnRequestsSigned: true,
    spMetadata: {
        privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
    }
}
```

启用后，SP 元数据端点会自动包含 `AuthnRequestsSigned="true"`。

### 身份提供者配置 [#身份提供者配置]

还须提供身份提供者（IdP）配置：

* **metadata**：来自身份提供者的 XML 元数据
* **privateKey**：IdP 通信所用私钥（可选）
* **privateKeyPass**：IdP 私钥密码（如果加密）
* **isAssertionEncrypted**：来自 IdP 的断言是否加密
* **encPrivateKey**：IdP 断言解密用私钥
* **encPrivateKeyPass**：IdP 解密密钥密码

### 签名证书与轮换 [#签名证书与轮换]

当 IdP 轮换其签名证书时，请将旧 PEM 和新 PEM 都作为数组列在 `idpMetadata.cert` 下。Better Auth 会接受由任一证书签名的 SAML 响应，因此在轮换完成期间用户仍可继续登录。

```ts
samlConfig: {
    entryPoint: "https://idp.example.com/sso",
    idpMetadata: {
        entityID: "https://idp.example.com",
        singleSignOnService: [
            { Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", Location: "https://idp.example.com/sso" },
        ],
        cert: [
            "-----BEGIN CERTIFICATE-----\n<current>\n-----END CERTIFICATE-----",
            "-----BEGIN CERTIFICATE-----\n<next>\n-----END CERTIFICATE-----",
        ],
    },
}
```

`SAMLConfig.cert` 和 `samlConfig.idpMetadata.cert` 都接受单个 PEM 字符串或数组。当两者同时设置时，以 `idpMetadata.cert` 为准。

更改证书不会影响已关联到该提供商的账户。持有所列证书对应私钥的人可以任意这些用户的身份登录；因此，如果组织管理员不应自行更改证书，请在 `guardProviderMutation` 中拒绝会改变认证边界的变更。

如果 IdP 发布元数据 XML 文档，请将其传入 `idpMetadata.metadata`，并不要设置 `cert`。证书会从该文档中获取，并在 IdP 重新发布文档时轮换。如果配置既未提供 `cert` 也未提供 `idpMetadata.metadata`，注册会因 `CERT_SOURCE_MISSING` 失败，因为 samlify 没有可用于验证 SAML 响应的内容。

应用程序可以使用 `guardProviderMutation`，将 SSO 更改与配对的目录同步控制平面协调起来。IdP 实体 ID、端点、签名证书、SP 实体 ID、NameID 格式、POST 断言使用者 URL 以及断言签名策略都属于身份验证边界更改。如果目录已配对时守卫阻止了身份验证边界更改，请取消配对目录，更新 IdP 信任配置，然后重新配对。OIDC 客户端密钥轮换和 SAML SP 私钥轮换不会改变身份验证边界。

### SAML 属性映射 [#saml-属性映射]

配置 SAML 属性与用户字段的映射：

```ts
mapping: {
    email: "email",         // Default: "email" or "nameID"
    name: "displayName",    // Default: "displayName"
    firstName: "givenName", // Default: "givenName"
    lastName: "surname",    // Default: "surname"
    extraFields: {
        department: "department",
        role: "jobTitle",
        phone: "telephoneNumber"
    }
}
```

## SAML 安全 [#saml-安全]

此插件包括可选的安全特性，防护常见的 SAML 漏洞。

### AuthnRequest / InResponseTo 校验 [#authnrequest--inresponseto-校验]

可启用 SP 发起的 SAML 流程中的 InResponseTo 校验。启用后插件会跟踪 AuthnRequest ID 并验证 SAML 响应的 `InResponseTo` 属性，以防范：

* **未请求的响应**：未由合法登录请求触发的响应
* **重放攻击**：重复使用旧的 SAML 响应
* **跨提供者注入**：原本发给其他提供者的响应

<Callout type="info">
  此功能为**可选启用**，以确保向后兼容。若要增强安全性，请显式开启。
</Callout>

#### 启用校验（单实例） [#启用校验单实例]

单实例部署可用内存存储启用校验：

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // 默认启用 InResponseTo 校验
                enableInResponseToValidation: true,
                // 可选：禁止 IdP 发起的 SSO（更严安全）
                allowIdpInitiated: false,
                // AuthnRequest 有效期（默认 5 分钟）
                requestTTL: 10 * 60 * 1000, // 10 分钟
            },
        }),
    ],
});
```

#### 选项说明 [#选项说明]

| 选项                             | 类型        | 默认值            | 描述                                                                 |
| ------------------------------ | --------- | -------------- | ------------------------------------------------------------------ |
| `enableInResponseToValidation` | `boolean` | `true`         | 为 SP 发起流程启用 InResponseTo 校验。                                       |
| `allowIdpInitiated`            | `boolean` | `true`         | 允许 IdP 发起的 SSO（无 InResponseTo 的响应）。若要更严格的安全性请设为 `false`。仅在启用校验时适用。 |
| `requestTTL`                   | `number`  | `300000`（5 分钟） | AuthnRequest 记录的生存时间（毫秒）。超过此时间的请求将被拒绝。                             |

#### 错误处理 [#错误处理]

校验失败时用户会被重定向并附带错误参数：

* `?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID` — 未找到请求 ID 或已过期
* `?error=invalid_saml_response&error_description=Provider+mismatch` — 响应是发给其他提供者的
* `?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed` — 已禁用 IdP 发起的 SSO

### 断言重放保护 [#断言重放保护]

插件包含断言重放保护，防止攻击者捕获并重新提交有效的 SAML 响应。每个断言 ID 会被追踪，重复使用将被拒绝。

<Callout type="info">
  重放保护始终启用。这是一项关键安全特性，可防止攻击者重复使用被截获的 SAML 响应。
</Callout>

#### 工作原理 [#工作原理]

1. 接收 SAML 响应时，从 XML 中提取断言 ID
2. 系统检查该 ID 是否出现过
3. 新断言时，存储 ID 直到其 `NotOnOrAfter` 到期
4. 重复断言（重放攻击）则拒绝

SAML ACS 端点（`/sso/saml2/sp/acs/:providerId`）受到断言重放保护。

<Callout type="info">
  重放保护使用数据库校验表，因此在多实例部署中无需额外配置即可正常工作。
</Callout>

#### 错误处理 [#错误处理-1]

检测到重放攻击时重定向并附加错误：

* `?error=replay_detected&error_description=SAML+assertion+has+already+been+used` — 该断言 ID 已被使用

### 时间戳校验 [#时间戳校验]

插件验证 SAML 断言的时间戳（`NotBefore` 和 `NotOnOrAfter`），避免接收过期或未来时间的断言。验证包含可配置的时钟偏差，以兼容 IdP 与 SP 服务器间的时间差。

#### SAML 规范背景 [#saml-规范背景]

根据 **SAML 2.0 Core 规范**，`NotBefore` 和 `NotOnOrAfter` 是**可选**属性，但广泛采纳的 **SAML2Int**（联邦互操作实现规范）要求：

> “身份提供者必须包含 `<saml:Conditions>` 元素，且必须包括限制断言有效期的 `@NotBefore` 和 `@NotOnOrAfter`。”

Better Auth 提供灵活性支持两者：

* **默认行为**：接受不含时间戳断言（符合 SAML 2.0 Core），但记录警告
* **严格模式**：拒绝无时间戳断言（符合 SAML2Int）

对于每个断言：

* **NotBefore**：若当前时间早于 `NotBefore - clockSkew`，则拒绝该断言
* **NotOnOrAfter**：若当前时间晚于 `NotOnOrAfter + clockSkew`，则拒绝该断言

对于每个断言：

* **NotBefore**：当前时间早于 `NotBefore - clockSkew` 则拒绝
* **NotOnOrAfter**：当前时间晚于 `NotOnOrAfter + clockSkew` 则拒绝

#### 配置示例 [#配置示例]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // 时钟偏差容忍（默认 5 分钟）
                clockSkew: 5 * 60 * 1000,
                // 是否强制要求断言必须有时间戳（默认 false）
                requireTimestamps: false,
            },
        }),
    ],
});
```

#### 选项说明 [#选项说明-1]

| 选项                  | 类型        | 默认值            | 描述                                                                               |
| ------------------- | --------- | -------------- | -------------------------------------------------------------------------------- |
| `clockSkew`         | `number`  | `300000`（5 分钟） | 允许的时钟偏差，单位毫秒。用于容忍 IdP 与 SP 服务器间的时间差。                                             |
| `requireTimestamps` | `boolean` | `false`        | 当为 `true` 时，未包含 `NotBefore`/`NotOnOrAfter` 条件的断言将被拒绝；当为 `false` 时，断言会被接受，但会记录警告。 |

#### 何时启用 `requireTimestamps` [#何时启用-requiretimestamps]

<Callout type="info">
  **建议**：在企业和高安全性部署中启用 `requireTimestamps: true`。
</Callout>

在以下情况下启用 `requireTimestamps: true`：

* 你的 IdP 遵循 **SAML2Int**（如 Okta、Azure AD、OneLogin 等大多数企业 IdP）
* 你需要满足 **SOC 2**、**ISO 27001** 或类似合规要求
* 你希望防止接受格式错误或测试断言
* 你处于配置正确的 IdP 的**生产环境**

在以下情况下保持 `requireTimestamps: false`（默认）：

* 集成可能不包含时间戳的**遗留 IdP**
* 进行带有模拟 IdP 的**开发/测试**
* 你需要对各种 IdP 实现具有**最高兼容性**

默认关闭时适合：

* 集成遗留 IdP，可能不包含时间戳
* 开发测试及模拟 IdP
* 追求最大兼容性的场景

#### 更严格的企业/生产环境配置 [#更严格的企业生产环境配置]

```ts title="auth.ts"
sso({
    saml: {
        clockSkew: 60 * 1000,      // 1 分钟容忍
        requireTimestamps: true,   // 严格拒绝无时间戳断言（SAML2Int）
    },
})
```

#### 错误提示 [#错误提示]

* **"SAML assertion is not yet valid"** — 当前时间早于 `NotBefore` 时间戳（减去时钟偏差）
* **"SAML assertion has expired"** — 当前时间晚于 `NotOnOrAfter` 时间戳（加上时钟偏差）
* **"SAML assertion missing required timestamp conditions"** — 断言没有时间戳且启用了 `requireTimestamps`

### 算法校验 [#算法校验]

Better Auth 默认验证 SAML 加密算法，警告已废弃的算法（SHA-1、RSA 1.5、3DES）。

```ts
sso({
    saml: {
        algorithms: {
            // "warn"（默认） | "reject" | "allow"
            onDeprecated: "warn",
        },
    },
})
```

| 值          | 行为            |
| ---------- | ------------- |
| `"warn"`   | 记录警告，允许认证（默认） |
| `"reject"` | 抛出错误，阻止认证     |
| `"allow"`  | 静默，不进行校验      |

生产环境严格配置示例：

```ts title="auth.ts"
sso({
    saml: {
        algorithms: {
            onDeprecated: "reject",
        },
    },
})
```

#### 支持算法 [#支持算法]

**签名算法：**

* `RSA-SHA256`、`RSA-SHA384`、`RSA-SHA512`
* `ECDSA-SHA256`、`ECDSA-SHA384`、`ECDSA-SHA512`

**摘要算法：**

* `SHA256`、`SHA384`、`SHA512`

**废弃（触发警告/拒绝）：**

* `RSA-SHA1`（签名）

* `SHA1`（摘要）

* `RSA 1.5`（密钥加密）

| 选项                | 默认值   | 描述               |
| ----------------- | ----- | ---------------- |
| `maxResponseSize` | 256KB | SAML 响应最大大小（字节）  |
| `maxMetadataSize` | 100KB | IdP 或 SP 元数据最大大小 |

* `SHA256`、`SHA384`、`SHA512`

**废弃（触发警告/拒绝）：**

| 选项                | 默认值   | 描述               |
| ----------------- | ----- | ---------------- |
| `maxResponseSize` | 256KB | 最大 SAML 响应大小（字节） |
| `maxMetadataSize` | 100KB | 最大 IdP 元数据大小（字节） |

### 尺寸限制 [#尺寸限制]

Better Auth 强制限制 SAML 负载大小，防止通过大型 XML 发起拒绝服务攻击。

| 选项                | 默认值    | 描述              |
| ----------------- | ------ | --------------- |
| `maxResponseSize` | 256 KB | SAML 响应最大大小（字节） |
| `maxMetadataSize` | 100 KB | IdP 元数据最大大小（字节） |

#### 自定义限制 [#自定义限制]

```ts title="auth.ts"
sso({
    saml: {
        // 企业级 IdP 可能需要更大响应限制
        maxResponseSize: 512 * 1024, // 512KB
        maxMetadataSize: 100 * 1024, // 100KB
    },
})
```

<Callout type="info">
  若要在请求到达应用之前就真正提前拒绝超大负载，请在基础设施层配置大小限制（nginx `client_max_body_size`、CDN 设置、负载均衡器）。
</Callout>

## 共享重定向 URI [#共享重定向-uri]

默认每个 OIDC 提供者都有单独的回调 URL（`/sso/callback/:providerId`）。您可配置所有提供者共用单个回调 URI：

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            redirectURI: "/sso/callback" // [!code highlight]
        })
    ]
});
```

该值可为相对路径或完整 URL：

```ts
// 相对路径（追加到 baseURL）
sso({ redirectURI: "/sso/callback" })

// 完整 URL
sso({ redirectURI: "https://login.example.com/callback" })
```

提供者 ID 会存储在 OAuth 状态中，以便回调时识别发起的提供者。

<Callout type="info">
  当使用完整 URL 时，请确保它会路由到您的 Better Auth 实例。
</Callout>

<Callout type="info">
  此选项仅影响 OIDC 提供者。SAML 提供者使用单独的 ACS 端点，并会自动配置。
</Callout>

<Callout type="info">
  共享端点（`/sso/callback`）和按提供者的端点（`/sso/callback/:providerId`）都会始终注册，以保持向后兼容，因此无论此设置如何，现有集成都可以继续工作。
</Callout>

## 域名验证 [#域名验证]

域名验证允许您的应用在验证每个关联域名的所有权后信任新的 SSO 提供者。

验证域名后，该域对应的提供者被允许 **自动账户关联**。即用户使用 SSO 登录且邮箱匹配已存在账户时，会自动关联两者，前提是用户邮箱的域名已通过验证。

在用户通过另一个身份提供者（例如社交提供者）登录后，域名验证也是自动分配组织所必需的。Better Auth 使用已存储用户的已验证邮箱，并且仅在已验证域名恰好映射到一个组织时分配该组织。

<Tabs items="[&#x22;client&#x22;, &#x22;server&#x22;]">
  <Tab value="client">
    ```ts title="auth-client.ts"
    const authClient = createAuthClient({
        plugins: [
            ssoClient({ // [!code highlight]
                domainVerification: { // [!code highlight]
                    enabled: true // [!code highlight]
                } // [!code highlight]
            }) // [!code highlight]
        ]
    })
    ```
  </Tab>

  <Tab value="server">
    ```ts title="auth.ts"
    import { betterAuth } from "better-auth";
    import { sso } from "@better-auth/sso";

    const auth = betterAuth({
        plugins: [
            sso({ // [!code highlight]
                domainVerification: { // [!code highlight]
                    enabled: true // [!code highlight]
                } // [!code highlight]
            }) // [!code highlight]
        ]
    });
    ```
  </Tab>
</Tabs>

启用后请确保再次迁移数据库 schema。

<Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
  <Tab value="migrate">
    ```bash
    npx auth migrate
    ```
  </Tab>

  <Tab value="generate">
    ```bash
    npx auth generate
    ```
  </Tab>
</Tabs>

请参考 [Schema](#if-you-have-enabled-domain-verification) 部分手动添加字段。

<Callout type="info">
  禁用域名验证后，Better Auth 不会根据电子邮件域名匹配关系创建组织成员资格。通过明确关联到某个组织的提供者登录仍会正常配置，而 `signIn.sso` 仍会通过电子邮件或域名解析提供者。
</Callout>

### 验证您的域名 [#验证您的域名]

启用域名验证后，每个新的 SSO 提供者一开始都会是不受信任的。
这意味着在域名所有权验证完成之前，不允许新的注册或登录。

进行域名验证流程：

<Steps>
  <Step>
    #### 获取验证令牌 [#获取验证令牌]

    当注册 SSO 提供者时，会向该提供者发放一个 **验证令牌**（它会作为响应的一部分返回）。
    您可以使用此令牌来证明对该域名的所有权。
  </Step>

  <Step>
    #### 创建 `TXT` DNS 记录 [#创建-txt-dns-记录]

    为此，您需要在域名的 DNS 设置中添加一条 `TXT` 记录：

    * **Host：** `_better-auth-token-{your-provider-id}`（&#x2A;*注意：** 为遵循 DNS 基础设施的子域命名约定，系统会自动在前面添加一个下划线。`better-auth-token` 部分可通过 `domainVerification.tokenPrefix` 选项进行自定义）
    * **Value：** 为您提供的验证令牌。

    如果 SSO 提供者列出了多个以逗号分隔的域名，请在每个列出的域名下都添加此 `TXT` 记录。例如，若提供者的 `domain: "company.com,subsidiary.com"`，则必须分别为 `company.com` 和 `subsidiary.com` 发布该记录。

    **保存记录并等待其传播。** 这可能需要长达 48 小时，但通常会快得多。
  </Step>

  <Step>
    #### 提交验证请求 [#提交验证请求]

    **DNS 记录传播完成后**，您就可以提交验证请求（见下文）
  </Step>
</Steps>

### 域名验证请求 [#域名验证请求]

配置好 DNS 记录后，您可以通过 `auth` 实例提交域名验证请求。
请求会判定所有权是否验证成功，验证完成则标记该 SSO 提供者对应域名为已验证。

验证会应用于请求开始时提供者的确切域名配置。如果在 DNS 验证进行期间提供者被更新或删除，请求会返回带有 `SSO_PROVIDER_CHANGED` 代码的 `409` 响应。重新加载提供者，确认其当前域名，然后重试验证。如果当前提供者没有有效的验证令牌，请先申请新的令牌。

**Endpoint:** `POST /sso/verify-domain`

### Client Side

```ts
const { data, error } = await authClient.sso.verifyDomain({
    providerId: "acme-corp", // required, 提供者 ID
});
```

### Server Side

```ts
const data = await auth.api.verifyDomain({
    body: {
        providerId: "acme-corp", // required, 提供者 ID
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type verifyDomain = {
  /**
  * 提供者 ID
  */
  providerId: string = "acme-corp"
}
```

### 申请新的验证令牌 [#申请新的验证令牌]

每个域名验证令牌默认有效期为 1 周（自颁发或注册时起）。

令牌过期后无法使用，您可重新申请：

**Endpoint:** `POST /sso/request-domain-verification`

### Client Side

```ts
const { data, error } = await authClient.sso.requestDomainVerification({
    providerId: "acme-corp", // required, 提供者 ID
});
```

### Server Side

```ts
const data = await auth.api.requestDomainVerification({
    body: {
        providerId: "acme-corp", // required, 提供者 ID
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type requestDomainVerification = {
  /**
  * 提供者 ID
  */
  providerId: string = "acme-corp"
}
```

### SAML 端点 [#saml-端点]

插件会自动创建以下 SAML 端点：

* **SP Metadata**：`/api/auth/sso/saml2/sp/metadata?providerId={providerId}`
* **SAML ACS（Assertion Consumer Service）**：`/api/auth/sso/saml2/sp/acs/{providerId}`（同时支持 GET 和 POST）

### SAML 回调 URL 配置 [#saml-回调-url-配置]

SAML ACS 端点（`/api/auth/sso/saml2/sp/acs/{providerId}`）同时处理 **SP 发起**和 **IdP 发起**的 SSO 流程：

* **SP 发起**：用户在您的应用中点击“使用 SSO 登录” → 重定向到 IdP → IdP 将 SAMLResponse POST 到回调端点
* **IdP 发起**：用户在 IdP 仪表板中点击应用图标（Okta、Azure AD 等）→ IdP 将 SAMLResponse POST 到回调端点

升级到带用途前缀的验证标识符或用途专属的 state cookie 密钥时，请重新启动待处理的 SP 发起式 SAML 登录，并同时升级参与的服务器。这些流程会使用 Better Auth 生成的 relay state。未经请求的 IdP 发起式流程不会使用该 state，因此不受其格式变化影响。请参阅[验证存储升级说明](/docs/reference/options#verification)和 [state cookie 升级说明](/docs/reference/options#storestatestrategy)。

Better Auth 会根据 `baseURL` 和 `providerId` 自动推导 ACS URL，因此无需在 SP 端进行配置。

如果 IdP 已经向其他 URL 发送请求（例如现有 AD FS relying-party trust），请在 `spMetadata.metadata` 中将该 URL 列为 HTTP-POST `AssertionConsumerService`，并将其 POST 请求转发到 `/sso/saml2/sp/acs/:providerId`。之后 Better Auth 便会接受发往该 URL 的响应。`samlConfig.callbackUrl` 是登录后的重定向地址，不是 ACS URL；请参阅 [1.7 升级指南](/docs/guides/1-7-upgrade-guide#saml-configuration-is-simplified)。

IdP 必须为此 SP 添加 `AudienceRestriction`。可接受的 audience 为 SP entity ID；如果需要匹配 IdP 专属的 SP 标识符，也可以使用 `samlConfig` 中显式设置的 `audience` 值。Better Auth 还会根据此提供商的 ACS 端点检查 bearer `Recipient` 和响应 `Destination` 的值。

登录后的重定向目标由客户端 `signIn.sso()` 调用中的 `callbackURL` 参数控制：

```ts
await authClient.signIn.sso({
  providerId: "my-provider",
  callbackURL: "/dashboard", // 用户在 SSO 后到达的页面
});
```

对于未请求的 **IdP 发起**流程，如果未使用 `signIn.sso()`（因此未设置客户端 `callbackURL`），请在提供者的 `samlConfig` 或全局 `saml` 插件选项中配置 `idpInitiatedCallbackUrl`。提供者级别的设置优先于全局设置，同样的回退机制也适用于验证错误重定向。Better Auth 生成的 `RelayState` 成功和错误回调具有更高的优先级。如果未配置任何回退选项，则保留现有的重定向行为。

更新提供者时，将 `null` 传递给 `samlConfig.idpInitiatedCallbackUrl`，即可移除其覆盖设置并回退到全局设置。

回调路由会自动支持 GET 和 POST 方法，因此无需在您的框架中创建任何额外的路由处理程序。

### 其他提供者字段 [#其他提供者字段]

您可以使用 `schema.ssoProvider.additionalFields` 向 `ssoProvider` 表添加自定义列。这些字段可用于存储提供者元数据，例如组织拥有多个 SSO 提供者时显示的名称。

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

export const auth = betterAuth({
  plugins: [
    sso({
      schema: {
        ssoProvider: {
          additionalFields: {
            displayName: {
              type: "string",
              required: true,
            },
          },
        },
      },
    }),
  ],
});
```

然后在注册或更新提供者时包含该字段：

```ts
await auth.api.registerSSOProvider({
  body: {
    providerId: "okta-main",
    displayName: "Okta",
    issuer: "https://idp.example.com",
    domain: "example.com",
    oidcConfig: {
      clientId: "client-id",
      clientSecret: "client-secret",
    },
  },
  headers,
});
```

## Schema [#schema]

插件要求在 `ssoProvider` 表中添加用于存储提供者配置的字段。



<DatabaseTable name="ssoProvider" fields="ssoProviderTableFields" />

### 若已启用域名验证： [#若已启用域名验证]

`ssProvider` schema 增加：



<DatabaseTable name="ssoProvider" fields="ssoProviderDomainVerificationFields" />

### IdP 发起的 SAML SSO [#idp-发起的-saml-sso]

Better Auth 支持 **IdP 发起的 SSO 流程**，用户从身份提供者仪表盘（如 Okta、Azure AD、OneLogin）直接进入应用，常见于企业集中管理场景。

**流程：**

1. 用户点击 IdP 仪表盘中的应用图标
2. IdP 将 SAMLResponse POST 到 `/api/auth/sso/saml2/sp/acs/{providerId}`
3. Better Auth 处理断言、创建会话，并重定向到你的应用
4. 浏览器通过 GET 请求跟随重定向（自动处理）

无需额外的路由处理器。回调路由会自动处理 GET 和 POST 请求。

<Callout type="warn">
  IdP 发起的流程不包含由 Better Auth 生成的 RelayState，其中没有客户端回调 URL，因此重定向会回退到 Better Auth 基础 URL。在前端应用与 Better Auth 服务器位于不同源的拆分源设置中，这会跳转到身份验证服务器源，并可能导致 404。请在 `saml` 插件选项下全局配置 `idpInitiatedCallbackUrl`，或在每个提供商的 `samlConfig` 下进行配置（例如 `https://app.example.com/dashboard`），以便将成功登录和验证错误返回到前端。
</Callout>

<Callout type="info">
  如果你之前为了兼容而手动为 SAML 回调路由创建了 GET 处理器，在升级后可以将其移除。Better Auth 现在会自动处理 GET 请求。
</Callout>

<Callout type="info">
  **安全性：** Better Auth 会验证所有重定向 URL，以防止开放重定向攻击。仅允许相对路径（例如 `/dashboard`）以及与你配置的 `trustedOrigins` 匹配的 URL。诸如 `https://evil.com` 这类恶意 URL 或协议相对 URL（`//evil.com`）会被自动阻止。
</Callout>

有关 Okta 和 DummyIDP 的详细 SAML SSO 教程，见 [SAML SSO with Okta](/docs/guides/saml-sso-with-okta)。

## 选项 [#选项]

### 服务器端 [#服务器端]

* **provisionUser**：用户登录时的自定义预置函数。
* **organizationProvisioning**：用户自动加入组织的相关配置。
* **defaultOverrideUserInfo**：默认用提供者信息覆盖用户信息。
* **disableImplicitSignUp**：禁用隐式自动注册，需显式请求注册。

**provisionUserOnEveryLogin**：如果为 `true`，则每次登录都会调用 `provisionUser` 回调，而不只是新用户注册时。默认值为 `false`。

**organizationProvisioning**：将用户预置到组织中的选项。

**defaultOverrideUserInfo**：默认用提供者信息覆盖用户信息。

**disableImplicitSignUp**：禁用隐式注册。

如果您想为特定受信任的提供者允许账户关联，请在 auth 配置中启用 `accountLinking` 选项，并在 `trustedProviders` 列表中指定这些提供者。



<TypeTable type="ssoOptionsType" />

