单点登录 (SSO)

OIDC OAuth2 SSO SAML

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

此插件处于积极开发中,可能不适合生产环境使用。请在 GitHub 上报告任何问题或 bug,并在 security@better-auth.com 上报告任何安全问题。

安装

安装插件

npm install @better-auth/sso

将插件添加到服务器

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

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

迁移数据库

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

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

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

添加客户端插件

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

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

使用

注册 OIDC 提供程序

要注册 OIDC 提供程序,请使用 registerSSOProvider 端点并提供提供程序的必要配置细节。

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

示例

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: {
        id: "sub",
        email: "email",
        emailVerified: "email_verified",
        name: "name",
        image: "picture",
    },
});
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",
        },
    },
    headers,
});

注册 SAML 提供程序

要注册 SAML 提供程序,请使用带有 SAML 配置细节的 registerSSOProvider 端点。提供程序将充当服务提供程序 (SP) 并与您的身份提供程序 (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",
        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",
            extraFields: {
                department: "department",
                role: "role"
            }
        },
    },
    headers,
});

获取服务提供程序元数据

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

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

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

使用 SSO 登录

要使用 SSO 提供程序登录,您可以调用 signIn.sso

您可以使用匹配域名的电子邮件登录:

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

或者您可以指定域名:

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

如果提供程序与组织关联,您也可以使用组织 slug 登录:

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

或者,您可以使用提供程序的 ID 登录:

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

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

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

完整方法

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"],    requestSignUp: true,});
PropDescriptionType
email?
用于登录的电子邮件地址。此用于标识用于登录的颁发者。如果提供了颁发者,则为可选。
string
organizationSlug?
用于登录的组织 slug。
string
providerId?
用于登录的提供程序 ID。这可以代替电子邮件或颁发者提供。
string
domain?
提供程序的域名。
string
callbackURL
登录后重定向到的 URL。
string
errorCallbackURL?
登录后重定向到的 URL。
string
newUserCallbackURL?
如果用户是新用户,登录后重定向到的 URL。
string
scopes?
从提供程序请求的范围。
string[]
requestSignUp?
明确请求注册。当此提供程序的 disableImplicitSignUp 为 true 时有用。
boolean

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

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async (user) => {
                // 配置用户
            },
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async (user) => {
                    // 如果需要,获取角色
                },
            },
        }),
    ],
});

配置

SSO 插件提供了强大的配置功能,当用户通过 SSO 提供程序登录时,可以自动设置用户并管理他们的组织成员资格。

用户配置

用户配置允许您在用户通过 SSO 提供程序登录时运行自定义逻辑。这对于以下情况很有用:

  • 使用来自 SSO 提供程序的附加数据设置用户配置文件
  • 将用户属性与外部系统同步
  • 创建用户特定资源
  • 记录 SSO 登录
  • 从 SSO 提供程序更新用户信息
auth.ts
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);

                // 与外部系统同步
                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 令牌(针对 OIDC 提供程序) - 对于 SAML 可能为 undefined
  • provider:SSO 提供程序配置

组织配置

组织配置在 SSO 提供程序链接到特定组织时自动管理用户在组织中的成员资格。这对于以下情况特别有用:

  • 企业 SSO,其中每个公司/域映射到组织
  • 基于 SSO 属性的自动角色分配
  • 通过 SSO 管理团队成员资格

基本组织配置

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

带有自定义角色的高级组织配置

auth.ts
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 提供程序时,您可以将它链接到特定组织:

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", // 链接到组织
        samlConfig: {
            // SAML 配置...
        },
    },
    headers,
});

现在,当来自 acmecorp.com 的用户通过此提供程序登录时,他们将自动被添加到“Acme Corp”组织中,并获得适当的角色。

多组织示例

您可以为不同组织设置多个 SSO 提供程序:

multi-org-setup.ts
// Acme Corp SAML 提供程序
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp",
        issuer: "https://acme.okta.com",
        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. 确定角色 使用 defaultRolegetRole 函数
  5. 将用户添加到 组织中,并使用确定的角色
  6. 运行用户配置 (如果配置)以进行额外设置

配置最佳实践

1. 幂等操作

确保您的配置函数可以安全地多次运行:

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. 错误处理

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

provisionUser: async ({ user, userInfo }) => {
    try {
        await syncWithExternalSystem(user, userInfo);
    } catch (error) {
        // 记录错误但不抛出 - 用户仍可以登录
        console.error('Failed to sync user with external system:', error);
        await logProvisioningError(user.id, error);
    }
},

3. 条件配置

仅在需要时运行某些配置步骤:

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

SAML 配置

服务提供程序配置

在注册 SAML 提供程序时,您需要提供服务提供程序 (SP) 元数据配置:

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

身份提供程序配置

您还需要提供身份提供程序 (IdP) 配置:

  • metadata:来自您的身份提供程序的 XML 元数据
  • privateKey:IdP 通信的私钥(可选)
  • privateKeyPass:IdP 私钥密码(如果加密)
  • isAssertionEncrypted:来自 IdP 的断言是否加密
  • encPrivateKey:IdP 断言解密的私钥
  • encPrivateKeyPass:IdP 解密密钥密码

SAML 属性映射

配置 SAML 属性如何映射到用户字段:

mapping: {
    id: "nameID",           // 默认:"nameID"
    email: "email",         // 默认:"email" 或 "nameID"
    name: "displayName",    // 默认:"displayName"
    firstName: "givenName", // 默认:"givenName"
    lastName: "surname",    // 默认:"surname"
    extraFields: {
        department: "department",
        role: "jobTitle",
        phone: "telephoneNumber"
    }
}

SAML 端点

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

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

架构

插件需要在 ssoProvider 表中添加额外字段来存储提供程序的配置。

Field NameTypeKeyDescription
idstring数据库标识符
issuerstring-颁发者标识符
domainstring-提供程序的域名
oidcConfigstring-OIDC 配置 (JSON 字符串)
samlConfigstring-SAML 配置 (JSON 字符串)
userIdstring-用户 ID
providerIdstring-提供程序 ID。用于标识提供程序并生成重定向 URL。
organizationIdstring-组织 ID。如果提供程序链接到组织。

选项

服务器

provisionUser:当用户使用 SSO 提供程序登录时,用于配置用户的自定义函数。

organizationProvisioning:将用户配置到组织的选项。

defaultOverrideUserInfo:默认使用提供程序信息覆盖用户信息。

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

trustEmailVerified:信任来自提供程序的电子邮件验证标志。

PropTypeDefault
provisionUser?
function
-
organizationProvisioning?
object
-
defaultOverrideUserInfo?
boolean
-
disableImplicitSignUp?
boolean
-
providersLimit?
number | function
10