我们新上线的 Flutter 应用,其用户认证流程是一个彻底的黑盒。它依赖于公司统一的 SAML 2.0 单点登录(SSO)体系,当用户反馈“登录太慢”或者“登录失败”时,排查过程异常痛苦。在可观测性平台上,我们只能看到从 Dart 客户端发出的一个初始登录请求,然后链路就断了。用户的浏览器被重定向到身份提供商(IdP),后续的所有交互,直到最终认证成功返回我们的应用,整个过程在追踪系统里是一片空白。这对于一个需要对SLA负责的团队来说,是无法接受的。
初步的构想很简单:全面接入 OpenTelemetry。但问题随之而来,标准的 W3C Trace Context Propagator 依赖于在服务间通过 HTTP Header(即 traceparent
)传递上下文。SAML 流程的本质是基于浏览器的重定向,我们无法控制用户浏览器向第三方 IdP 发送的请求头。当流程从我们的服务(SP)重定向到 IdP,再由 IdP 重定向回我们的断言消费服务(ACS)时,追踪上下文早已丢失。
在真实项目中,直接放弃追踪这条关键路径不是一个选项。我们必须找到一种方法,在 SAML 这种“非直连”的通信模式下,将断裂的链路重新缝合起来。我们的决策是设计一套自定义的上下文传播机制,利用 SAML 协议中一个标准的、可用于传递状态的字段:RelayState
。
整个方案的核心思路如下:
- 后端 SP (Service Provider): 在发起 SAML 认证请求前,启动一个新的 Trace Span。将当前的
TraceContext
(包含trace-id
和parent-span-id
)打包成一个自包含了签名和时间戳的紧凑令牌(例如一个短时效的 JWT)。 - 上下文传递: 将这个令牌作为
RelayState
参数的值,附加到发送给 IdP 的 SAML AuthnRequest 中。IdP 在处理认证后,必须原封不动地将RelayState
返回给我们的 ACS 端点。 - 后端 ACS (Assertion Consumer Service): 在 ACS 端点,我们从 IdP 发回的 SAMLResponse 中解析出
RelayState
。验证令牌的签名和时效性,然后提取出TraceContext
。 - 链路缝合: 使用提取出的
TraceContext
作为父上下文,启动一个新的 Span。这样,ACS 的处理逻辑就成功地与初始登录请求关联到了同一条 Trace 中。 - Dart 客户端: 客户端的追踪保持不变,它负责发起初始登录和接收最终结果,形成链路的起点和终点。
下面是这个流程的示意图和关键部分的代码实现。我们后端选用 Go 语言,因为它在处理 HTTP 和加解密方面高效且直接。
sequenceDiagram participant DartClient as Dart/Flutter App participant BackendSP as Backend SP (/login/saml) participant IdP as Identity Provider participant BackendACS as Backend ACS (/saml/acs) DartClient->>+BackendSP: POST /api/login (starts trace T1, span S1) BackendSP->>BackendSP: 1. Start new span S2 (child of S1) BackendSP->>BackendSP: 2. Create TraceContext token from S2 BackendSP->>BackendSP: 3. Generate SAML AuthnRequest with RelayState=token BackendSP-->>-DartClient: HTTP 302 Redirect to IdP Note over DartClient, IdP: User's browser handles redirects. TraceContext via header is lost. DartClient->>+IdP: User authenticates IdP-->>-DartClient: HTTP 302 Redirect to ACS with SAMLResponse & RelayState DartClient->>+BackendACS: POST /saml/acs (with SAMLResponse & RelayState) BackendACS->>BackendACS: 1. Extract RelayState (token) BackendACS->>BackendACS: 2. Validate token & extract TraceContext BackendACS->>BackendACS: 3. Start new span S3, linking to S2 via context BackendACS-->>-DartClient: Login success, set session cookie Note over BackendSP, BackendACS: Spans S1, S2, and S3 are now connected in the same trace T1.
第一步: 后端服务提供商(SP)的埋点
这是用户登录请求的入口。它需要生成 SAML 请求,并把我们的追踪信标(TraceContext
Token)植入其中。我们定义一个 TraceContextPropagator
结构体来封装这个逻辑。
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// RelayStatePropagator 负责在 RelayState 中打包和解包 OpenTelemetry 上下文
type RelayStatePropagator struct {
secretKey []byte
}
// NewRelayStatePropagator 创建一个新的传播器实例
// 在生产环境中,secretKey 应该从安全的配置源加载
func NewRelayStatePropagator(secret string) *RelayStatePropagator {
return &RelayStatePropagator{secretKey: []byte(secret)}
}
// a common mistake is not providing a sufficiently long or random secret key.
const (
tokenVersion = "v1"
separator = "|"
timestampLayout = time.RFC3339
tokenTTL = 5 * time.Minute // SAML 流程通常在几分钟内完成
)
// Inject 将当前的 TraceContext 注入到一个安全的、可作为 RelayState 的字符串中
func (p *RelayStatePropagator) Inject(ctx context.Context) (string, error) {
sc := trace.SpanContextFromContext(ctx)
if !sc.IsValid() {
// 如果没有有效的上下文,则不注入任何内容,返回空 RelayState
return "", nil
}
// 1. 获取 W3C TraceContext 头部格式的字符串
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carrier)
traceparent := carrier["traceparent"]
if traceparent == "" {
return "", fmt.Errorf("failed to inject traceparent header")
}
// 2. 构造 payload
// 格式: version|timestamp|traceparent
timestamp := time.Now().UTC().Format(timestampLayout)
payload := strings.Join([]string{tokenVersion, timestamp, traceparent}, separator)
// 3. 计算签名
// 使用 HMAC-SHA256 保证完整性和真实性
h := hmac.New(sha256.New, p.secretKey)
h.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 4. 组合最终的 token
// 格式: payload|signature
token := fmt.Sprintf("%s%s%s", payload, separator, signature)
return base64.URLEncoding.EncodeToString([]byte(token)), nil // Base64 编码以确保 URL 安全
}
// HandleLoginRequest 是处理登录请求的 HTTP Handler
func (s *Server) HandleLoginRequest(w http.ResponseWriter, r *http.Request) {
// 使用 OTel HTTP 中间件后,这里已经有了一个 Span
ctx := r.Context()
tracer := otel.Tracer("saml-sp-tracer")
// 1. 启动一个新的子 Span,专门用于 SAML 请求生成
ctx, samlSpan := tracer.Start(ctx, "generate-saml-authn-request")
defer samlSpan.End()
// 2. 使用我们的自定义传播器生成 RelayState
relayState, err := s.propagator.Inject(ctx)
if err != nil {
http.Error(w, "Failed to create trace propagation token", http.StatusInternalServerError)
// 记录错误日志,这里是关键的排错点
log.Printf("Error injecting trace context into RelayState: %v", err)
return
}
// 3. 生成 SAML AuthnRequest (具体实现依赖 SAML 库, 此处为伪代码)
// authRequest, err := s.samlServiceProvider.CreateAuthnRequest(relayState)
// ... 错误处理
// 4. 将用户重定向到 IdP
// redirectURL := s.samlServiceProvider.GetRedirectURL(authRequest)
// http.Redirect(w, r, redirectURL, http.StatusFound)
// 伪代码响应
log.Printf("Generated RelayState: %s", relayState)
fmt.Fprintf(w, "Generated RelayState, would redirect to IdP now.")
}
这里的核心在于 RelayStatePropagator
的 Inject
方法。它不仅仅是传递 traceparent
,而是创建了一个有时效性、有签名的令牌。这是至关重要的,因为 RelayState
会暴露在用户浏览器中,必须防止被篡改或用于重放攻击。一个常见的错误是直接将 traceparent
放入 RelayState
,这既不安全,也丢失了状态信息(如 tracestate
)。
第二步: 断言消费服务(ACS)的实现
ACS 是 SAML 流程的终点,也是我们缝合链路的关键节点。它接收来自 IdP 的响应,并需要从中“复活”我们的追踪上下文。
// Extract 从 RelayState 字符串中解析出 TraceContext
func (p *RelayStatePropagator) Extract(relayState string) (context.Context, error) {
if relayState == "" {
return context.Background(), nil // 没有 RelayState,返回一个空的上下文
}
decoded, err := base64.URLEncoding.DecodeString(relayState)
if err != nil {
return nil, fmt.Errorf("invalid base64 RelayState: %w", err)
}
token := string(decoded)
// 1. 分离 payload 和 signature
parts := strings.Split(token, separator)
if len(parts) != 4 {
return nil, fmt.Errorf("invalid token format")
}
payload := strings.Join(parts[0:3], separator)
signature := parts[3]
// 2. 验证签名
h := hmac.New(sha256.New, p.secretKey)
h.Write([]byte(payload))
expectedSignature, _ := base64.URLEncoding.DecodeString(signature)
if !hmac.Equal(h.Sum(nil), expectedSignature) {
return nil, fmt.Errorf("invalid signature")
}
// 3. 验证时间戳
version, timestampStr, traceparent := parts[0], parts[1], parts[2]
if version != tokenVersion {
return nil, fmt.Errorf("unsupported token version: %s", version)
}
timestamp, err := time.Parse(timestampLayout, timestampStr)
if err != nil {
return nil, fmt.Errorf("invalid timestamp format: %w", err)
}
if time.Since(timestamp) > tokenTTL {
return nil, fmt.Errorf("token expired")
}
// 4. 从 traceparent 恢复上下文
carrier := propagation.MapCarrier{"traceparent": traceparent}
return otel.GetTextMapPropagator().Extract(context.Background(), carrier), nil
}
// HandleACSRequest 是处理 IdP 回调的 HTTP Handler
func (s *Server) HandleACSRequest(w http.ResponseWriter, r *http.Request) {
// 1. 解析 SAML 响应和 RelayState
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
samlResponse := r.PostFormValue("SAMLResponse")
relayState := r.PostFormValue("RelayState")
// 2. 从 RelayState 中提取父 Span 上下文
// 这里的坑在于:如果提取失败,我们不应该中断认证流程,
// 而是应该继续,只是会产生一条新的、不完整的 Trace。
// 这对于系统的韧性至关重要。
parentCtx, err := s.propagator.Extract(relayState)
if err != nil {
// 记录一个严重的警告,但不返回错误,以便监控和排查
log.Printf("WARNING: Failed to extract trace context from RelayState: %v. A new trace will be started.", err)
parentCtx = context.Background() // Fallback to a new trace
}
// 3. 启动新的 Span,并正确关联父 Span
tracer := otel.Tracer("saml-acs-tracer")
ctx, acsSpan := tracer.Start(parentCtx, "process-saml-assertion")
defer acsSpan.End()
// 4. 处理 SAML 断言 (伪代码)
// principal, err := s.samlServiceProvider.ParseResponse(samlResponse)
// if err != nil {
// // 在 Span 中记录错误
// acsSpan.RecordError(err)
// acsSpan.SetStatus(codes.Error, err.Error())
// http.Error(w, "Invalid SAML response", http.StatusUnauthorized)
// return
// }
// acsSpan.SetAttributes(attribute.String("saml.principal.name_id", principal.NameID))
// 5. 创建用户会话等后续操作...
// ...
log.Printf("Successfully processed SAML assertion for trace ID: %s", acsSpan.SpanContext().TraceID().String())
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Login successful!")
}
Extract
方法是 Inject
的逆过程,它执行了严格的验证。在 HandleACSRequest
中,我们特意对 Extract
的错误做了容错处理。在生产环境中,不能因为可观测性组件的故障(例如 RelayState
格式错误)而导致核心的登录功能不可用。单元测试的思路应该覆盖所有 Extract
可能失败的路径:签名错误、超时、格式错误等,确保在这些情况下,业务逻辑依然能走下去。
第三步: Dart/Flutter 客户端的配合
客户端的职责相对简单:发起登录,处理重定向,并在登录成功后进行一次 API 调用以验证链路是否完整。我们通常使用一个 WebView
来承载整个 SAML 流程。
// main.dart
import 'package:flutter/material.dart';
import 'package:opentelemetry/api.dart' as api;
import 'package:opentelemetry/sdk.dart' as sdk;
import 'package:opentelemetry/exporter.dart';
import 'package:http/http.dart' as http;
import 'package:opentelemetry_http/opentelemetry_http.dart';
// 简化的 OpenTelemetry SDK 初始化
// 在真实应用中,这部分会更复杂,包括资源属性、采样器等配置
void initializeOpenTelemetry() {
final provider = sdk.TracerProvider(
processors: [
sdk.SimpleSpanProcessor(
ConsoleSpanExporter(), // 实际项目中应替换为 OtlpHttpSpanExporter
),
],
);
api.globalTracerProvider = provider;
}
void main() {
initializeOpenTelemetry();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: LoginPage(),
);
}
}
class LoginPage extends StatefulWidget {
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
String _status = 'Ready to log in';
final client = OtelHttpClient(http.Client()); // 使用 OTel 包装的 HttpClient
Future<void> _loginAndFetchProfile() async {
final tracer = api.globalTracerProvider.getTracer('flutter-app-tracer');
final span = tracer.startSpan('user-login-flow');
// 将 span 设为当前上下文,以便 HttpClient 自动注入 traceparent
await api.context.withSpan(span, () async {
try {
setState(() { _status = '1. Initiating SAML login...'; });
// 1. 调用后端的 /api/login 端点,这将返回一个重定向
// OtelHttpClient 会自动添加 traceparent header
final loginResponse = await client.post(
Uri.parse('http://your-backend.com/api/login'),
);
// 在真实应用中,这里会启动一个 WebView 并导航到 IdP 的 URL
// loginResponse.headers['location']
// 此处为了简化,我们仅模拟这个过程
print('Would redirect to IdP now...');
await Future.delayed(const Duration(seconds: 2)); // 模拟用户认证耗时
setState(() { _status = '2. Simulating SAML callback & fetching profile...'; });
// 3. 假设 WebView 流程已完成,并且后端 ACS 已设置了会话 Cookie
// 我们现在调用一个受保护的 API 端点
// 这个请求的 traceparent 会被后端识别,并与 ACS 处理的 span 关联
final profileResponse = await client.get(
Uri.parse('http://your-backend.com/api/profile'),
);
if (profileResponse.statusCode == 200) {
setState(() { _status = '3. Login and profile fetch successful!\n${profileResponse.body}'; });
span.setStatus(api.StatusCode.ok);
} else {
throw Exception('Failed to fetch profile: ${profileResponse.statusCode}');
}
} catch (e, s) {
setState(() { _status = 'Login flow failed: $e'; });
span.recordException(e, stackTrace: s);
span.setStatus(api.StatusCode.error, description: e.toString());
} finally {
span.end();
}
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('SAML + OpenTelemetry Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_status),
SizedBox(height: 20),
ElevatedButton(
onPressed: _loginAndFetchProfile,
child: Text('Login with SAML'),
),
],
),
),
);
}
}
通过 OtelHttpClient
,Dart 客户端发出的所有 HTTP 请求都会自动携带 traceparent
头。这意味着从 Flutter App 发起的 /api/login
请求,到登录成功后发起的 /api/profile
请求,都会在 OpenTelemetry 后端被正确地关联起来。而中间最关键的 SAML 重定向黑盒,则通过我们自定义的 RelayStatePropagator
被点亮了。
最终,我们在 Jaeger 或 Grafana Tempo 中看到了一条完整的链路,它清晰地展示了:
- Dart 客户端发起登录请求的耗时。
- 后端 SP 生成 SAML 请求的耗时。
- 从 SP 重定向到 ACS 完成的端到端时间(包含了用户在 IdP 上的交互时间)。
- 后端 ACS 处理 SAML 断言并创建会话的耗时。
- 客户端登录成功后首次调用业务 API 的耗时。
这个方案的一个局限性在于,它强依赖于 IdP 对 RelayState
参数的正确处理。尽管这是 SAML 2.0 规范的标准行为,但在一些非标或老旧的 IdP 实现中可能会遇到问题。此外,RelayState
的长度也可能有限制,我们的 TraceContext
令牌必须保持足够紧凑。未来的一个迭代方向可能是探索与 IdP 厂商合作,推动其直接支持 W3C Trace Context 标准,但这在复杂的企业环境中往往是一个漫长且不可控的过程。就目前而言,利用 RelayState
是一种务实且有效的工程妥协。