单点登录(SSO)

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

OIDC OAuth2 SSO SAML

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

安装

安装插件

npm install @better-auth/sso

添加插件到服务器

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

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

数据库迁移

运行迁移或生成 schema 以向数据库添加所需的字段和表。

npx auth migrate
npx auth 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 视您的基础路径配置可能会有所不同。

如果您想让所有提供者共享一个回调 URL(例如迁移自其他认证提供者时),请参阅 共享重定向 URI

注册 OIDC 提供者时,Better Auth 会自动抓取并校验身份提供者(IdP)的 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 Discovery Document

{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(token 端点客户端认证方式)

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

相对端点和无基础路径的 issuer 示例:

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

相对端点和带基础路径的 issuer 示例:

  • issuer: "https://your-org.okta.com/v1"
  • token_endpoint: "/tokens"
  • 规范化后 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 不支持仅隐式(implicit-only)的 OIDC 流程。因此,即使 OIDC 规范允许隐式提供者省略 token_endpoint,但本插件仍要求提供 token_endpointjwks_uri

发现错误

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

错误代码含义
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 不在您应用的 trustedOrigins 中
discovery_invalid_json发现响应为空或非有效 JSON
unsupported_token_auth_methodIdP 支持的 token 认证方式不被支持

支持的 token 认证方法:

  • 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 服务或开发用 IdP,只支持 "none"

总结

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

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

用于登录的邮箱,用以识别登录的发行者。若 issuer 已提供,该字段可选。

organizationSlugstring

组织的标识 slug。

providerIdstring

用于登录的提供者 ID,可替代 email 或 issuer。

domainstring

提供者的域名。

callbackURLstringrequired

登录后重定向的 URL。

errorCallbackURLstring

登录失败时重定向的 URL。

newUserCallbackURLstring

用户是新用户时重定向的 URL。

scopesstring[]

请求的权限范围。

loginHintstring

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

requestSignUpboolean

显式请求注册。若该提供者禁用了隐式注册,则需要设置为 true。

注意:若提供了 email 且未指定 loginHint,email 将自动作为登录提示发送给 OIDC 提供者。SAML 流程不支持 loginHint。

用户认证成功后,如用户不存在,会使用 provisionUser 函数创建用户。 若启用了组织自动添加且提供者关联了组织,用户也将被自动添加至该组织。

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

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

预置(Provisioning)

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

用户预置

用户预置允许您在用户通过 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 提供者配置

组织预置

组织预置用于自动管理用户在组织中的权限,当 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 提供者时可指定所属组织:

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

面板功能:

  • 生成企业客户上手共享链接
  • 监控各组织 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. 幂等操作

确保预置操作可多次安全执行:

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",
                        spMetadata: {
                            entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
                            metadata: "<!-- Your SP Metadata XML -->",
                        }
                    }
                }
            ]
        })
    ]
});

当数据库中无匹配提供者时,使用 defaultSSO。

这允许您无需数据库配置便测试 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: IdP 的 XML 元数据
  • privateKey: IdP 通信私钥(可选)
  • privateKeyPass: 私钥密码(若加密)
  • 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 属性,以防范:

  • 未请求的响应:未经合法登录请求触发的响应
  • 重放攻击:重复利用旧响应
  • 跨提供者注入:针对其它提供者的响应注入

此特性为可选,需显式启用,保证向后兼容。

启用校验(单实例)

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

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

选项说明

选项类型默认描述
enableInResponseToValidationbooleanfalse启用 SP 发起 SAML 流程的 InResponseTo 校验。
allowIdpInitiatedbooleantrue允许 IdP 发起的 SSO(无 InResponseTo 的响应)。设置为 false 启用更严格安全,仅在校验启用时有效。
requestTTLnumber300000AuthnRequest 记录 TTL,单位毫秒。仅在校验启用时生效。

错误处理

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

  • ?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 则拒绝

配置示例

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

选项说明

选项类型默认描述
clockSkewnumber300000(5 分钟)时钟偏差容差,单位毫秒。允许 IdP 与 SP 服务器时间微小差异。
requireTimestampsbooleanfalse若为 true,无时间戳断言会被拒绝;若为 false,接受但记录警告。

何时启用 requireTimestamps

建议:企业及高安全部署启用 requireTimestamps: true

启用场景:

  • IdP 遵从 SAML2Int(大多数企业 IdP 如 Okta、Azure AD、OneLogin)
  • 需要符合 SOC 2ISO 27001 等合规要求
  • 防止接受畸形或测试断言
  • 生产环境且 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" — 断言无时间戳且启用了强制校验

算法校验

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

auth.ts
sso({
    saml: {
        algorithms: {
            // "warn"(默认) | "reject" | "allow"
            onDeprecated: "warn",
        },
    },
})
取值行为
"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(数据加密)

尺寸限制

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 提供者已通过 samlConfigcallbackUrl 支持自定义回调 URL。

共享端点(/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 记录:

  • 主机名_better-auth-token-{您的-provider-id}注意:会自动加前导下划线以符合 DNS 子域规则,better-auth-token 可通过 domainVerification.tokenPrefix 自定义)
  • 记录值:您获得的验证令牌

保存记录并等待生效(通常几分钟至 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 向回调端点 POST SAMLResponse
  • IdP 发起:用户在 IdP 仪表盘(Okta、Azure AD 等)点击应用图标→IdP 向回调端点 POST SAMLResponse

重要:您 SAML 配置中的 callbackUrl 应是用户登录后跳转的应用界面地址(例 /dashboard),不要指定回调路由自身。Better Auth 会自动处理回调,成功后重定向至 callbackUrl

samlConfig: {
  callbackUrl: "/dashboard", // 正确:指向应用终点
  // callbackUrl: "/api/auth/sso/saml2/callback/my-provider" // 错误:不要指向回调接口
}

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

Schema

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

Table
字段
类型
描述
id
string
pk
数据库标识符
issuer
string
发行者标识
domain
string
提供者对应域名
oidcConfig
string
OIDC 配置(JSON 字符串)
samlConfig
string
SAML 配置(JSON 字符串)
userId
string
用户 ID
providerId
string
提供者 ID,用于识别提供者及生成重定向 URL
organizationId
string
组织 ID,如提供者关联组织

若已启用域名验证:

ssProvider schema 增加:

Table
字段
类型
描述
domainVerified
boolean
标记提供者域名是否已验证

IdP 发起的 SAML SSO

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

流程:

  1. 用户点击 IdP 仪表盘应用图标
  2. IdP 向 /api/auth/sso/saml2/callback/{providerId} POST SAMLResponse
  3. Better Auth 处理断言、创建会话,重定向到您的 callbackUrl
  4. 浏览器执行重定向的 GET 请求(自动处理)

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

升级后若此前为此回调路由自定义了 GET 处理,可删除,Better Auth 现自动支持。

安全警告:Better Auth 验证所有重定向 URL,防止开放重定向攻击。仅允许相对路径(如 /dashboard)和符合配置的 trustedOrigins 的 URL。恶意链接如 https://evil.com 或协议相对 URL (//evil.com) 自动阻止。

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

选项

服务器端

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

若您想允许受信任提供者间的账号关联,可在认证配置中启用 accountLinking 并在 trustedProviders 列表中指定提供者。

Prop

Type