某个周三下午,一家做 DevOps
可观测性平台的初创公司刚刚签下了他们第一个金融行业客户——一家区域性商业银行,年合同金额约六十万美元。合同签字页还没寄到,对方
IT 安全团队的邮件已经先到了,标题是”SSO Onboarding Checklist
for Vendor
Integration”,正文写道:“请按附件元数据(metadata)配置 SAML
2.0 SP-initiated SSO,我们的 IdP 是 PingFederate
10.3,NameID 使用 emailAddress
格式,断言需要使用我方证书加密,RelayState
必须透传。接口上线前请提交 SP metadata XML
至以下邮箱,我们需要 3 个工作日完成对接评审。”
创始团队里没有任何人在生产环境碰过
SAML——他们的登录体系一直是 OIDC + Google
Workspace。但合同已经签了,SLA
要求两周内完成集成,换句话说:这一周必须把一个 2005
年发布、基于 XML
数字签名、充满命名空间细节的协议,在生产系统上跑通,并通过对方安全团队的测试。这篇文章就是写给这类工程师的:你未必喜欢
SAML,但你必须能在一周内交付它。
在 IAM 工程体系里,SAML 属于”你以为已经被淘汰、但每次接大客户都会重新回来”的那一类协议。关于 IAM 全景,可以参考《IAM 全景:身份、认证、授权与治理的边界》;关于现代 OIDC SSO,可以对比《SSO 与 OIDC:从一次登录到统一身份》;关于认证系统整体架构,可以参考《认证架构:从密码到零信任》。本文专注于一个具体问题:当现代工程师第一次被甩一份 IdP metadata XML 时,应该知道什么。
一、为什么 2026 年还要学 SAML
1.1 一个 2005 年的协议为什么没死
SAML 2.0(Security Assertion Markup Language 2.0)由 OASIS 于 2005 年 3 月正式批准,比 OAuth 1.0(2010)早五年,比 OIDC(2014)早九年。它是 Web 时代第一个被广泛部署的联邦身份(federated identity)协议,也是最后一代以 XML 为默认数据格式的大型标准之一。在”XML over HTTP”仍是主流企业架构、SOAP 仍是跨系统调用范式的时代,SAML 的设计完全符合当时的工程审美:严格的 XML Schema、XML Digital Signature、XML Encryption、基于 SOAP 的后端通道。
SAML 没有死的原因不是技术优越,而是组织惯性:
第一,企业 IT 的首要 KPI 是”不出事”,而不是”用新东西”。一家美国中型保险公司可能在 2009 年采购了 PingFederate 集群,配置了与 Active Directory、Workday、Salesforce、ServiceNow 的联邦关系,在过去十五年里这套系统稳定地支撑了每天数万次登录、通过了每年一次的 SOC 2 和 SOX 审计。没有任何内部团队有动力去替换它——即便你能证明 OIDC 更现代,“替换 IdP”这个项目从立项到上线通常要 18 到 24 个月,涉及数百个 SP 的重新对接,风险远大于收益。
第二,SAML 深度嵌入了企业 HR 与业务 SaaS 生态。Workday、SAP SuccessFactors、ADP 这类 HRIS(Human Resources Information System)都原生支持 SAML 作为 IdP 或 SP;Salesforce、ServiceNow、Atlassian Cloud(Confluence、Jira)、Box、Dropbox Business、Workplace by Meta 对 SAML SP 的支持比 OIDC 更成熟稳定——许多场景下 OIDC 选项甚至要购买额外的”Premium SSO”附加包。换句话说,对企业 IT 管理员来说,SAML 仍是默认选项。
第三,老派企业 IdP 的存量巨大。PingFederate、CA/Broadcom SiteMinder、Oracle Access Manager、IBM Security Verify(前身 TFIM/ISAM)、Microsoft ADFS(Active Directory Federation Services)、Shibboleth(高等教育领域几乎垄断)——这些产品的客户群体对 SAML 的投入是以十年为单位的。Azure AD(现 Entra ID)和 Okta 虽然同时支持 SAML 与 OIDC,但在真正的 B2B 对接场景中,企业客户给你的往往仍是 SAML metadata URL。
第四,合规与审计的惯性。许多金融、医疗、政府客户的安全团队有一份”认可协议清单”,SAML 2.0 在上面,OIDC 可能还在评估。对方给不了批准,你就上不了线。
1.2 SAML 与 OIDC 的工程定位对比
| 维度 | SAML 2.0 | OIDC / OAuth 2.1 |
|---|---|---|
| 数据格式 | XML(严格 Schema) | JSON / JWT |
| 签名 | XML Digital Signature(Canonicalization + 签名) | JWS(紧凑、字符串级) |
| 主场景 | 企业内网 / B2B Web SSO | 公网 / 移动端 / API 授权 |
| 移动端支持 | 差(浏览器重定向强绑定) | 原生良好 |
| 传输 | 浏览器前端通道(Redirect / POST) | 前端重定向 + 后端 Token 端点 |
| 刷新机制 | 无(重新走 SSO) | refresh_token |
| 典型 IdP | ADFS、PingFederate、Shibboleth | Okta、Auth0、Entra ID、Keycloak |
| 断言携带 | 结构化 XML,属性映射丰富 | id_token(claims) + userinfo |
| 攻击面 | XML Signature Wrapping、XXE、XSLT | JWT alg=none、redirect_uri、CSRF |
这张表解释了为什么 2026 年还要学 SAML:对接企业客户时你没得选;但它同时也解释了为什么你自己搭的新系统几乎一定应该选 OIDC——XML 栈的复杂度、签名验证的实现坑、移动端的天然不适配,使得 SAML 作为”主路径”的日子已经过去。
1.3 什么时候必须学 SAML
以下场景里学 SAML 不是加分项,而是入场券:
- 做 B2B SaaS,ICP(理想客户画像)包含金融、保险、医疗、制造业、政府、大型零售;
- 在 Salesforce AppExchange、Atlassian Marketplace、ServiceNow Store 上架并允许企业客户 SSO;
- 接入高等教育联盟(InCommon、eduGAIN),Shibboleth 是事实标准;
- 产品需要经过 SOC 2 Type II、ISO 27001、FedRAMP 审计,客户评估问卷里往往明确问”是否支持 SAML 2.0 SP-initiated SSO”。
如果你的用户只是个人消费者,或者 B2B 只面向其他初创公司,那么你可以把本文当作知识储备。否则,继续往下读。
二、SAML 2.0 核心概念速览
2.1 三个参与方
SAML 定义了一个三方关系,术语必须记住:
- Identity Provider(IdP):身份提供方,负责认证用户、签发断言。企业客户的 IdP 通常是 PingFederate、ADFS、Okta 等,对你来说是外部系统。
- Service Provider(SP):服务提供方,也就是你的应用。SP 接收并验证 IdP 签发的断言,建立本地会话。
- Principal:主体,通常就是人类用户。在协议文本里出现,在工程中你通常称其为 user 或 subject。
这和 OIDC 的 RP(Relying Party)/ OP(OpenID Provider)/ End-User 是对应关系:IdP ≈ OP、SP ≈ RP、Principal ≈ End-User。
2.2 三种断言类型
断言(Assertion)是 IdP 签发给 SP 的 XML 文档,声明”关于某个主体的某个事实”。SAML 2.0 规范定义了三种:
- Authentication Statement(认证断言):主体在某时某刻通过某种方式(AuthnContext)完成了认证。这是绝大多数 SSO 场景里唯一真正使用的断言类型。
- Attribute Statement(属性断言):主体具有哪些属性(email、部门、组、manager 等)。SP 通常依赖这些属性做用户创建(JIT provisioning)或授权决策。
- Authorization Decision Statement(授权决策断言):主体是否被允许对某资源做某动作。实践中几乎没人用——授权决策通常由 SP 自己做或走 XACML / Rego,原因是 IdP 一般不了解 SP 的资源粒度。
2.3 四种绑定
绑定(Binding)规定”SAML 消息如何通过某种传输层送达”。SAML 2.0 规范里有多种,工程中只需记住四种:
- HTTP Redirect Binding:通过 URL
查询参数传递 SAML 消息,经 DEFLATE 压缩 + Base64 编码 + URL
编码。受 URL 长度限制,只适合小消息,典型用途是
AuthnRequest和LogoutRequest。 - HTTP POST Binding:通过 HTML
表单自动提交传递,消息 Base64 编码后放在
<input type="hidden">里。SAMLResponse(包含断言)几乎总是走 POST。 - HTTP Artifact Binding:前端只传一个”artifact”引用,SP 通过后端 SOAP 通道向 IdP 拉取实际断言。安全性更好(断言不经浏览器),但要求 IdP 对 SP 开放后端接口,现代公网场景极少使用。
- SOAP Binding:纯后端通道,用于某些元数据交换与属性查询。对 Web SSO 无关。
2.4 几个关键协议消息
AuthnRequest:SP 发给 IdP,请求认证用户。Response:IdP 发给 SP,包含一个或多个 Assertion。LogoutRequest/LogoutResponse:单点登出(Single Logout, SLO),复杂度极高,实现不完整的情况普遍。ArtifactResolve/ArtifactResponse:配合 Artifact Binding 使用。
三、断言结构:从一份真实的 SAMLResponse 开始
3.1 AuthnRequest 示例
以下是 SP 发给 IdP 的一个典型
AuthnRequest(未压缩编码前的 XML):
<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"
Version="2.0"
IssueInstant="2026-04-21T09:12:33Z"
Destination="https://idp.example-bank.com/idp/SSO.saml2"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
AssertionConsumerServiceURL="https://app.vendor.io/saml/acs">
<saml:Issuer>https://app.vendor.io/saml/metadata</saml:Issuer>
<samlp:NameIDPolicy
Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
AllowCreate="true"/>
<samlp:RequestedAuthnContext Comparison="exact">
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
</samlp:AuthnRequest>几个必须理解的字段:
ID:本次请求的唯一标识。SP 必须保存它,用于后续在Response的InResponseTo中匹配,防止断言重放和 CSRF。Destination:IdP 的 SSO endpoint。IdP 会校验此字段是否就是自己,否则拒绝。AssertionConsumerServiceURL(简称 ACS URL):IdP 签发响应后 POST 回的 SP 端点。许多 IdP 会要求该 URL 必须在事先交换的 SP metadata 中注册,运行时请求里传过来的 ACS 必须匹配其中之一。NameIDPolicy:请求使用哪种 NameID 格式。常见是emailAddress、persistent(跨会话稳定)、transient(仅本次会话)、unspecified。RequestedAuthnContext:请求的认证强度。PasswordProtectedTransport表示 HTTPS + 密码即可,更强的有MultiFactorAuth、X509等。
通过 HTTP Redirect 发送时,上面这段 XML 会被
DEFLATE(原始 deflate,RFC 1951,不是 zlib
头)压缩、再 Base64 编码、再 URL 编码,最终成为
?SAMLRequest=... 参数。如果 SP
有自己的签名密钥,还会附加
SigAlg=...&Signature=...(即所谓
Redirect-Signing,签名是对拼接后的查询字符串做的,不是对 XML
做的)。
3.2 SAMLResponse 示例
以下是 IdP 签发的 Response(已省略 base64
编码环节,展示解码后结构):
<samlp:Response
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_0e1a8f9d-44b2-4d21-8a3b-6f5c0e0dcb10"
InResponseTo="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"
Version="2.0"
IssueInstant="2026-04-21T09:12:41Z"
Destination="https://app.vendor.io/saml/acs">
<saml:Issuer>https://idp.example-bank.com/idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion
ID="_3b9a7c4e-6d01-42b7-91ff-2a4f5d7e3210"
Version="2.0"
IssueInstant="2026-04-21T09:12:41Z">
<saml:Issuer>https://idp.example-bank.com/idp</saml:Issuer>
<!-- 数字签名在此(enveloped signature),省略 -->
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">...</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
alice.chen@example-bank.com
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData
NotOnOrAfter="2026-04-21T09:17:41Z"
Recipient="https://app.vendor.io/saml/acs"
InResponseTo="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions
NotBefore="2026-04-21T09:12:11Z"
NotOnOrAfter="2026-04-21T09:17:41Z">
<saml:AudienceRestriction>
<saml:Audience>https://app.vendor.io/saml/metadata</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement
AuthnInstant="2026-04-21T09:12:40Z"
SessionIndex="_sess-82ab14e6"
SessionNotOnOrAfter="2026-04-21T17:12:40Z">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="email"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue>alice.chen@example-bank.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="displayName">
<saml:AttributeValue>Alice Chen</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="department">
<saml:AttributeValue>Risk Management</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="groups">
<saml:AttributeValue>vendor-admins</saml:AttributeValue>
<saml:AttributeValue>risk-analysts</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>SP 必须校验的内容(顺序很重要):
- XML Schema 合法,未包含 DOCTYPE(防 XXE),未包含不期望的外部引用;
- 签名算法在白名单内(禁用 SHA-1 / RSA-MD5 等,禁止
alg=none之类的极端情况); - 签名覆盖
<Assertion>或<Response>(见下文 XSW); - 签名验证通过,使用预先交换的 IdP X509 证书或其 SubjectKeyIdentifier 匹配;
Issuer与 SP 对该 IdP 的预期一致;Destination等于当前 ACS URL,Recipient同样等于 ACS URL;InResponseTo存在且等于 SP 侧保存的某个未消费 AuthnRequest ID;NotBefore、NotOnOrAfter覆盖当前时间(允许 ±2~3 分钟时钟偏移),SubjectConfirmationData/NotOnOrAfter同样有效;AudienceRestriction/Audience包含自己的 EntityID;- 断言未被消费过(ID 去重,通常基于 Assertion ID 做一段时间 Redis 缓存);
- 通过后提取 NameID / Attributes,落地本地 session。
这 11 步里任何一步 SP 实现不全,都会出现现实中真实发生过的安全事故。
3.3 签名位置:Response 级 vs Assertion 级
SAML 允许签名出现在两个位置:
- 在
<samlp:Response>上(签名覆盖整个响应); - 在
<saml:Assertion>上(签名仅覆盖断言,可以被重打包到另一个 Response 里)。
一些 IdP 同时签名两者。SP 实现必须明确声明自己的策略——最健壮的做法是只信任 Assertion 级签名(不管 Response 是否额外签名),且在验证签名后以签名过的 Assertion 元素为唯一数据源,不要回退到 DOM 里任何未被签名覆盖的同名节点。这是防御 XML Signature Wrapping 的关键。
四、SP-initiated 流程逐步时序
4.1 完整步骤
用户访问 SP 的受保护资源:浏览器
GET https://app.vendor.io/dashboard,SP 发现无本地 session。SP 构造 AuthnRequest:生成 ID,记录到短期存储(例如 Redis,key 即 ID,TTL 5 分钟),将
Destination设为 IdP SSO endpoint。选择 Redirect Binding 则 DEFLATE+Base64+URL-encode,选择 POST Binding 则只 Base64。SP 把浏览器重定向到 IdP:Redirect Binding 下返回
302 Location: https://idp.example-bank.com/idp/SSO.saml2?SAMLRequest=...&RelayState=<不透明>。RelayState 通常用于告诉自己登录完成后跳回哪个页面,不要把原始 URL 直接塞进去,而是塞一个 opaque key,server side 映射真实 URL。IdP 认证用户:如果 IdP 没有会话,弹出自己的登录页;如果有会话,直接进入下一步。此阶段 MFA、条件访问策略(Conditional Access)、设备合规检查等都在 IdP 侧完成,SP 看不见。
IdP 签发 SAMLResponse:生成包含 Assertion 的 Response,签名、(如配置)加密,Base64 编码,返回给浏览器一个自动提交的 HTML form:
<html> <body onload="document.forms[0].submit()"> <form method="POST" action="https://app.vendor.io/saml/acs"> <input type="hidden" name="SAMLResponse" value="PHNhbWxwOlJlc3BvbnNl..."> <input type="hidden" name="RelayState" value="opaque-key-abc"> </form> </body> </html>浏览器 POST 到 SP 的 ACS URL:SP 执行上节 11 步校验,通过后建立本地 session(set-cookie
sid=...; HttpOnly; Secure; SameSite=Lax),然后使用 RelayState 映射出的真实 URL 做 302 跳转。
4.2 IdP-initiated 流程
有些场景下是 IdP 主动发起,比如企业员工的门户页(如 Okta Dashboard)上有一堆应用图标,点击后直接 POST 一份 SAMLResponse 到 SP 的 ACS URL,不存在先前的 AuthnRequest。
这条流程的安全风险比 SP-initiated 高得多:
- 没有 InResponseTo 可验证:SP 无法确定这个响应对应哪个请求,天然无法防重放到单次登录行为;
- 天然 CSRF 风险:攻击者可以在自己的 IdP 上登录,获取对自己账户的 SAMLResponse,然后诱导受害者提交到 SP 的 ACS URL,使受害者以攻击者身份登录(account hijacking via SAML response injection);
- 许多 SP 在实现 IdP-initiated 时忘记校验 Audience 或 NameID 的业务合理性。
现代指导建议:除非客户强制要求,否则关闭 IdP-initiated 入口;必须支持时,要求请求带一次性 Token(SP 生成的 CSRF-like “unsolicited response nonce”,或至少在 ACS 处理成功后显示确认页让用户主动点击”Continue as …“)。OWASP SAML Cheatsheet 与 NIST SP 800-63C 对此都有明确警示。
4.3 RelayState 的正确姿势
RelayState 的语义是”协议透明的状态参数”,用于让 SP 在 SSO 完成后记起”用户原本要去哪里”。实现错误的常见姿势有:
RelayState = https://app.vendor.io/dashboard?project=123 // 错误:暴露内部 URL
RelayState = https://evil.com/phish // 错误:被利用做 open redirect
正确姿势:
# SP 端,发起 AuthnRequest 前
nonce = secrets.token_urlsafe(16)
redis.setex(f"relay:{nonce}", 600, json.dumps({
"target_url": "/dashboard?project=123",
"csrf": secrets.token_urlsafe(16),
}))
authn_request = build_authn_request(...)
redirect_to_idp(authn_request, relay_state=nonce)
# ACS 回调
payload = redis.get(f"relay:{request.form['RelayState']}")
if payload is None:
raise BadRequest("invalid RelayState")
target = json.loads(payload)["target_url"]
assert target.startswith("/") # 只允许相对路径
redis.delete(f"relay:{request.form['RelayState']}") # 一次性五、Metadata:SP 与 IdP 的”握手文件”
5.1 SP Metadata 必填
SP metadata 是你提供给客户 IdP 管理员的 XML 文件,告诉他们”我是谁、请把响应发到哪里、请用哪把公钥给我加密”。一个最小可用的 SP metadata 形如:
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://app.vendor.io/saml/metadata">
<SPSSODescriptor AuthnRequestsSigned="true"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>MIIDxx...signing</ds:X509Certificate></ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>MIIDxx...encryption</ds:X509Certificate></ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://app.vendor.io/saml/slo"/>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://app.vendor.io/saml/acs"/>
</SPSSODescriptor>
<Organization>
<OrganizationName xml:lang="en">Vendor Inc.</OrganizationName>
<OrganizationDisplayName xml:lang="en">Vendor</OrganizationDisplayName>
<OrganizationURL xml:lang="en">https://vendor.io</OrganizationURL>
</Organization>
<ContactPerson contactType="technical">
<EmailAddress>security@vendor.io</EmailAddress>
</ContactPerson>
</EntityDescriptor>关键属性:
entityID:SP 的唯一标识,通常就是 metadata URL 本身。任何后续通信里的Issuer、Audience都用它。AuthnRequestsSigned="true":SP 签名自己的请求,IdP 需要验证。WantAssertionsSigned="true":要求 IdP 对 Assertion 级签名。<KeyDescriptor use="signing">与<KeyDescriptor use="encryption">:分离签名与加密证书是最佳实践——但许多老 IdP 不支持加密证书分离,它们只会看第一个KeyDescriptor,实际部署前要沟通。
5.2 IdP Metadata 解析
客户给你的 IdP metadata 结构类似,重点字段:
<IDPSSODescriptor WantAuthnRequestsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">...X509Certificate...</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<SingleSignOnService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example-bank.com/idp/SSO.saml2"/>
<SingleSignOnService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example-bank.com/idp/SSO.saml2"/>
</IDPSSODescriptor>SP 必须提取:
- IdP 的
entityID(将用于断言Issuer校验); - 签名证书(一个或多个——旧证书未撤销期间可能并存);
SingleSignOnService的 Redirect 和 POST Location;- 可选的
SingleLogoutService。
5.3 Metadata 自动化
在生产中,不要把 metadata XML 写死在代码或配置里。推荐:
- 客户提供 metadata URL(许多 IdP 会暴露一个稳定的
https://idp.example.com/idp/shibboleth或https://.../FederationMetadata/2007-06/FederationMetadataFile.xml); - SP 每日拉取一次该 URL,缓存在本地数据库,记录其
validUntil; - 新证书一旦出现立即信任(这是证书轮换的基础);
- 建议 metadata 本身也带签名(Metadata Signing),用一组 Federation 根证书验证,避免运行时 pin URL 被 MITM。
六、签名、加密、证书轮换
6.1 签名算法
SAML 2.0 的签名基于 XML Digital Signature(XMLDSIG)。常见算法 URI:
http://www.w3.org/2000/09/xmldsig#rsa-sha1(已淘汰)http://www.w3.org/2001/04/xmldsig-more#rsa-sha256(推荐)http://www.w3.org/2001/04/xmldsig-more#rsa-sha384/#rsa-sha512http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256(部分旧 IdP 不支持)
规范化(Canonicalization)方法:
http://www.w3.org/2001/10/xml-exc-c14n#(Exclusive Canonical XML,最常用)http://www.w3.org/TR/2001/REC-xml-c14n-20010315(Inclusive)
工程陷阱:若客户 IdP 仍然只用 SHA-1,标准操作是写入合同或集成备忘录里记录这一限制,在 SP 侧显式为该 IdP 打开 SHA-1 白名单(隔离配置,不影响其他客户),并推动对方在下次大版本升级前切到 SHA-256。不要悄悄全局支持 SHA-1。
6.2 断言加密
当断言携带敏感信息(如 SSN、工资带、客户 ID)且你担心断言在浏览器-SP 路径上被记录(Web 代理日志、HAR 上传、浏览器扩展)时,使用 XML Encryption 加密 Assertion:
- Key Transport:IdP 生成随机 AES
密钥(Content Encryption Key,CEK),用 SP 公钥 RSA-OAEP
加密 CEK,用 CEK 加密 Assertion。RSA 公钥从 SP metadata 的
KeyDescriptor use="encryption"获取。 - Key Agreement:使用 ECDH-ES 协商 CEK。支持度参差不齐,企业 IdP 不都支持。
加密后的结构如下:
<saml:EncryptedAssertion>
<xenc:EncryptedData Type="http://www.w3.org/2001/04/xmlenc#Element">
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/>
<ds:KeyInfo>
<xenc:EncryptedKey>
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
<xenc:CipherData><xenc:CipherValue>...CEK...</xenc:CipherValue></xenc:CipherData>
</xenc:EncryptedKey>
</ds:KeyInfo>
<xenc:CipherData><xenc:CipherValue>...ciphertext...</xenc:CipherValue></xenc:CipherData>
</xenc:EncryptedData>
</saml:EncryptedAssertion>选择要点:AES-GCM(aes128-gcm /
aes256-gcm)优于 AES-CBC(历史上 CBC 存在
padding oracle 等);许多库的默认仍是
CBC,需要显式切换。
6.3 证书轮换:双证书滚动
这是运维团队最常见的事故源之一。典型事故模板:“周一早上
9:03,500 多个 SSO 用户同时无法登录,错误信息是
Signature verification failed——原因是 IdP
管理员昨晚轮换了签名证书,忘了提前通知 SP 团队”。
SP 侧要做到不停机更换证书:
- 在 metadata 里同时保留两个 signing KeyDescriptor,一新一旧,任一能验证通过即视为签名有效;
- 代码层面允许
trusted_signing_certs为列表,按指纹匹配; - 新证书生效后等待至少 N 天(N ≥ 2 × 最大 session 生命周期),确认没有残留引用,再从配置里删除旧证书;
- 监控签名失败率,即使未全面失败也要告警;
- 证书到期前至少 30 天发出第一轮告警、14 天升级、7 天电话提醒。
IdP 侧同样:发布新证书前至少 30 天更新 metadata,让所有 SP 都拉取到”双证书”状态。
七、参考实现:Java 与 Python
7.1 Java:OpenSAML 4 最小示例
OpenSAML(由 Shibboleth 项目维护)是 JVM 生态里最主流的 SAML 库。构造 AuthnRequest:
import org.opensaml.core.config.InitializationService;
import org.opensaml.saml.saml2.core.*;
import org.opensaml.saml.saml2.core.impl.*;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
InitializationService.initialize();
XMLObjectBuilderFactory bf = XMLObjectProviderRegistrySupport.getBuilderFactory();
AuthnRequest ar = (AuthnRequest) bf
.getBuilder(AuthnRequest.DEFAULT_ELEMENT_NAME)
.buildObject(AuthnRequest.DEFAULT_ELEMENT_NAME);
ar.setID("_" + UUID.randomUUID());
ar.setVersion(SAMLVersion.VERSION_20);
ar.setIssueInstant(Instant.now());
ar.setDestination("https://idp.example-bank.com/idp/SSO.saml2");
ar.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
ar.setAssertionConsumerServiceURL("https://app.vendor.io/saml/acs");
Issuer issuer = (Issuer) bf.getBuilder(Issuer.DEFAULT_ELEMENT_NAME)
.buildObject(Issuer.DEFAULT_ELEMENT_NAME);
issuer.setValue("https://app.vendor.io/saml/metadata");
ar.setIssuer(issuer);
NameIDPolicy nip = (NameIDPolicy) bf.getBuilder(NameIDPolicy.DEFAULT_ELEMENT_NAME)
.buildObject(NameIDPolicy.DEFAULT_ELEMENT_NAME);
nip.setFormat(NameIDType.EMAIL);
nip.setAllowCreate(true);
ar.setNameIDPolicy(nip);验证 Response 的关键片段(省略 context/criteria 设置):
ResponseUnmarshaller um = (ResponseUnmarshaller) registry.getUnmarshallerFactory()
.getUnmarshaller(Response.DEFAULT_ELEMENT_NAME);
Response response = (Response) um.unmarshall(xmlElement);
SignatureValidator.validate(response.getAssertions().get(0).getSignature(), credential);
SAML20AssertionValidator validator = new SAML20AssertionValidator(
conditionValidators, subjectConfirmationValidators, statementValidators,
null, signaturePrevalidator, signatureTrustEngine);
ValidationResult result = validator.validate(response.getAssertions().get(0), context);
if (result != ValidationResult.VALID) {
throw new SecurityException("Assertion rejected");
}实务建议:不要自己写 XML 解析和签名验证。使用 OpenSAML、Spring Security SAML2、或 Keycloak SAML Adapter。
7.2 Python:python3-saml
OneLogin 的 python3-saml 库是 Python
生态事实标准:
from onelogin.saml2.auth import OneLogin_Saml2_Auth
def init_saml_auth(request):
return OneLogin_Saml2_Auth(
{
"https": "on",
"http_host": request.host,
"server_port": "443",
"script_name": request.path,
"get_data": request.args.to_dict(),
"post_data": request.form.to_dict(),
},
custom_base_path="/etc/app/saml",
)
# 发起
def login(request):
auth = init_saml_auth(request)
return redirect(auth.login(return_to="/dashboard"))
# ACS 回调
def acs(request):
auth = init_saml_auth(request)
auth.process_response()
errors = auth.get_errors()
if errors:
abort(400, "SAML errors: " + ", ".join(errors))
if not auth.is_authenticated():
abort(401)
attrs = auth.get_attributes()
nameid = auth.get_nameid()
session["user"] = {"email": nameid, "groups": attrs.get("groups", [])}
return redirect(auth.redirect_to(request.form.get("RelayState", "/")))settings.json 里必须设置:
{
"strict": true,
"security": {
"authnRequestsSigned": true,
"wantAssertionsSigned": true,
"wantAssertionsEncrypted": false,
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
"rejectUnsolicitedResponsesWithInResponseTo": true,
"wantMessagesSigned": true
}
}strict: true
是任何生产环境的强制要求——它打开全部时间窗、Audience、Destination
校验。把它设成 false 上线是最常见的低级错误。
7.3 Go:crewjam/saml
sp := saml.ServiceProvider{
EntityID: "https://app.vendor.io/saml/metadata",
Key: spKey,
Certificate: spCert,
AcsURL: mustURL("https://app.vendor.io/saml/acs"),
MetadataURL: mustURL("https://app.vendor.io/saml/metadata"),
IDPMetadata: idpMetadata,
AllowIDPInitiated: false,
SignatureMethod: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
}注意 AllowIDPInitiated: false
是默认选项——除非客户强制要求,否则保留。
八、安全陷阱与 XML 签名包装攻击
8.1 XML Signature Wrapping(XSW)
XSW 是 SAML 历史上最具代表性的攻击。原理是:XML
签名的”被签对象”是通过 ds:Reference 的 URI
指针标识的(例如
URI="#_3b9a7c4e..."),签名验证时库会:
- 找到那个 ID 对应的元素,规范化,计算 hash;
- 与签名中的 DigestValue 比较;
- 用公钥验证 SignedInfo。
但签名验证之后,应用代码是从哪个 DOM 节点读取
Subject/NameID/Attributes 的?如果读取的是 “找到的第一个
<Assertion>” 或类似 XPath,而 XML
文档里被攻击者额外塞入了另一个同名元素,且签名库只验证了原始带
ID 的那一份——应用就可能读到未被签名的伪造断言。
一个经典 XSW 变体 payload(简化):
<samlp:Response>
<!-- 攻击者伪造的 Assertion(未签名),放在前面 -->
<saml:Assertion ID="_evil">
<saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
<!-- ... 伪造属性 ... -->
</saml:Assertion>
<!-- IdP 原始的、签名真实覆盖的 Assertion -->
<saml:Assertion ID="_3b9a7c4e...">
<ds:Signature>
<ds:SignedInfo>
<ds:Reference URI="#_3b9a7c4e...">...</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>...</ds:SignatureValue>
</ds:Signature>
<saml:Subject><saml:NameID>alice.chen@example-bank.com</saml:NameID></saml:Subject>
</saml:Assertion>
</samlp:Response>此时如果代码写成:
// 错误代码!
Response resp = unmarshall(xml);
SignatureValidator.validate(resp.getSignature(), cred); // 验证通过
NameID name = resp.getAssertions().get(0).getSubject().getNameID(); // 读到了 _evil就被绕过了。SAML 安全研究者至少总结出 7 种 XSW 变体(Juraj Somorovsky 等人 2012 年论文),差别在于攻击者将伪造节点塞在文档的哪个位置:根前、签名兄弟、签名子节点、旧签名引用链外等。
8.2 XSW 的正确防御
- 使用”先验签再解析”的路径:把经过签名验证的
Assertion元素以其引用的 ID 为唯一锚点重新反序列化,不依赖文档位置; - 拒绝出现多个 Assertion 的 Response(除非你显式支持);
- 拒绝文档中出现签名 URI 未指向的多余
<Assertion>节点; - 使用成熟库(OpenSAML、python3-saml
新版、Shibboleth SP)并保持更新,不要自己基于
lxml+xmlsec1手搓验证; - 禁用 DOCTYPE 与外部实体解析,防
XXE(
libxml2默认不安全,需显式XML_PARSE_NOENT=0并拒绝<!DOCTYPE>)。
8.3 Duplicate Assertion / 重放
即使签名正确,同一份 SAMLResponse 能用第二次就是事故。防御:
- SP 维护一个最近 N 分钟(≥
NotOnOrAfter - NotBefore最大值)内已消费的 Assertion ID 集合(Redis SET,TTL 1 小时); - ACS 处理时先做
SADD key assertion_id,若返回 0 视为重放,直接拒绝; - 日志中记录 Assertion ID 与客户端 IP,便于溯源。
8.4 RelayState 开放重定向
若 SP 在 SSO 完成后把 RelayState 当作
Location 直接跳转,而 RelayState
本身可被任意构造,就是教科书级 open
redirect。除了前文”只允许相对路径 + 服务端 opaque
key”的写法,还可以在配置里维护白名单域名并严格校验。
8.5 IdP-initiated 下的 CSRF / Response Injection
前文已述。补一条具体缓解:ACS 端在”首次看到无 InResponseTo 的 Response”时,跳转到一个确认页(“You are logging in as Alice Chen from Example Bank, continue?”),只有用户主动点击才建立 session。这能阻断大多数”诱骗 Victim POST 攻击者的 SAMLResponse”场景。
8.6 元数据不信任 / 中间人
若 SP 从客户给的 HTTP URL 拉取 metadata(而非 HTTPS,或虽 HTTPS 但未 pin 证书),中间人可以替换 metadata 里的签名证书,从而签发任意断言。缓解:
- 只从 HTTPS URL 拉取;
- 要求 metadata 本身带 XMLDSIG,使用带外交换的根证书验证;
- 新增或更换证书时触发告警,要求人工确认后生效。
8.7 其他速查清单
| 风险 | 要点 |
|---|---|
| XXE | 禁用 DTD / 外部实体 |
| XSLT 注入 | 禁用 XSLT Transform |
| 时钟偏移 | 允许 ±3 分钟,超过触发告警 |
| NameID 混淆 | 明确 Format,持久 NameID 不随邮箱变更 |
| Session 绑定 | Assertion 成功后重新生成 session id |
| SLO 实现不全 | 可接受,但文档要写明 |
九、SAML 与 OIDC 的共存与迁移
9.1 共存策略
大多数 B2B SaaS 在现实中是这样的:新客户走 OIDC,老客户走 SAML。同一个用户对象模型在后端,只是前端认证路径有两条:
┌─ /sso/oidc/{tenant} ──► OIDC flow ─┐
Login page ─────►│ ├──► session
└─ /sso/saml/{tenant} ──► SAML flow ─┘
tenant 是客户标识,配置里绑定该客户的 IdP 详情。设计要点:
- 用户标识解耦:内部 user_id 独立于 NameID / sub,以”tenant_id + external_id”做唯一索引;
- 属性映射在 tenant 级可配置:不同客户的 IdP 可能把部门放在不同 Attribute Name 下;
- 会话统一:SAML 成功后走与 OIDC 完全相同的 session 发放逻辑,避免两条路径两个 bug 面。
9.2 代理桥接模式
如果你的后端不想直接讲 SAML(例如主产品已深度依赖 OIDC 库、上游服务是纯 JWT 鉴权),可以引入桥接组件:
- Keycloak:可以作为中间 IdP,南向对客户 IdP 做 SAML SP,北向对你的应用暴露 OIDC OP。配置中称为 “Identity Brokering”。
- Shibboleth SP + OIDC 前置:更重,但高等教育联盟场景下是事实标准。
- 自建 saml-to-oidc 代理:极不推荐,除非有专职安全团队。
桥接模式的架构示意:
Client IdP ──SAML──► Keycloak ──OIDC──► Your App
(PingFederate) (broker realm) (standard RP)
权衡:桥接降低了应用团队的认知负担,但引入了新的运维目标(Keycloak 的高可用、升级、漏洞跟踪),并且 SAML 侧的坑(证书轮换、XSW 防御)仍然存在,只是下沉到了 Keycloak 运维团队。
9.3 原生接入 vs 桥接:怎么选
| 场景 | 原生接 SAML | 用 Keycloak / Auth0 桥接 |
|---|---|---|
| 已有成熟 Java / XML 安全经验 | 更合适 | 也可行,但收益没那么大 |
| 主系统已全面 OIDC 化 | 成本偏高 | 优先选择 |
| 只有 1–2 个大客户需要 SAML | 可接受 | 视团队能力决定 |
| 未来会有 10+ 企业客户接入 | 容易形成维护负担 | 更适合长期复用 |
| 团队没有专职身份平台运维 | 风险较高 | 采购型桥接更稳 |
| 需要统一审计、统一 session、统一 token 形态 | 需要自己整合 | 桥接优势明显 |
一句话判断:如果你的应用内部已经是 OIDC / JWT 世界,就尽量不要把 XML 解析和签名验证逻辑直接引进业务代码。
9.4 何时真正迁移
企业客户从 SAML 迁到 OIDC 的动因通常是:
- IdP 升级换代(例如从 ADFS 2016 迁到 Entra ID,OIDC 成为默认);
- 原有应用加入移动端或 API 场景,SAML 不适合;
- 审计发现 SAML 证书管理流程老化,希望借升级重构。
迁移本身是一项独立工程,参见《SSO 与 OIDC:从一次登录到统一身份》。对 SP 而言,最低成本策略是让两条路径并存 6-12 个月,按租户切换,保留回滚窗口。
十、工程坑点清单
10.1 实施期常见坑
- Clock skew 引发 5% 登录失败:SP 服务器
NTP 漂移,
NotBefore校验被拒。部署 chrony,允许 ±3 分钟,超过 2 分钟触发告警。 - IdP 返回的 Assertion 少属性:客户 IdP
管理员没映射
groups,导致 JIT provisioning 的用户角色为空,权限失败。集成 checklist 必须包含”提交一次端到端测试断言样例”。 <号被双重编码:Base64 里套 URL-encode 里再套 HTML-escape。调试时用python -c "import base64,zlib;print(zlib.decompress(base64.b64decode(s), -15).decode())"解码 Redirect Binding。- 签名算法谈判失败:IdP 仍用 SHA-1,SP 强制 SHA-256,IdP 拒绝接受请求。默认强制 SHA-256;如因客户历史包袱必须兼容 SHA-1,只允许按租户临时白名单放开,设置到期时间、额外告警和迁移计划。
- RelayState 长度超限:一些老 IdP 的 RelayState 最大 80 字节(规范建议),你塞 JSON 直接爆掉。使用 opaque key。
10.2 生产期常见事故
- 证书到期:前文已述,监控 + 双证书策略。
- IdP 变更 EntityID:罕见但会发生(例如客户搬迁 PingFederate 集群)。SP 应允许”给某租户配置多个信任的 Issuer”过渡期。
- 单点登出风暴:SLO 通知所有已登录 SP,一次性发数百个 LogoutRequest,SP 如果同步处理会超时。做异步队列。
- Assertion 重放引发权限漏洞:如 8.3。必须上 Redis 去重。
- XSW 攻击真实发生:不是理论,2011-2014 年多家企业 SaaS(包括 Salesforce、IBM、Google Apps)被公开披露过类似漏洞。保持 SAML 库更新。
十一、选型与迁移建议
11.1 客户联调 checklist
和企业客户 IT 团队联调前,最好一次把下面这些信息要齐:
- metadata URL 或 metadata XML(优先 URL)
- EntityID、ACS URL、回调环境(测试 / 生产)
- NameID 格式要求(email、persistent、transient)
- 必需属性清单(email、firstName、lastName、groups、department)
- 签名算法与证书轮换联系人
- 是否要求 IdP-initiated
- 是否要求 SLO
- 一份真实测试断言样例
没有这张清单,后续绝大多数「为什么就是登不上」都会变成跨公司来回扯皮。
- 新项目首选 OIDC。只在 B2B 大客户强制要求时补充 SAML 适配层。
- 如果必须支持 SAML,优先使用桥接(Keycloak 或 Auth0 / Okta 的 Inbound SAML 功能),不要让业务代码里出现 XML。
- 如果产品已有 SAML 代码,优先迁到主流库的最新版(OpenSAML 5、python3-saml 最新),不要停留在 2018 年的分支。
- 证书轮换与监控优先级 = P0。其余功能可以延后,证书过期无可辩解。
- IdP-initiated 默认关闭。客户提要求时单独评估。
- SLO 默认不实现。绝大多数客户不要求,实现不完整反而是坑。文档里写”Single Logout 不支持,登出需在各 SP 独立执行”即可。
- 与客户 IT 对接时坚持使用 metadata URL,不要接收手改过的 XML 附件——版本跟踪、证书轮换全都会出问题。
- 审计日志:ACS 成功/失败、断言 ID、Issuer、NameID、IP、User-Agent、耗时。默认打开并保留至少 180 天。
回到开头那家初创公司:签下银行合同的第二周,他们选择不自己写 SAML 代码,而是在现有 OIDC 架构前面部署了一台 Keycloak 作为 SAML-to-OIDC broker。第 11 天通过了银行 IT 的联调,第 14 天 GA 上线。六个月后他们又签下两家保险客户,broker 路径被复用,零额外开发量。SAML 并不值得”学得精通”;但值得你掌握到”能一周内让客户满意、不出安全事故”的程度——这正是本文的目标。
十二、参考资料
- OASIS
SAML 2.0
规范总集——
saml-core-2.0-os.pdf、saml-bindings-2.0-os.pdf、saml-profiles-2.0-os.pdf、saml-metadata-2.0-os.pdf四件套 - RFC 7522 SAML 2.0 Bearer Assertion for OAuth 2.0
- NIST SP 800-63C Federation and Assertions
- OWASP SAML Security Cheat Sheet
- Juraj Somorovsky et al., On Breaking SAML: Be Whoever You Want to Be, USENIX Security 2012——XSW 论文
- OpenSAML 5 Wiki
- python3-saml
- crewjam/saml (Go)
- Keycloak Identity Brokering 文档
- SAMLtool 在线调试器(解码 SAMLRequest / SAMLResponse)
- 《IAM 全景:身份、认证、授权与治理的边界》
- 《SSO 与 OIDC:从一次登录到统一身份》
- 《认证架构:从密码到零信任》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】IAM 全景:为什么这是高价值赛道
身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】Keycloak 工程拆解:Realm、Client、Flow 与扩展机制
从 Quarkus runtime、Infinispan 缓存、数据库 schema,到 Authentication Flow 引擎、SPI 扩展点、multi-site 部署与常见工程坑点,拆解 Keycloak 的真实工程形态与选型边界。