单点登录(SSO)
将单点登录(SSO)集成到您的应用程序中。
OIDC OAuth2 SSO SAML
单点登录(SSO)允许用户使用一套凭证登陆多个应用。本插件支持 OpenID Connect(OIDC)、OAuth2 提供者和 SAML 2.0。
安装
安装插件
npm install @better-auth/sso添加插件到服务器
import { betterAuth } from "better-auth"
import { sso } from "@better-auth/sso";
const auth = betterAuth({
plugins: [
sso()
]
})添加客户端插件
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。
示例
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"
}
}
}
});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)自动填充。
最小 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 },});providerIdstringrequired提供者唯一标识符
issuerstringrequiredOIDC 发行者 URL。发现文档在 {issuer}/.well-known/openid-configuration 位置
domainstringrequired该提供者对应的邮箱域名
oidcConfigObjectrequiredOIDC 配置(大多数字段自动发现)
clientIdstringrequired您 IdP 的 OAuth 客户端 ID
clientSecretstringrequired您 IdP 的 OAuth 客户端密钥
自动发现字段
如果未显式提供,Better Auth 会根据 IdP 的发现文档填充以下字段:
authorizationEndpointtokenEndpointjwksEndpointuserInfoEndpointdiscoveryEndpointtokenEndpointAuthentication(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),我们建议:
- 事先注册一批已知 IDP 源:
trustedOrigins: [
"https://your-org.okta.com",
"https://accounts.google.com",
"https://login.microsoftonline.com",
"https://auth0.com",
"https://idp.example.com"
],- 或者通过回调函数动态计算
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_endpoint 和 jwks_uri。
发现错误
若身份提供者配置错误或无法访问,注册将失败并返回结构化错误:
| 错误代码 | 含义 |
|---|---|
issuer_mismatch | IdP 的发现文档中 issuer 与您配置的不符 |
discovery_incomplete | 缺少必要字段(如 authorization_endpoint、token_endpoint、jwks_uri) |
discovery_not_found | 发现文档地址返回 404 |
discovery_timeout | IdP 超时(默认 10 秒) |
discovery_invalid_url | 发现 URL 格式错误或使用不支持的协议 |
discovery_untrusted_origin | 发现 URL 或其中解析的 URL 不在您应用的 trustedOrigins 中 |
discovery_invalid_json | 发现响应为空或非有效 JSON |
unsupported_token_auth_method | IdP 支持的 token 认证方式不被支持 |
支持的 token 认证方法:
client_secret_basicclient_secret_post
如果您的 IdP 只支持不被支持的认证方式(如 private_key_jwt、tls_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)集成。
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"
}
}
}
});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 错误:
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:
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 提供者登录。
您可以用带域名的邮箱登录:
import { authClient } from "@/lib/auth-client"
const res = await authClient.signIn.sso({
email: "user@example.com",
callbackURL: "/dashboard",
});或者您可以单独指定域名:
import { authClient } from "@/lib/auth-client"
const res = await authClient.signIn.sso({
domain: "example.com",
callbackURL: "/dashboard",
});如果提供者与组织关联,还可以用组织 slug 登录:
import { authClient } from "@/lib/auth-client"
const res = await authClient.signIn.sso({
organizationSlug: "example-org",
callbackURL: "/dashboard",
});也可以使用提供者 ID 登录:
import { authClient } from "@/lib/auth-client"
const res = await authClient.signIn.sso({
providerId: "example-provider-id",
callbackURL: "/dashboard",
});可选地,传递登录提示(例如邮箱或其他标识符)用于预填或引导身份提供者:
import { authClient } from "@/lib/auth-client"
const res = await authClient.signIn.sso({
providerId: "example-provider-id",
loginHint: "user@example.com",
callbackURL: "/dashboard",
});使用服务端 API 可调用 signInSSO:
import { authClient } from "@/lib/auth-client"
const res = await auth.api.signInSSO({
body: {
organizationSlug: "example-org",
callbackURL: "/dashboard",
}
});完整方法
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,});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 函数创建用户。
若启用了组织自动添加且提供者关联了组织,用户也将被自动添加至该组织。
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 登录事件
- 更新用户信息
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 管理团队成员身份
基础组织预置
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";
const auth = betterAuth({
plugins: [
sso({
organizationProvisioning: {
disabled: false, // 开启组织预置
defaultRole: "member", // 新成员默认角色
},
}),
],
});进阶组织预置与自定义角色
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 提供者时可指定所属组织:
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 提供者:
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,
});组织预置流程
- 用户使用关联组织的 SSO 提供者登录
- 用户认证成功,在数据库中找到或创建
- 检查组织成员身份 — 如未加入关联组织
- 通过
defaultRole或getRole函数确定角色 - 将用户加入组织并赋予角色
- 如配置,执行用户预置进一步设置
预置最佳实践
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 提供者
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 属性,以防范:
- 未请求的响应:未经合法登录请求触发的响应
- 重放攻击:重复利用旧响应
- 跨提供者注入:针对其它提供者的响应注入
此特性为可选,需显式启用,保证向后兼容。
启用校验(单实例)
单实例部署可用内存存储启用校验:
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 分钟
},
}),
],
});选项说明
| 选项 | 类型 | 默认 | 描述 |
|---|---|---|---|
enableInResponseToValidation | boolean | false | 启用 SP 发起 SAML 流程的 InResponseTo 校验。 |
allowIdpInitiated | boolean | true | 允许 IdP 发起的 SSO(无 InResponseTo 的响应)。设置为 false 启用更严格安全,仅在校验启用时有效。 |
requestTTL | number | 300000 | AuthnRequest 记录 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 响应被重用。
工作原理
- 接收 SAML 响应时,从 XML 中提取断言 ID
- 系统检查该 ID 是否出现过
- 新断言时,存储 ID 直到其
NotOnOrAfter到期 - 重复断言(重放攻击)则拒绝
两个 SAML 端点受保护:
/sso/saml2/callback/:providerId/sso/saml2/sp/acs/:providerId
重放保护使用数据库验证表,多实例部署可正常支持,无需额外配置。
错误处理
检测到重放攻击时重定向并附加错误:
?error=replay_detected&error_description=SAML+assertion+has+already+been+used— 断言 ID 已经使用过
时间戳校验
插件验证 SAML 断言的时间戳(NotBefore 和 NotOnOrAfter),避免接收过期或未来时间的断言。验证包含可配置的时钟偏差,以兼容 IdP 与 SP 服务器间的时间差。
SAML 规范背景
根据 SAML 2.0 Core 规范,NotBefore 和 NotOnOrAfter 是可选属性,但广泛采纳的 SAML2Int(联邦互操作实现规范)要求:
“身份提供者必须包含
<saml:Conditions>元素,且必须包括限制断言有效期的@NotBefore和@NotOnOrAfter。”
Better Auth 支持灵活配置:
- 默认行为:接受不含时间戳断言(符合 SAML 2.0 Core),但记录警告
- 严格模式:拒绝无时间戳断言(符合 SAML2Int)
工作流程
对于每个断言:
- NotBefore:当前时间早于
NotBefore - clockSkew则拒绝 - NotOnOrAfter:当前时间晚于
NotOnOrAfter + clockSkew则拒绝
配置示例
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,
},
}),
],
});选项说明
| 选项 | 类型 | 默认 | 描述 |
|---|---|---|---|
clockSkew | number | 300000(5 分钟) | 时钟偏差容差,单位毫秒。允许 IdP 与 SP 服务器时间微小差异。 |
requireTimestamps | boolean | false | 若为 true,无时间戳断言会被拒绝;若为 false,接受但记录警告。 |
何时启用 requireTimestamps
建议:企业及高安全部署启用 requireTimestamps: true。
启用场景:
- IdP 遵从 SAML2Int(大多数企业 IdP 如 Okta、Azure AD、OneLogin)
- 需要符合 SOC 2、ISO 27001 等合规要求
- 防止接受畸形或测试断言
- 生产环境且 IdP 配置正确
默认关闭时适合:
- 集成遗留 IdP,可能不包含时间戳
- 开发测试及模拟 IdP
- 追求最大兼容性的场景
更严格的企业/生产环境配置
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)。
sso({
saml: {
algorithms: {
// "warn"(默认) | "reject" | "allow"
onDeprecated: "warn",
},
},
})| 取值 | 行为 |
|---|---|
"warn" | 记录警告,允许认证(默认) |
"reject" | 抛错,阻止认证 |
"allow" | 静默,不做验证 |
生产环境严格配置示例:
sso({
saml: {
algorithms: {
onDeprecated: "reject",
},
},
})支持算法
签名算法:
RSA-SHA256、RSA-SHA384、RSA-SHA512ECDSA-SHA256、ECDSA-SHA384、ECDSA-SHA512
摘要算法:
SHA256、SHA384、SHA512
废弃(触发警告/拒绝):
RSA-SHA1(签名)SHA1(摘要)RSA 1.5(密钥加密)3DES(数据加密)
尺寸限制
Better Auth 强制限制 SAML 负载大小,防止通过大型 XML 发起拒绝服务攻击。
| 选项 | 默认值 | 描述 |
|---|---|---|
maxResponseSize | 256 KB | SAML 响应最大大小(字节) |
maxMetadataSize | 100 KB | IdP 元数据最大大小(字节) |
自定义限制
sso({
saml: {
// 企业级 IdP 可能需要更大响应限制
maxResponseSize: 512 * 1024, // 512KB
maxMetadataSize: 100 * 1024, // 100KB
},
})若需真正早期拒绝超大负载(在请求到应用前),应在基础设施层面配置限流(如 nginx 的 client_max_body_size,CDN 设置,负载均衡器)。
共享重定向 URI
默认每个 OIDC 提供者都有单独的回调 URL(/sso/callback/:providerId)。您可配置所有提供者共用单个回调 URI:
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 提供者已通过 samlConfig 的 callbackUrl 支持自定义回调 URL。
共享端点(/sso/callback)和单独端点(/sso/callback/:providerId)始终同时注册,保持向后兼容,已有集成无影响。
域名验证
域名验证让您的应用自动信任新的 SSO 提供者,通过关联域名自动验证所有权。
验证域名后,该域对应的提供者被允许 自动账户关联。即用户使用 SSO 登录且邮箱匹配已存在账户时,会自动关联两者,前提是用户邮箱的域名已通过验证。
const authClient = createAuthClient({
plugins: [
ssoClient({
domainVerification: {
enabled: true
}
})
]
})import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";
const auth = betterAuth({
plugins: [
sso({
domainVerification: {
enabled: true
}
})
]
});启用后请确保再次迁移数据库 schema。
npx auth migratenpx 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 提供者对应域名为已验证。
const { data, error } = await authClient.sso.verifyDomain({ providerId: "acme-corp", // required});providerIdstringrequired提供者 ID
申请新的验证令牌
每个域名验证令牌默认有效期为 1 周(自颁发或注册时起)。
令牌过期后无法使用,您可重新申请:
const { data, error } = await authClient.sso.requestDomainVerification({ providerId: "acme-corp", // required});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 表添加存储提供者配置的字段。
若已启用域名验证:
ssProvider schema 增加:
IdP 发起的 SAML SSO
Better Auth 支持 IdP 发起的 SSO 流程,用户从身份提供者仪表盘(如 Okta、Azure AD、OneLogin)直接进入应用,常见于企业集中管理场景。
流程:
- 用户点击 IdP 仪表盘应用图标
- IdP 向
/api/auth/sso/saml2/callback/{providerId}POST SAMLResponse - Better Auth 处理断言、创建会话,重定向到您的
callbackUrl - 浏览器执行重定向的 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