单点登录 (SSO)

OIDC OAuth2 SSO SAML

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

本插件正处于积极开发阶段,可能暂不适用于生产环境。如有问题或错误,请在 GitHub 上提交反馈;如有安全相关疑虑,请发送邮件至 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

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

添加客户端插件

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

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

使用

注册 OIDC 提供程序

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

重定向 URL 将使用提供程序 ID 自动生成。例如,如果提供程序 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 提供商,请使用 registerSSOProvider 端点并提供 SAML 配置详细信息。该提供商将作为服务提供商(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 元数据 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-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 元数据 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-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,});
属性描述类型
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('与外部系统同步用户失败:', 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 表中添加额外字段来存储身份提供商的配置信息。

字段名称类型Key描述
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