单点登录(SSO)

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

OIDC OAuth2 SSO SAML

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

需要自助式 SSO,让您的客户可以自行配置 SSO 连接?联系企业版

安装

安装插件

npm install @better-auth/sso

将插件添加到服务器

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

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

迁移数据库

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

npx auth migrate
npx auth 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 接口并提供相应的配置。

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

如果您需要所有提供者共享单个回调 URL(例如从其他身份验证提供程序迁移时),请参阅共享重定向 URI

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

示例

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",
            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 发现

Better Auth 会自动从以下地址抓取并验证提供者的 OpenID Connect 发现文档

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

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

POST/sso/register
Notes

最小 OIDC 配置 — 端点会根据 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    },});
Parameters
providerIdstringrequired

提供者的唯一标识符

issuerstringrequired

OIDC 发行者 URL。发现文档将从 {issuer}/.well-known/openid-configuration 获取

domainstringrequired

用于此提供者的电子邮件域

oidcConfigObjectrequired

OIDC 配置(大多数字段会自动发现)

clientIdstringrequired

来自 IdP 的 OAuth 客户端 ID

clientSecretstringrequired

来自 IdP 的 OAuth 客户端密钥

自动发现字段

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

  • authorizationEndpoint
  • tokenEndpoint
  • jwksEndpoint
  • userInfoEndpoint
  • discoveryEndpoint
  • tokenEndpointAuthentication(用于令牌端点客户端身份验证的方法)

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

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

  • issuer: "https://your-org.okta.com"
  • token_endpoint: "/v1/tokens"
  • normalized token_endpoint: "https://your-org.okta.com/v1/tokens"

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

  • issuer: "https://your-org.okta.com/v1"
  • token_endpoint: "/tokens"
  • normalized token_endpoint: "https://your-org.okta.com/v1/tokens"

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

受信任的来源

发现端点及发现过程中解析到的任意 URL 都必须符合您应用的 trustedOrigins 配置。 如果未明确添加,将导致发现失败,并返回错误代码 discovery_untrusted_origin

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

如果需要支持多个已知的 IDP(例如 Okta),我们建议:

  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) => {
    // 初始化和 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 文档。

为什么发生发现失败

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

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

发现错误

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

错误代码含义
issuer_mismatchIdP 的发现文档报告的 issuer 与您配置的 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", // 覆盖发现结果
}

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

总结

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

注册 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/sp/acs/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",
            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/sp/acs/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",
                emailVerified: "email_verified",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    },
    headers,
});

IdP 发起的 SSO

对于 IdP 发起的流程(例如通过 Okta 仪表板),如果默认处理程序不支持在 SAML POST 之后处理 GET 请求,您的框架可能需要一个明确的路由处理程序来管理重定向。

创建此文件以防止出现 404 错误:

app/api/auth/sso/saml2/callback/[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));
}

获取服务提供者元数据

对于 SAML 提供者,您可以获取需要配置到 IdP 的服务提供者(SP)元数据 XML:

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 登录

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

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

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

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

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

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

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

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

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

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

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

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

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

可选地,传递登录提示(例如邮箱或其他标识符)用于预填或引导身份提供者:

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

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

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"],    loginHint: "user@example.com",    requestSignUp: true,});
Parameters
emailstring

用于登录的电子邮件地址。这用于识别要登录的发行者。如果提供了发行者,则是可选的。

organizationSlugstring

要登录的组织 slug

providerIdstring

要登录的提供者 ID。可以代替电子邮件或发行者提供。

domainstring

提供者的域名

callbackURLstringrequired

登录后重定向的 URL

errorCallbackURLstring

登录后出错的回调 URL

newUserCallbackURLstring

如果用户是新用户,重定向到的 URL

scopesstring[]

从提供者请求的范围

loginHintstring

发送到身份提供者的登录提示(例如邮箱或其他标识符)

requestSignUpboolean

明确请求注册。当提供者的 disableImplicitSignUp 为 true 时很有用。

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

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

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)

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

用户预置

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

  • 设置带有 SSO 提供者额外数据的用户资料
  • 同步用户属性到外部系统
  • 创建特定于用户的资源
  • 记录 SSO 登录事件
  • 从 SSO 提供者更新用户信息
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 管理团队成员身份

基础组织预置

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

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

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

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 提供者时可指定所属组织:

当启用 organization 插件并提供 organizationId 时,调用者必须是组织的 owneradmin。普通成员会收到 403 FORBIDDEN。未启用 organization 插件的 SSO 部署将保持之前的行为(仅查找成员身份)。

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

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: await headers() // 包含用户会话令牌的头
});

来自 acmecorp.com 的用户登录此提供者时,自动加入 Acme Corp 组织并分配对应角色。

自助 SSO 管理面板

使用 Better Auth Infrastructure 可访问自助 SSO 面板,简化企业客户接入流程。组织管理员可生成分享链接,指导企业 IT 配置身份提供者,无需手动交换 SAML 元数据和证书。

地址:

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

面板功能:

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

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

多组织示例

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

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

// 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. 幂等操作

如果启用 provisionUserOnEveryLogin,请确保你的预置函数可以安全地多次运行:

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 配置

默认 SSO 提供者

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-----",
                        callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs/default-saml",
                        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

部分企业 IdP(如 Okta、Azure AD、ADFS)要求签名的 AuthnRequests。可通过:

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 解密密钥密码

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 漏洞。

AuthnRequest / InResponseTo 校验

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

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

此功能为可选启用,以确保向后兼容。若要增强安全性,请显式开启。

启用校验(单实例)

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

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 分钟
            },
        }),
    ],
});

选项说明

OptionTypeDefaultDescription
enableInResponseToValidationbooleantrue为 SP 发起流程启用 InResponseTo 校验。
allowIdpInitiatedbooleantrue允许 IdP 发起的 SSO(无 InResponseTo 的响应)。若要更严格的安全性请设为 false。仅在启用校验时适用。
requestTTLnumber300000 (5 min)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 会被追踪,重复使用将被拒绝。

重放保护始终启用。这是一项关键安全特性,可防止攻击者重复使用被截获的 SAML 响应。

工作原理

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

两个 SAML 端点都受保护:

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

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

错误处理

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

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

时间戳校验

插件验证 SAML 断言的时间戳(NotBeforeNotOnOrAfter),避免接收过期或未来时间的断言。验证包含可配置的时钟偏差,以兼容 IdP 与 SP 服务器间的时间差。

SAML 规范背景

根据 SAML 2.0 Core 规范NotBeforeNotOnOrAfter可选属性,但广泛采纳的 SAML2Int(联邦互操作实现规范)要求:

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

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

  • 默认行为:接受不含时间戳断言(符合 SAML 2.0 Core),但记录警告
  • 严格模式:拒绝无时间戳断言(符合 SAML2Int)

对于每个断言:

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

对于每个断言:

  • NotBefore:当前时间早于 NotBefore - clockSkew 则拒绝
  • NotOnOrAfter:当前时间晚于 NotOnOrAfter + clockSkew 则拒绝

配置示例

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,
            },
        }),
    ],
});

选项说明

OptionTypeDefaultDescription
clockSkewnumber300000 (5 min)允许的时钟偏差,单位毫秒。用于容忍 IdP 与 SP 服务器间的时间差。
requireTimestampsbooleanfalse当为 true 时,未包含 NotBefore/NotOnOrAfter 条件的断言将被拒绝;当为 false 时,断言会被接受,但会记录警告。

何时启用 requireTimestamps

建议:在企业和高安全性部署中启用 requireTimestamps: true

在以下情况下启用 requireTimestamps: true

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

在以下情况下保持 requireTimestamps: false(默认):

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

默认关闭时适合:

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

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

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)。

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

生产环境严格配置示例:

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

支持算法

签名算法:

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

摘要算法:

  • SHA256SHA384SHA512

废弃(触发警告/拒绝):

  • RSA-SHA1(签名)

  • SHA1(摘要)

  • RSA 1.5(密钥加密)

  • 3DES(数据加密)

  • SHA256SHA384SHA512

废弃(触发警告/拒绝):

Option默认值描述
maxResponseSize256KB最大 SAML 响应大小(字节)
maxMetadataSize100KB最大 IdP 元数据大小(字节)

尺寸限制

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

选项默认值描述
maxResponseSize256 KBSAML 响应最大大小(字节)
maxMetadataSize100 KBIdP 元数据最大大小(字节)

自定义限制

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

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

共享重定向 URI

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

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

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

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

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

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

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

当使用完整 URL 时,请确保它会路由到您的 Better Auth 实例。

此选项仅影响 OIDC 提供者。SAML 提供者使用单独的 ACS 端点,并会自动配置。

共享端点(/sso/callback)和按提供者的端点(/sso/callback/:providerId)都会始终注册,以保持向后兼容,因此无论此设置如何,现有集成都可以继续工作。

域名验证

域名验证让您的应用自动信任新的 SSO 提供者,通过关联域名自动验证所有权。

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

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

const auth = betterAuth({
    plugins: [
        sso({ 
            domainVerification: { 
                enabled: true
            } 
        }) 
    ]
});

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

npx auth migrate
npx auth generate

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

验证您的域

启用域名验证后,所有新注册的 SSO 提供者初始均为未信任状态。 这意味着新用户注册或登录会被允许,直至完成域所有权验证。

进行域名验证流程:

获取验证令牌

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

创建 TXT DNS 记录

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

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

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

提交验证请求

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

域名验证请求

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

POST/sso/verify-domain
const { data, error } = await authClient.sso.verifyDomain({    providerId: "acme-corp", // required});
Parameters
providerIdstringrequired

提供者 ID

申请新的验证令牌

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

令牌过期后无法使用,您可重新申请:

POST/sso/request-domain-verification
const { data, error } = await authClient.sso.requestDomainVerification({    providerId: "acme-corp", // required});
Parameters
providerIdstringrequired

提供者 ID

SAML 端点

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

  • SP 元数据: /api/auth/sso/saml2/sp/metadata?providerId={providerId}
  • SAML 回调: /api/auth/sso/saml2/callback/{providerId}(支持 GET 和 POST)

SAML 回调 URL 配置

SAML 回调端点 (/api/auth/sso/saml2/callback/{providerId}) 支持:

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

重要:您 SAML 配置中的 callbackUrl 用作断言消费者服务(ACS)URL。请将其设置为该提供者的 SAML 回调路由(例如 https://yourapp.com/api/auth/sso/saml2/sp/acs/my-provider)。如果省略,Better Auth 会根据您的 baseURLproviderId 自动推导。

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

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

回调路由自动支持 GET 和 POST,无需您额外创建路由处理。

Schema

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

Table
字段
类型
描述
id
string
PK
A database identifier
issuer
string
-
The issuer identifier
domain
string
-
The domain of the provider
oidcConfig ?
string
-
The OIDC configuration (JSON string)
samlConfig ?
string
-
The SAML configuration (JSON string)
userId
string
FK
The user ID
providerId
string
-
The provider ID. Used to identify a provider and to generate a redirect URL.
organizationId ?
string
-
The organization Id. If provider is linked to an organization.

若已启用域名验证:

ssProvider schema 增加:

Table
字段
类型
描述
domainVerified ?
boolean
-
A flag indicating whether the provider domain has been verified.

IdP 发起的 SAML SSO

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

流程:

  1. 用户在 IdP 仪表盘中点击你的应用图标
  2. IdP 将 SAMLResponse POST 到 /api/auth/sso/saml2/callback/{providerId}
  3. Better Auth 处理断言,创建会话,并重定向到你的应用
  4. 浏览器跟随重定向发起 GET 请求(自动处理)

无需额外配置,回调接口自动支持 GET 和 POST。

IdP 发起的流程不携带 RelayState,因此登录后的重定向会回退到你应用的基础 URL。要控制在 SP 发起的 SSO 之后用户落地到哪里,请在 signIn.sso() 调用中通过 callbackURL 传入目标地址。

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

安全性: Better Auth 会验证所有重定向 URL,以防止开放重定向攻击。仅允许相对路径(例如 /dashboard)以及与你配置的 trustedOrigins 匹配的 URL。诸如 https://evil.com 这类恶意 URL 或协议相对 URL(//evil.com)会被自动阻止。

有关 Okta 和 DummyIDP 的详细 SAML SSO 教程,见 SAML SSO with Okta

选项

服务器端

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

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

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

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

disableImplicitSignUp: 禁用隐式注册。

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

Prop

Type