单点登录 (SSO)

OIDC OAuth2 SSO SAML

单点登录(SSO)允许用户使用一组凭据在多个应用中进行身份验证。此插件支持 OpenID Connect(OIDC)、OAuth2 提供商和 SAML 2.0。

【Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC), OAuth2 providers, and SAML 2.0.】

SAML 2.0 支持正在积极开发中,可能不适合用于生产环境。请在 GitHub 上报告任何问题或漏洞。

安装

【Installation】

安装插件

npm install @better-auth/sso

Add Plugin to the server

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

const auth = betterAuth({
    plugins: [ 
        sso() 
    ] 
})

Migrate the database

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

npx @better-auth/cli migrate
npx @better-auth/cli generate

See the Schema section to add the fields manually.

Add the client plugin

auth-client.ts
import { createAuthClient } from "better-auth/client"
import { ssoClient } from "@better-auth/sso/client"

const authClient = createAuthClient({
    plugins: [ 
        ssoClient() 
    ] 
})

用法

【Usage】

注册 OIDC 提供商

【Register an OIDC Provider】

要注册 OIDC 提供商,请使用 registerSSOProvider 端点,并提供该提供商所需的配置详细信息。

【To register an OIDC provider, use the registerSSOProvider endpoint and provide the necessary configuration details for the provider.】

重定向 URL 将使用提供者 ID 自动生成。例如,如果提供者 ID 是 hydra,则重定向 URL 将是 {baseURL}/api/auth/sso/callback/hydra。请注意,/api/auth 可能会根据你的基础路径配置而有所不同。

【A redirect URL will be automatically generated using the provider ID. For instance, if the provider ID is hydra, the redirect URL would be {baseURL}/api/auth/sso/callback/hydra. Note that /api/auth may vary depending on your base path configuration.】

当你注册 OIDC 提供者时,Better Auth 会自动获取并验证 IdP 的 OIDC 发现文档。大多数端点字段是可选的 — 有关自动发现字段和可能的注册错误的详细信息,请参阅 OIDC 发现

示例

【Example】

register-oidc-provider.ts
import { authClient } from "@/lib/auth-client";

// Register with OIDC configuration
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: {
            id: "sub",
            email: "email",
            emailVerified: "email_verified",
            name: "name",
            image: "picture",
            extraFields: {
                department: "department",
                role: "role"
            }
        }
    }
});
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: {
                id: "sub",
                email: "email",
                emailVerified: "email_verified",
                name: "name",
                image: "picture",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    },
    headers,
});

OIDC 发现

【OIDC Discovery】

Better Auth 会自动从以下位置获取并验证提供者的 OpenID Connect 发现文档

【Better Auth automatically fetches and validates the provider's OpenID Connect Discovery Document from:】

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

这允许 oidcConfig 中的大多数端点相关字段为可选 —— 它们将自动从身份提供者(IdP)获取。

【This allows most endpoint-related fields in oidcConfig to be optional — they will be hydrated automatically from the Identity Provider (IdP).】

POST
/sso/register
Notes

Minimal OIDC configuration — endpoints are discovered automatically from the issuer.

const { data, error } = await authClient.sso.register({    providerId: "okta", // required    issuer: "https://your-org.okta.com", // required    domain: "yourcompany.com", // required    oidcConfig: { // required        clientId: "your-client-id", // required        clientSecret: "your-client-secret", // required    },});
PropDescriptionType
providerId
Unique identifier for the provider
string
issuer
The OIDC issuer URL. Discovery document is fetched from {issuer}/.well-known/openid-configuration
string
domain
Email domain for this provider
string
oidcConfig
OIDC configuration (most fields are auto-discovered)
Object
oidcConfig.clientId
OAuth client ID from your IdP
string
oidcConfig.clientSecret
OAuth client secret from your IdP
string

自动发现的字段

【Fields Automatically Discovered】

Better Auth 通过读取 IdP 的发现文档(如果未明确提供)来填写以下字段:

【Better Auth fills in the following fields by reading the IdP's discovery document (if not explicitly provided):】

  • authorizationEndpoint
  • tokenEndpoint
  • jwksEndpoint
  • userInfoEndpoint
  • discoveryEndpoint
  • tokenEndpointAuthentication(令牌端点客户端认证的方法)

根据规范,我们的发现流程预期所有 URL 都是有效的,并且应为绝对 URL。也支持相对路径,并会相对于发行者的基础 URL 进行解析,在可用时保留路径。

【Following the spec, our discovery process expects all URLs to be valid and to be absolute urls. Relative paths are also supported and resolved relative to the issuer's base URL preserving the path when available.】

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

【Example of relative endpoint and issuer without base path:】

  • issuerhttps://your-org.okta.com
  • token_endpoint"/v1/tokens"
  • 规范化的 token_endpoint"https://your-org.okta.com/v1/tokens"

带有基本路径的相对端点和发行者示例:

【Example of relative endpoint and issuer with base path:】

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

如果你在 oidcConfig 中显式设置这些字段,你的值将覆盖发现到的值。 当你需要覆盖身份提供商(IdP)发布的元数据或使用不完整的模拟服务器时,这非常有用。

受信任的来源

【Trusted origins】

发现端点以及通过发现过程解析的任何 URL 都受你应用的 trustedOrigins 配置限制。除非你明确更新 trustedOrigins 配置,否则发现将以 discovery_untrusted_origin 错误代码失败:

【Both the discovery endpoint as well as any URL resolved through the discovery process are subject to your app's trustedOrigins configuration. Discovery will fail with the discovery_untrusted_origin code unless you explicitly update your trustedOrigins configuration:】

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

如果你的使用场景需要支持多个任意但已知的身份提供商(例如 Okta),我们建议:

【If your use-case requires to support multiple arbitrary but known IDPs (e.g Okta), we recommend to:】

  1. 提前注册一个知名身份提供者(IDP)列表
trustedOrigins: [
    "https://your-org.okta.com",
    "https://accounts.google.com",
    "https://login.microsoftonline.com",
    "https://auth0.com",
    "https://idp.example.com"
],
  1. 或者通过指定回调函数动态计算 trustedOrigins
trustedOrigins: async (request) => {
    // request is undefined during initialization and auth.api calls
    if (!request) {
        return ["https://my-frontend.com"];
    }

    // SSO trusted origin list
    if (request.url.endsWith("/sso/register")) {
        const trustedOrigins = await fetchOriginList();
        return trustedOrigins;
    }

    // Your normal origin list for everything else
    return [];
}

有关更多信息,请参阅 trustedOrigins 文档。

【See the trustedOrigins docs for more information.】

探索为何可能失败

【Why Discovery Can Fail】

Better Auth 会在允许注册之前验证 IdP 的元数据是否正确且完整。这可以防止在登录或令牌验证过程中出现细微的运行时错误。

【Better Auth validates that the IdP's metadata is correct and complete before allowing registration. This prevents subtle runtime failures during sign-in or token validation.】

Better Auth 支持仅隐式的 OIDC 流程。因此,即使 OIDC 规范允许仅隐式的提供商省略 token_endpointtoken_endpointjwks_uri 仍然是必需的。

发现错误

【Discovery Errors】

如果身份提供者配置错误或无法访问,注册将因结构化错误而失败。

【If the Identity Provider is misconfigured or unreachable, registration will fail with a structured error.】

错误代码含义
issuer_mismatchIdP 的发现文档报告的 issuer 与你配置的不一致
discovery_incomplete缺少必填字段(authorization_endpointtoken_endpointjwks_uri
discovery_not_found发现文档端点返回 404
discovery_timeoutIdP 在超时时间内未响应(默认:10 秒)
discovery_invalid_url发现 URL 格式错误或使用不支持的协议
discovery_untrusted_origin发现 URL 或在此过程中发现的某个 URL 未被你的应用的受信任来源配置信任
discovery_invalid_json发现响应为空或不是有效的 JSON
unsupported_token_auth_methodIdP 仅支持 Better Auth 不支持的令牌认证方法

支持的令牌认证方法:

  • client_secret_basic
  • client_secret_post

如果你的身份提供者(IdP)只宣传不受支持的方法(例如 private_key_jwttls_client_auth 或针对公共客户端的 "none"),你可以显式地覆盖该方法:

oidcConfig: {
    clientId: "your-client-id",
    clientSecret: "your-client-secret",
    tokenEndpointAuthentication: "client_secret_basic", // Override discovery
}

这在模拟 OIDC 服务器或仅宣传“none”作为支持方法的开发身份提供者中尤其常见。

摘要

【Summary】

  • Better Auth 会在注册时自动执行 OIDC 发现
  • oidcConfig 中的大多数端点设置变为可选
  • 显式用户配置总是优先于自动发现
  • 如果身份提供者配置错误,注册会快速失败
  • 发现错误是有结构且明确的
  • 公共客户端身份提供商或模拟服务器可能需要覆盖 tokenEndpointAuthentication

注册 SAML 提供商

【Register a SAML Provider】

要注册 SAML 提供商,请使用 registerSSOProvider 端点并提供 SAML 配置详细信息。该提供商将作为服务提供商(SP)运行,并与你的身份提供商(IdP)集成。

【To register a SAML provider, use the registerSSOProvider endpoint with SAML configuration details. The provider will act as a Service Provider (SP) and integrate with your Identity Provider (IdP).】

register-saml-provider.ts
import { authClient } from "@/lib/auth-client";

await authClient.sso.register({
    providerId: "saml-provider",
    issuer: "https://idp.example.com",
    domain: "example.com",
    samlConfig: {
        entryPoint: "https://idp.example.com/sso",
        cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
        callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
        audience: "https://yourapp.com",
        wantAssertionsSigned: true,
        signatureAlgorithm: "sha256",
        digestAlgorithm: "sha256",
        identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
        idpMetadata: {
            metadata: "<!-- IdP Metadata 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 Metadata 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-sp-encryption-key-password"
        },
        mapping: {
            id: "nameID",
            email: "email",
            name: "displayName",
            firstName: "givenName",
            lastName: "surname",
            emailVerified: "email_verified",
            extraFields: {
                department: "department",
                role: "role"
            }
        }
    }
});
register-saml-provider.ts
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
    body: {
        providerId: "saml-provider",
        issuer: "https://idp.example.com",
        domain: "example.com",
        samlConfig: {
            entryPoint: "https://idp.example.com/sso",
            cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
            callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
            audience: "https://yourapp.com",
            wantAssertionsSigned: true,
            signatureAlgorithm: "sha256",
            digestAlgorithm: "sha256",
            identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
            idpMetadata: {
                metadata: "<!-- IdP Metadata 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 Metadata 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-sp-encryption-key-password"
            },
            mapping: {
                id: "nameID",
                email: "email",
                name: "displayName",
                firstName: "givenName",
                lastName: "surname",
                emailVerified: "email_verified",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    },
    headers,
});

获取服务提供商元数据

【Get Service Provider Metadata】

对于 SAML 提供商,你可以检索需要在你的身份提供商中配置的服务提供商元数据 XML:

【For SAML providers, you can retrieve the Service Provider metadata XML that needs to be configured in your Identity Provider:】

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

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

使用单点登录登录

【Sign In with SSO】

要使用 SSO 提供商登录,你可以调用 signIn.sso

【To sign in with an SSO provider, you can call signIn.sso

你可以使用域名匹配的邮箱登录:

【You can sign in using the email with domain matching:】

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

或者你可以指定域名:

【or you can specify the domain:】

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

如果提供者与某个组织关联,你也可以使用组织标识登录:

【You can also sign in using the organization slug if a provider is associated with an organization:】

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

或者,你可以使用提供商的 ID 登录:

【Alternatively, you can sign in using the provider's ID:】

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

你可以选择传递登录提示(例如电子邮件地址或其他标识符)来预先填充或指向身份提供者:

【Optionally, you can pass a login hint (for example, an email address or another identifier) to prefill or direct the identity provider:】

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

要使用服务器 API,你可以使用 signInSSO

【To use the server API you can use signInSSO

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

完整方法

【Full method】

POST
/sign-in/sso
const { data, error } = await authClient.signIn.sso({    email: "john@example.com",    organizationSlug: "example-org",    providerId: "example-provider",    domain: "example.com",    callbackURL: "https://example.com/callback", // required    errorCallbackURL: "https://example.com/callback",    newUserCallbackURL: "https://example.com/new-user",    scopes: ["openid", "email", "profile", "offline_access"],    loginHint: "user@example.com",    requestSignUp: true,});
PropDescriptionType
email?
The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided.
string
organizationSlug?
The slug of the organization to sign in with.
string
providerId?
The ID of the provider to sign in with. This can be provided instead of email or issuer.
string
domain?
The domain of the provider.
string
callbackURL
The URL to redirect to after login.
string
errorCallbackURL?
The URL to redirect to after login.
string
newUserCallbackURL?
The URL to redirect to after login if the user is new.
string
scopes?
Scopes to request from the provider.
string[]
loginHint?
Login hint to send to the identity provider (e.g., email or identifier).
string
requestSignUp?
Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider.
boolean

注意:如果提供了电子邮件且未指定 loginHint,电子邮件将自动作为 login_hint 发送给 OIDC 提供程序。SAML 流程不支持 login_hint。

【Note: If email is provided and loginHint is not specified, email will be sent as the login_hint to OIDC providers automatically. SAML flows do not support login_hint.】

当用户通过身份验证时,如果用户不存在,将使用 provisionUser 函数创建用户。如果启用了组织配置并且有提供者与组织关联,用户将被添加到该组织。

【When a user is authenticated, if the user does not exist, the user will be provisioned using the provisionUser function. If the organization provisioning is enabled and a provider is associated with an organization, the user will be added to the organization.】

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async (user) => {
                // provision user
            },
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async (user) => {
                    // get role if needed
                },
            },
        }),
    ],
});

配置

【Provisioning】

SSO 插件提供强大的配置功能,可在用户通过 SSO 提供商登录时自动设置用户并管理其组织成员资格。

【The SSO plugin provides powerful provisioning capabilities to automatically set up users and manage their organization memberships when they sign in through SSO providers.】

用户配置

【User Provisioning】

用户配置允许你在用户通过 SSO 提供商登录时运行自定义逻辑。这在以下情况下非常有用:

【User provisioning allows you to run custom logic whenever a user signs in through an SSO provider. This is useful for:】

  • 使用来自 SSO 提供商的附加数据设置用户配置文件
  • 将用户属性与外部系统同步
  • 创建用户特定的资源
  • 记录 SSO 登录
  • 从单点登录提供商更新用户信息
auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async ({ user, userInfo, token, provider }) => {
                // Update user profile with SSO data
                await updateUserProfile(user.id, {
                    department: userInfo.attributes?.department,
                    jobTitle: userInfo.attributes?.jobTitle,
                    manager: userInfo.attributes?.manager,
                    lastSSOLogin: new Date(),
                });

                // Create user-specific resources
                await createUserWorkspace(user.id);

                // Sync with external systems
                await syncUserWithCRM(user.id, userInfo);

                // Log the SSO sign-in
                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 令牌(用于 OIDC 提供商) - SAML 可能未定义
  • provider:SSO 提供商配置

【The provisionUser function receives:

  • user: The user object from the database
  • userInfo: User information from the SSO provider (includes attributes, email, name, etc.)
  • token: OAuth2 tokens (for OIDC providers) - may be undefined for SAML
  • provider: The SSO provider configuration】

组织配置

【Organization Provisioning】

当将 SSO 提供商与特定组织关联时,组织供应会自动管理用户在组织中的成员资格。这对于以下情况特别有用:

【Organization provisioning automatically manages user memberships in organizations when SSO providers are linked to specific organizations. This is particularly useful for:】

  • 企业单点登录,每个公司/域对应一个组织
  • 基于SSO属性的自动角色分配
  • 通过SSO管理团队成员

基本组织配置

【Basic Organization Provisioning】

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,           // Enable org provisioning
                defaultRole: "member",     // Default role for new members
            },
        }),
    ],
});

使用自定义角色的高级组织配置

【Advanced Organization Provisioning with Custom Roles】

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async ({ user, userInfo, provider }) => {
                    // Assign roles based on SSO attributes
                    const department = userInfo.attributes?.department;
                    const jobTitle = userInfo.attributes?.jobTitle;
                    
                    // Admins based on job title
                    if (jobTitle?.toLowerCase().includes('manager') || 
                        jobTitle?.toLowerCase().includes('director') ||
                        jobTitle?.toLowerCase().includes('vp')) {
                        return "admin";
                    }
                    
                    // Special roles for IT department
                    if (department?.toLowerCase() === 'it') {
                        return "admin";
                    }
                    
                    // Default to member for everyone else
                    return "member";
                },
            },
        }),
    ],
});

将 SSO 提供商链接到组织

【Linking SSO Providers to Organizations】

在注册 SSO 提供商时,你可以将其链接到特定的组织:

【When registering an SSO provider, you can link it to a specific organization:】

register-org-provider.ts
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp-saml",
        issuer: "https://acme-corp.okta.com",
        domain: "acmecorp.com",
        organizationId: "org_acme_corp_id", // Link to organization
        samlConfig: {
            // SAML configuration...
        },
    },
    headers,
});

现在,当来自 acmecorp.com 的用户通过此提供商登录时,他们将自动被添加到“Acme Corp”组织中,并分配相应的角色。

【Now when users from acmecorp.com sign in through this provider, they'll automatically be added to the "Acme Corp" organization with the appropriate role.】

多组织示例

【Multiple Organizations Example】

你可以为不同的组织设置多个单点登录(SSO)提供商:

【You can set up multiple SSO providers for different organizations:】

multi-org-setup.ts
// Acme Corp SAML provider
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp",
        issuer: "https://acme.okta.com",
        domain: "acmecorp.com",
        organizationId: "org_acme_id",
        samlConfig: { /* ... */ },
    },
    headers,
});

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

组织配置流程

【Organization Provisioning Flow】

  1. 用户通过与组织关联的单点登录(SSO)提供商登录
  2. 用户已通过身份验证,并已在数据库中找到或创建
  3. 检查组织成员资格 - 如果用户尚未成为关联组织的成员
  4. 角色的确定 使用 defaultRolegetRole 函数
  5. 用户已被添加到组织中并分配了确定的角色
  6. 用户配置运行(如果已配置)以进行额外设置

配置最佳实践

【Provisioning Best Practices】

1. 幂等操作

【1. Idempotent Operations】

确保你的配置功能可以安全地多次运行:

【Make sure your provisioning functions can be safely run multiple times:】

provisionUser: async ({ user, userInfo }) => {
    // Check if already provisioned
    const existingProfile = await getUserProfile(user.id);
    if (!existingProfile.ssoProvisioned) {
        await createUserResources(user.id);
        await markAsProvisioned(user.id);
    }
    
    // Always update attributes (they might change)
    await updateUserAttributes(user.id, userInfo.attributes);
},

2. 错误处理

【2. Error Handling】

优雅地处理错误以避免阻碍用户登录:

【Handle errors gracefully to avoid blocking user sign-in:】

provisionUser: async ({ user, userInfo }) => {
    try {
        await syncWithExternalSystem(user, userInfo);
    } catch (error) {
        // Log error but don't throw - user can still sign in
        console.error('Failed to sync user with external system:', error);
        await logProvisioningError(user.id, error);
    }
},

3. 条件性配置

【3. Conditional Provisioning】

仅在需要时运行特定的配置步骤:

【Only run certain provisioning steps when needed:】

organizationProvisioning: {
    disabled: false,
    getRole: async ({ user, userInfo, provider }) => {
        // Only process role assignment for certain providers
        if (provider.providerId.includes('enterprise')) {
            return determineEnterpriseRole(userInfo);
        }
        return "member";
    },
},

SAML 配置

【SAML Configuration】

默认 SSO 提供商

【Default SSO Provider】

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            defaultSSO: [
                {
                    providerId: "default-saml", // Provider ID for the default provider
                    domain: "http://your-app.com",
                    samlConfig: {
                        issuer: "https://your-app.com",
                        entryPoint: "https://idp.example.com/sso",
                        cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
                        callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs",
                        spMetadata: {
                            entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
                            metadata: "<!-- Your SP Metadata XML -->",
                        }
                    }
                }
            ]
        })
    ]
});

当满足以下条件时,将使用默认的 SSO 提供程序:

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

【The defaultSSO provider will be used when:

  1. No matching provider is found in the database】

这使你可以在不在数据库中设置提供程序的情况下测试 SAML 身份验证。defaultSSO 提供程序支持与常规 SAML 提供程序相同的所有配置选项。

【This allows you to test SAML authentication without setting up providers in the database. The defaultSSO provider supports all the same configuration options as regular SAML providers.】

服务提供商配置

【Service Provider Configuration】

在注册 SAML 提供商时,你需要提供服务提供商 (SP) 的元数据配置:

【When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration:】

  • 元数据:服务提供商的 XML 元数据
  • 绑定:绑定方法,通常为“post”或“redirect”
  • privateKey:用于签名的私钥(可选)
  • privateKeyPass:私钥密码(如果已加密)
  • isAssertionEncrypted:断言是否应被加密
  • encPrivateKey:用于解密的私钥(如果启用了加密)
  • encPrivateKeyPass:加密私钥的密码

身份提供者配置

【Identity Provider Configuration】

你还需要提供身份提供者 (IdP) 配置:

【You also need to provide Identity Provider (IdP) configuration:】

  • 元数据:来自你的身份提供者的 XML 元数据
  • privateKey:用于 IdP 通信的私钥(可选)
  • privateKeyPass:IdP 私钥的密码(如果已加密)
  • isAssertionEncrypted:来自身份提供者(IdP)的断言是否被加密
  • encPrivateKey:用于 IdP 断言解密的私钥
  • encPrivateKeyPass:身份提供者解密密钥的密码

SAML 属性映射

【SAML Attribute Mapping】

配置 SAML 属性与用户字段的映射方式:

【Configure how SAML attributes map to user fields:】

mapping: {
    id: "nameID",           // Default: "nameID"
    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 Security】

SSO 插件包括可选的安全功能,以防范常见的 SAML 漏洞。

【The SSO plugin includes optional security features to protect against common SAML vulnerabilities.】

AuthnRequest / InResponseTo 验证

【AuthnRequest / InResponseTo Validation】

你可以为 SP 发起的 SAML 流程启用 InResponseTo 验证。启用后,插件会跟踪 AuthnRequest ID 并验证 SAML 响应中的 InResponseTo 属性。这可以防止:

【You can enable InResponseTo validation for SP-initiated SAML flows. When enabled, the plugin tracks AuthnRequest IDs and validates the InResponseTo attribute in SAML responses. This prevents:】

  • 未经请求的响应:不是由合法登录请求触发的响应
  • 重放攻击:重用旧的 SAML 响应
  • 跨提供商注入:原本面向不同提供商的响应

此功能为可选择启用,以确保向后兼容性。请明确启用以增强安全性。

启用验证(单实例)

【Enabling Validation (Single Instance)】

对于单实例部署,请启用内置内存存储的验证:

【For single-instance deployments, enable validation with the built-in in-memory store:】

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

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // Enable InResponseTo validation
                enableInResponseToValidation: true,
                // Optionally reject IdP-initiated SSO (stricter security)
                allowIdpInitiated: false,
                // Custom TTL for AuthnRequest validity (default: 5 minutes)
                requestTTL: 10 * 60 * 1000, // 10 minutes
            },
        }),
    ],
});

选项

【Options】

选项类型默认值描述
enableInResponseToValidationbooleanfalse启用针对 SP 发起流程的 InResponseTo 验证
allowIdpInitiatedbooleantrue允许 IdP 发起的 SSO(没有 InResponseTo 的响应)。对于更严格的安全性,请设置为 false。仅在启用验证时适用
requestTTLnumber300000(5 分钟)AuthnRequest 记录的生存时间(以毫秒为单位)。超过该时间的请求将被拒绝

错误处理

【Error Handling】

当 InResponseTo 验证失败时,用户会被重定向,并带有错误查询参数:

【When InResponseTo validation fails, users are redirected with an error query parameter:】

  • ?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 发起的 SSO — 已禁用 IdP 发起的 SSO

断言重放防护

【Assertion Replay Protection】

SSO 插件包含断言重放保护功能,以防止攻击者捕获并重新提交有效的 SAML 响应。每个 SAML 断言 ID 都会被跟踪,如果被重复使用将被拒绝。

【The SSO plugin includes assertion replay protection to prevent attackers from capturing and resubmitting valid SAML responses. Each SAML Assertion ID is tracked and rejected if reused.】

重放保护始终启用。这是一个关键的安全功能,可防止攻击者重用截获的 SAML 响应。

运作原理

【How It Works】

  1. 当收到 SAML 响应时,会从 XML 中提取断言 ID
  2. 系统会检查该断言 ID 是否之前已出现过
  3. 如果这是一个新的断言,它会被存储在数据库中,直到其 NotOnOrAfter 到期
  4. 如果是重复请求(重放攻击),该请求将被拒绝

两个 SAML 端点都是受保护的:

  • /sso/saml2/callback/:providerId
  • /sso/saml2/sp/acs/:providerId

重放保护使用数据库验证表,因此在多实例部署中无需额外配置即可正常工作。

错误处理

【Error Handling】

当检测到重放攻击时,用户会被重定向并显示错误:

【When a replay attack is detected, users are redirected with an error:】

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

时间戳验证

【Timestamp Validation】

SSO 插件会验证 SAML 断言的时间戳(NotBeforeNotOnOrAfter),以防止接受已过期或未来日期的断言。此验证包括可配置的时钟偏差容忍,以考虑服务器之间的时间差异。

【The SSO plugin validates SAML assertion timestamps (NotBefore and NotOnOrAfter) to prevent acceptance of expired or future-dated assertions. This validation includes a configurable clock skew tolerance to account for time differences between servers.】

SAML 规范背景

【SAML Specification Background】

根据 SAML 2.0 核心规范NotBeforeNotOnOrAfter 属性是 可选的。然而,广泛采用的 SAML2Int(用于联合互操作性的 SAML V2.0 实现规范)要求这些时间戳:

【According to the SAML 2.0 Core specification, NotBefore and NotOnOrAfter attributes are optional. However, the widely-adopted SAML2Int (SAML V2.0 Implementation Profile for Federation Interoperability) specification requires these timestamps:】

“身份提供者必须包含一个 <saml:Conditions> 元素。限制断言有效期限的条件,即 @NotBefore@NotOnOrAfter,必须包含在内。”

Better Auth 提供灵活性以支持两种模式:

  • 默认行为:接受没有时间戳的断言(符合 SAML 2.0 核心规范),但会记录警告
  • 严格模式:拒绝没有时间戳的断言(符合 SAML2Int 规范)

【Better Auth provides flexibility to support both:

  • Default behavior: Accepts assertions without timestamps (SAML 2.0 Core compliant) but logs a warning
  • Strict mode: Rejects assertions without timestamps (SAML2Int compliant)】

运作原理

【How It Works】

对于每个 SAML 断言:

  • NotBefore:如果当前时间早于 NotBefore - clockSkew,则断言被拒绝
  • NotOnOrAfter:如果当前时间晚于 NotOnOrAfter + clockSkew,则断言被拒绝

【For each SAML assertion:

  • NotBefore: The assertion is rejected if current time is before NotBefore - clockSkew
  • NotOnOrAfter: The assertion is rejected if current time is after NotOnOrAfter + clockSkew

配置

【Configuration】

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

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // Clock skew tolerance (default: 5 minutes)
                clockSkew: 5 * 60 * 1000,
                // Require timestamps in assertions (default: false)
                requireTimestamps: false,
            },
        }),
    ],
});

选项

【Options】

选项类型默认值描述
clockSkewnumber300000(5 分钟)时钟偏差容忍度(毫秒)。允许 IdP 和 SP 服务器之间的时间差异。
requireTimestampsbooleanfalsetrue 时,带有 NotBefore/NotOnOrAfter 条件的断言会被拒绝。当 false 时,会接受但记录警告。

何时启用 requireTimestamps

【When to Enable requireTimestamps

推荐:对于企业和高安全性部署,启用 requireTimestamps: true

在以下情况下启用 requireTimestamps: true

  • 如果你的身份提供者(IdP)遵循 SAML2Int(大多数企业 IdP,如 Okta、Azure AD、OneLogin)
  • 如果你需要 SOC 2ISO 27001 或类似的合规性
  • 如果你希望防止接受格式错误或测试的断言
  • 如果你处于 生产环境 并且 IdP 配置正确

【Enable requireTimestamps: true when:

  • Your IdP follows SAML2Int (most enterprise IdPs like Okta, Azure AD, OneLogin)
  • You need SOC 2, ISO 27001, or similar compliance
  • You want to prevent acceptance of malformed or test assertions
  • You're in a production environment with proper IdP configuration】

当满足以下情况时,请保持 requireTimestamps: false(默认值):

  • 与可能不包含时间戳的 旧版身份提供商(IdP) 集成
  • 在使用模拟 IdP 进行 开发/测试
  • 需要与各种 IdP 实现保持 最大兼容性

【Keep requireTimestamps: false (default) when:

  • Integrating with legacy IdPs that may not include timestamps
  • During development/testing with mock IdPs
  • You need maximum compatibility with various IdP implementations】

更严格的安全性(企业/生产环境)

【Stricter Security (Enterprise/Production)】

对于遵循 SAML2Int 的企业环境,请配置更严格的验证:

【For enterprise environments following SAML2Int, configure stricter validation:】

auth.ts
sso({
    saml: {
        clockSkew: 60 * 1000,      // 1 minute tolerance
        requireTimestamps: true,   // Reject assertions without timestamps (SAML2Int)
    },
})

错误信息

【Error Messages】

  • “SAML 断言尚未生效” — 当前时间早于 NotBefore 时间戳(减去时钟偏差)
  • “SAML 断言已过期” — 当前时间已超过 NotOnOrAfter 时间戳(加上时钟偏差)
  • “SAML 断言缺少必需的时间戳条件” — 断言没有时间戳,并且已启用 requireTimestamps

算法验证

【Algorithm Validation】

Better Auth 默认验证 SAML 加密算法,并对已弃用的算法(SHA-1、RSA 1.5、3DES)发出警告。

【Better Auth validates SAML cryptographic algorithms and warns about deprecated ones (SHA-1, RSA 1.5, 3DES) by default.】

auth.ts
sso({
    saml: {
        algorithms: {
            // "warn" (default) | "reject" | "allow"
            onDeprecated: "warn",
        },
    },
})
行为
"warn"记录警告,允许认证(默认)
"reject"抛出错误,阻止认证
"allow"静默,不进行验证

对于严格的安全(生产):

【For strict security (production):】

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

支持的算法

【Supported Algorithms】

签名算法:

  • RSA-SHA256RSA-SHA384RSA-SHA512
  • ECDSA-SHA256ECDSA-SHA384ECDSA-SHA512

摘要算法:

  • SHA256SHA384SHA512

已弃用(会触发警告/拒绝):

  • RSA-SHA1(签名)
  • SHA1(摘要)
  • RSA 1.5(密钥加密)
  • 3DES(数据加密)

大小限制

【Size Limits】

Better Auth 对 SAML 负载实现大小限制,以防止通过超大 XML 发起的拒绝服务攻击。

【Better Auth enforces size limits on SAML payloads to protect against denial-of-service attacks via oversized XML.】

选项默认值描述
maxResponseSize256KBSAML 响应的最大大小(字节)
maxMetadataSize100KBIdP 元数据的最大大小(字节)

自定义限制

【Customizing Limits】

auth.ts
sso({
    saml: {
        maxResponseSize: 512 * 1024, // 512KB for enterprise IdPs with large group claims
        maxMetadataSize: 100 * 1024, // 100KB
    },
})

要在请求到达你的应用之前真正拒绝过大的负载,请在基础设施层面配置大小限制(如 nginx 的 client_max_body_size、CDN 设置、负载均衡器)。

域名验证

【Domain verification】

域验证允许你的应用通过相关域自动验证所有权,从而自动信任新的单点登录(SSO)提供商。

【Domain verification allows your application to automatically trust a new SSO provider by automatically validating ownership via the associated domain.】

当提供商的域得到验证时,它也会被信任用于自动账户关联。这意味着如果用户使用 SSO 提供商(OIDC 或 SAML)登录,并且存在具有相同电子邮件的现有账户,只要用户的邮箱域与提供商验证的域匹配,账户就会自动关联。

【When a provider's domain is verified, it is also trusted for automatic account linking. This means that if a user signs in with an SSO provider (OIDC or SAML) and an existing account with the same email exists, the accounts will be linked automatically — as long as the user's email domain matches the provider's verified domain.】

auth-client.ts
const authClient = createAuthClient({
    plugins: [
        ssoClient({ 
            domainVerification: { 
                enabled: true
            } 
        }) 
    ]
})
auth.ts
const auth = betterAuth({
    plugins: [
        sso({ 
            domainVerification: { 
                enabled: true
            } 
        }) 
    ]
});

启用后,请确保再次迁移数据库模式。

【Once enabled, make sure you migrate the database schema (again).】

npx @better-auth/cli migrate
npx @better-auth/cli generate

请参阅 Schema 部分以手动添加字段。

【See the Schema section to add the fields manually.】

验证你的域名

【Verify your domain】

当启用域验证时,每个新的单点登录(SSO)提供商一开始都是不受信任的。这意味着在域所有权验证完成之前,新用户注册或登录将被允许。

【When domain verification is enabled, every new SSO provider will be untrusted at first. This means that new sign-ups or sign-ins will be allowed until the domain ownership has been verified.】

要验证你对某个域名的所有权,请按照以下步骤操作:

【To verify your ownership over a domain, follow these steps:】

获取验证令牌

【Acquire verification token】

当注册 SSO 提供商时,将向提供商发放一个验证令牌(它会作为响应的一部分返回)。 你可以使用此令牌来证明对该域的所有权。

创建 TXT DNS 记录

【Create TXT DNS record】

为此,你需要在域名的 DNS 设置中添加一个 TXT 记录:

【To do this, you'll need to add a TXT record to your domain's DNS settings:】

  • 主机: better-auth-token-{your-provider-id} (注意: 这假设使用默认的令牌前缀,可以通过 domainVerification.tokenPrefix 选项进行自定义)
  • 值: 你收到的验证令牌。

保存记录并等待其生效。 这可能需要长达 48 小时,但通常会更快。

提交验证请求

【Submit a validation request】

一旦 DNS 记录传播完成,你就可以提交验证请求(见下文)

域名验证请求

【Domain validation request】

一旦你配置了你的域名,你就可以使用你的 auth 实例提交验证请求。该请求将会出现两种情况:被拒绝(无法证明你对该域名的所有权),或者如果验证成功,你的 SSO 提供商域名将被标记为已验证。

【Once you have configured your domain, you can use your auth instance to submit a validation request. This request will either result in a rejection (could not prove your ownership over the domain) or if the verification is successful, your SSO provider domain will be marked as verified.】

POST
/sso/verify-domain
const { data, error } = await authClient.sso.verifyDomain({    providerId: "acme-corp", // required});
PropDescriptionType
providerId
The provider id
string

创建新的验证令牌

【Creating a new verification token】

每个域验证令牌自发放之时或 SSO 提供商注册之时起,默认有效期为 1 周。

【Every domain verification token will have a default expiry of 1 week since the moment it was issued or the moment when the SSO provider was registered.】

在那之后,该令牌将过期,无法再使用。到时,你可以创建一个新的验证令牌:

【After that time, the token will expire and cannot longer be used. When that happens, you can create a new verification token:】

POST
/sso/request-domain-verification
const { data, error } = await authClient.sso.requestDomainVerification({    providerId: "acme-corp", // required});
PropDescriptionType
providerId
The provider id
string

SAML 端点

【SAML Endpoints】

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

【The plugin automatically creates the following SAML endpoints:】

  • SP 元数据: /api/auth/sso/saml2/sp/metadata?providerId={providerId}
  • SAML 回调/api/auth/sso/saml2/callback/{providerId}

架构

【Schema】

该插件需要在 ssoProvider 表中添加额外的字段以存储提供商的配置。

【The plugin requires additional fields in the ssoProvider table to store the provider's configuration.】

Field NameTypeKeyDescription
idstringA database identifier
issuerstring-The issuer identifier
domainstring-The domain of the provider
oidcConfigstring-The OIDC configuration (JSON string)
samlConfigstring-The SAML configuration (JSON string)
userIdstring-The user ID
providerIdstring-The provider ID. Used to identify a provider and to generate a redirect URL.
organizationIdstring-The organization Id. If provider is linked to an organization.

如果你已启用域名验证:

【If you have enabled domain verification:】

ssoProvider 模式扩展如下:

【The ssoProvider schema is extended as follows:】

Field NameTypeKeyDescription
domainVerifiedboolean-A flag indicating whether the provider domain has been verified.

有关设置 SAML SSO 的详细指南,包括 Okta 示例和使用 DummyIDP 进行测试,请参见我们的 使用 Okta 的 SAML SSO

【For a detailed guide on setting up SAML SSO with examples for Okta and testing with DummyIDP, see our SAML SSO with Okta.】

选项

【Options】

服务器

【Server】

provisionUser:当用户使用 SSO 提供商登录时,用于创建用户的自定义函数。

组织配置:为组织配置用户的选项。

defaultOverrideUserInfo:默认情况下使用提供者信息覆盖用户信息。

disableImplicitSignUp:禁用新用户的隐式注册。

trustEmailVerified — 信任来自提供商的 email_verified 标记。⚠️ 请谨慎使用 — 如果滥用,可能导致账户被接管。仅在你清楚自己在做什么或在受控环境中启用此功能。

如果你想允许特定可信提供商进行账户关联,请在你的认证配置中启用 accountLinking 选项,并在 trustedProviders 列表中指定这些提供商。

【If you want to allow account linking for specific trusted providers, enable the accountLinking option in your auth config and specify those providers in the trustedProviders list.】

Prop

Type