单点登录(SSO)
将单点登录(SSO)集成到您的应用程序中。
OIDC OAuth2 SSO SAML
单点登录(SSO)允许用户使用一套凭证登录多个应用。本插件支持 OpenID Connect(OIDC)、OAuth2 提供者和 SAML 2.0。
需要自助式 SSO,让您的客户可以自行配置 SSO 连接?联系企业版。
安装
安装插件
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 发现文档:
{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(用于令牌端点客户端身份验证的方法)
根据规范,我们的发现流程要求所有 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),我们建议:
- 事先注册一批已知的 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 不支持仅隐式的 OIDC 流。
因此,即使 OIDC 规范允许仅隐式提供者省略 token_endpoint,token_endpoint 和 jwks_uri 仍是必需的。
发现错误
如果身份提供者配置错误或无法访问,注册将失败并返回结构化错误:
| 错误代码 | 含义 |
|---|---|
issuer_mismatch | IdP 的发现文档报告的 issuer 与您配置的 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 未被应用的受信来源配置信任 |
discovery_invalid_json | 发现响应为空或不是有效的 JSON |
unsupported_token_auth_method | IdP 仅支持 Better Auth 不支持的令牌身份验证方法 |
支持的令牌身份验证方法:
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 服务器或仅宣传 "none" 作为支持方法的开发 IdP 时很常见。
总结
- 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/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"
}
}
}
});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 错误:
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用于登录的电子邮件地址。这用于识别要登录的发行者。如果提供了发行者,则是可选的。
organizationSlugstring要登录的组织 slug
providerIdstring要登录的提供者 ID。可以代替电子邮件或发行者提供。
domainstring提供者的域名
callbackURLstringrequired登录后重定向的 URL
errorCallbackURLstring登录后出错的回调 URL
newUserCallbackURLstring如果用户是新用户,重定向到的 URL
scopesstring[]从提供者请求的范围
loginHintstring发送到身份提供者的登录提示(例如邮箱或其他标识符)
requestSignUpboolean明确请求注册。当提供者的 disableImplicitSignUp 为 true 时很有用。
注意:如果提供了电子邮件但未指定登录提示,电子邮件将自动作为登录提示发送给 OIDC 提供者。SAML 流不支持登录提示。
当用户通过身份验证后,如果用户不存在,将使用 provisionUser 函数创建用户。默认情况下,provisionUser 仅在新用户注册时运行。如果您希望每次登录都运行它(例如同步上游身份提供者的资料变更),请将 provisionUserOnEveryLogin 设置为 true。如果启用了组织预置且提供者与组织关联,用户将被加入该组织。
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 提供者更新用户信息
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 管理团队成员身份
基础组织预置
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 提供者时可指定所属组织:
当启用 organization 插件并提供 organizationId 时,调用者必须是组织的 owner 或 admin。普通成员会收到 403 FORBIDDEN。未启用 organization 插件的 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面板功能:
- 为企业客户生成 onboarding 链接,以便其自助配置 SAML 提供者
- 监控每个组织的 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. 幂等操作
如果启用 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 提供者
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 提供者:
- 数据库中未找到匹配的提供者
这允许您无需数据库配置便测试 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 响应
- 跨提供者注入:原本发给其他提供者的响应
此功能为可选启用,以确保向后兼容。若要增强安全性,请显式开启。
启用校验(单实例)
单实例部署可用内存存储启用校验:
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 分钟
},
}),
],
});选项说明
| Option | Type | Default | Description |
|---|---|---|---|
enableInResponseToValidation | boolean | true | 为 SP 发起流程启用 InResponseTo 校验。 |
allowIdpInitiated | boolean | true | 允许 IdP 发起的 SSO(无 InResponseTo 的响应)。若要更严格的安全性请设为 false。仅在启用校验时适用。 |
requestTTL | number | 300000 (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 响应。
工作原理
- 接收 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,则拒绝该断言
对于每个断言:
- 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,
},
}),
],
});选项说明
| Option | Type | Default | Description |
|---|---|---|---|
clockSkew | number | 300000 (5 min) | 允许的时钟偏差,单位毫秒。用于容忍 IdP 与 SP 服务器间的时间差。 |
requireTimestamps | boolean | false | 当为 true 时,未包含 NotBefore/NotOnOrAfter 条件的断言将被拒绝;当为 false 时,断言会被接受,但会记录警告。 |
何时启用 requireTimestamps
建议:在企业和高安全性部署中启用 requireTimestamps: true。
在以下情况下启用 requireTimestamps: true:
- 你的 IdP 遵循 SAML2Int(如 Okta、Azure AD、OneLogin 等大多数企业 IdP)
- 你需要满足 SOC 2、ISO 27001 或类似合规要求
- 你希望防止接受格式错误或测试断言
- 你处于配置正确的 IdP 的生产环境
在以下情况下保持 requireTimestamps: false(默认):
- 集成可能不包含时间戳的遗留 IdP
- 进行带有模拟 IdP 的开发/测试
- 你需要对各种 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" — 断言没有时间戳且启用了
requireTimestamps
算法校验
Better Auth 默认验证 SAML 加密算法,警告已废弃的算法(SHA-1、RSA 1.5、3DES)。
sso({
saml: {
algorithms: {
// "warn"(默认) | "reject" | "allow"
onDeprecated: "warn",
},
},
})| Value | 行为 |
|---|---|
"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(数据加密) -
SHA256、SHA384、SHA512
废弃(触发警告/拒绝):
| Option | 默认值 | 描述 |
|---|---|---|
maxResponseSize | 256KB | 最大 SAML 响应大小(字节) |
maxMetadataSize | 100KB | 最大 IdP 元数据大小(字节) |
尺寸限制
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 提供者使用单独的 ACS 端点,并会自动配置。
共享端点(/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 记录:
- Host:
_better-auth-token-{your-provider-id}(注意: 为遵循 DNS 基础设施的子域命名约定,系统会自动在前面添加一个下划线。better-auth-token部分可通过domainVerification.tokenPrefix选项进行自定义) - Value: 为您提供的验证令牌。
保存记录并等待其传播。 这可能需要长达 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 将 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 会根据您的 baseURL 和 providerId 自动推导。
登录后的重定向目标由客户端 signIn.sso() 调用中的 callbackURL 参数控制:
await authClient.signIn.sso({
providerId: "my-provider",
callbackURL: "/dashboard", // 用户在 SSO 后到达的页面
});回调路由自动支持 GET 和 POST,无需您额外创建路由处理。
Schema
插件要求在 ssoProvider 表添加存储提供者配置的字段。
若已启用域名验证:
ssProvider schema 增加:
IdP 发起的 SAML SSO
Better Auth 支持 IdP 发起的 SSO 流程,用户从身份提供者仪表盘(如 Okta、Azure AD、OneLogin)直接进入应用,常见于企业集中管理场景。
流程:
- 用户在 IdP 仪表盘中点击你的应用图标
- IdP 将 SAMLResponse POST 到
/api/auth/sso/saml2/callback/{providerId} - Better Auth 处理断言,创建会话,并重定向到你的应用
- 浏览器跟随重定向发起 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