在 Dart 应用中为 SAML 2.0 认证流程实现 OpenTelemetry 全链路追踪


我们新上线的 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

整个方案的核心思路如下:

  1. 后端 SP (Service Provider): 在发起 SAML 认证请求前,启动一个新的 Trace Span。将当前的 TraceContext(包含 trace-idparent-span-id)打包成一个自包含了签名和时间戳的紧凑令牌(例如一个短时效的 JWT)。
  2. 上下文传递: 将这个令牌作为 RelayState 参数的值,附加到发送给 IdP 的 SAML AuthnRequest 中。IdP 在处理认证后,必须原封不动地将 RelayState 返回给我们的 ACS 端点。
  3. 后端 ACS (Assertion Consumer Service): 在 ACS 端点,我们从 IdP 发回的 SAMLResponse 中解析出 RelayState。验证令牌的签名和时效性,然后提取出 TraceContext
  4. 链路缝合: 使用提取出的 TraceContext 作为父上下文,启动一个新的 Span。这样,ACS 的处理逻辑就成功地与初始登录请求关联到了同一条 Trace 中。
  5. 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.")
}

这里的核心在于 RelayStatePropagatorInject 方法。它不仅仅是传递 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 中看到了一条完整的链路,它清晰地展示了:

  1. Dart 客户端发起登录请求的耗时。
  2. 后端 SP 生成 SAML 请求的耗时。
  3. 从 SP 重定向到 ACS 完成的端到端时间(包含了用户在 IdP 上的交互时间)。
  4. 后端 ACS 处理 SAML 断言并创建会话的耗时。
  5. 客户端登录成功后首次调用业务 API 的耗时。

这个方案的一个局限性在于,它强依赖于 IdP 对 RelayState 参数的正确处理。尽管这是 SAML 2.0 规范的标准行为,但在一些非标或老旧的 IdP 实现中可能会遇到问题。此外,RelayState 的长度也可能有限制,我们的 TraceContext 令牌必须保持足够紧凑。未来的一个迭代方向可能是探索与 IdP 厂商合作,推动其直接支持 W3C Trace Context 标准,但这在复杂的企业环境中往往是一个漫长且不可控的过程。就目前而言,利用 RelayState 是一种务实且有效的工程妥协。


  目录