From 0532fc25bf4eca0824fa4234340539c24d17fde6 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Fri, 11 Sep 2026 16:10:27 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8D=8F=E8=AE=AE=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=B9=B6=E8=A7=A3=E8=80=A6=E5=8F=AF=E9=80=89=E9=81=A5?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 3 +- docs/specification.md | 3 +- internal/httpapi/router.go | 24 +---------- internal/httpapi/router_test.go | 44 +------------------- internal/protocol/token_exchange.go | 9 +++-- internal/protocol/token_exchange_test.go | 9 +++++ internal/signing/jwt.go | 51 +++++++++++++++++------- internal/telemetry/http.go | 23 +++++++++++ internal/telemetry/http_test.go | 45 +++++++++++++++++++++ 9 files changed, 128 insertions(+), 83 deletions(-) create mode 100644 internal/telemetry/http.go create mode 100644 internal/telemetry/http_test.go diff --git a/docs/architecture.md b/docs/architecture.md index e64492b..aa262fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,8 @@ exchange、client store 适配和 Transit signer,不能减少本项目的核 不引入完整 Authorization Server 框架。业务代码仍将 credential verification、授权和 token materialization 保持为独立边界。详见 [`poc.md`](poc.md)。 -HTTP router 外层使用 `otelhttp` 生成 server span 和标准 HTTP metrics。span 与 metric 只使用 +启用 OpenTelemetry 时,启动层可以在纯 chi router 外使用 `otelhttp` 生成 server span 和 +标准 HTTP metrics;未启用时不安装该 wrapper。span 与 metric 只使用 固定路由模板及受控低基数字段,不捕获请求/响应 body、Authorization header、 `subject_token`、输出 token、`client_id`、principal 或 `jti`。trace 与 metric provider 通过启动依赖注入;未配置 exporter 时保持 no-op,不把 telemetry 输出到标准输出。 diff --git a/docs/specification.md b/docs/specification.md index 423beb3..f56d66a 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -483,7 +483,8 @@ metrics 只能使用 verifier、audience、profile 和结果等受控低基数 使用 `go-jose/v4` 做 JOSE/JWK 互操作,并通过官方 `openbao/api/v2` 调用 Transit。 - HTTP 路由层使用 `chi/v5`,保持 handler 和 middleware 与标准 `net/http` 兼容;OAuth 表单字段仍由协议层显式解析,不使用自动 request binding。 -- 可观测性使用 OpenTelemetry Go trace 与 metric SDK,HTTP server 使用 `otelhttp`;SDK +- 可选的可观测性使用 OpenTelemetry Go trace 与 metric SDK,启用时 HTTP server 使用 + `otelhttp`;未配置 telemetry 时纯 chi handler 不创建 OTel instrumentation。SDK exporter/reader 由进程启动配置注入,不在协议层固定 OTLP gRPC、OTLP HTTP 或具体后端。 第一阶段日志使用结构化日志并关联 trace/span ID,不要求启用 OpenTelemetry Logs SDK。 diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index dadcbfd..21a7c35 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -5,10 +5,6 @@ import ( "net/http" "github.com/go-chi/chi/v5" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" ) type Endpoints struct { @@ -19,13 +15,7 @@ type Endpoints struct { Ready http.Handler } -type Telemetry struct { - TracerProvider trace.TracerProvider - MeterProvider metric.MeterProvider - Propagators propagation.TextMapPropagator -} - -func NewRouter(endpoints Endpoints, telemetry Telemetry) (http.Handler, error) { +func NewRouter(endpoints Endpoints) (http.Handler, error) { if endpoints.Metadata == nil || endpoints.Token == nil || endpoints.JWKS == nil || endpoints.Health == nil || endpoints.Ready == nil { return nil, errors.New("all HTTP endpoints are required") } @@ -37,15 +27,5 @@ func NewRouter(endpoints Endpoints, telemetry Telemetry) (http.Handler, error) { router.Get("/healthz", endpoints.Health.ServeHTTP) router.Get("/readyz", endpoints.Ready.ServeHTTP) - options := make([]otelhttp.Option, 0, 3) - if telemetry.TracerProvider != nil { - options = append(options, otelhttp.WithTracerProvider(telemetry.TracerProvider)) - } - if telemetry.MeterProvider != nil { - options = append(options, otelhttp.WithMeterProvider(telemetry.MeterProvider)) - } - if telemetry.Propagators != nil { - options = append(options, otelhttp.WithPropagators(telemetry.Propagators)) - } - return otelhttp.NewHandler(router, "workload-sts.http", options...), nil + return router, nil } diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go index dd7be24..735fa4e 100644 --- a/internal/httpapi/router_test.go +++ b/internal/httpapi/router_test.go @@ -3,11 +3,7 @@ package httpapi import ( "net/http" "net/http/httptest" - "strings" "testing" - - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/sdk/trace/tracetest" ) func TestRouterExposesOnlySpecifiedMethods(t *testing.T) { @@ -20,7 +16,7 @@ func TestRouterExposesOnlySpecifiedMethods(t *testing.T) { JWKS: endpoint, Health: endpoint, Ready: endpoint, - }, Telemetry{}) + }) if err != nil { t.Fatalf("NewRouter() error = %v", err) } @@ -53,43 +49,7 @@ func TestRouterExposesOnlySpecifiedMethods(t *testing.T) { } func TestRouterRequiresEveryEndpoint(t *testing.T) { - if _, err := NewRouter(Endpoints{}, Telemetry{}); err == nil { + if _, err := NewRouter(Endpoints{}); err == nil { t.Fatal("NewRouter() error = nil, want missing endpoint error") } } - -func TestRouterTelemetryDoesNotCaptureCredentialValues(t *testing.T) { - const canary = "canary-secret-token" - recorder := tracetest.NewSpanRecorder() - tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) - t.Cleanup(func() { _ = tracerProvider.Shutdown(t.Context()) }) - - endpoint := http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - response.WriteHeader(http.StatusNoContent) - }) - router, err := NewRouter(Endpoints{ - Metadata: endpoint, - Token: endpoint, - JWKS: endpoint, - Health: endpoint, - Ready: endpoint, - }, Telemetry{TracerProvider: tracerProvider}) - if err != nil { - t.Fatalf("NewRouter() error = %v", err) - } - - request := httptest.NewRequest(http.MethodGet, "/oauth2/jwks?subject_token="+canary, nil) - request.Header.Set("Authorization", "Bearer "+canary) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - - spans := recorder.Ended() - if len(spans) != 1 { - t.Fatalf("ended spans = %d, want 1", len(spans)) - } - for _, attribute := range spans[0].Attributes() { - if strings.Contains(attribute.Value.Emit(), canary) { - t.Fatalf("span attribute %q captured credential canary", attribute.Key) - } - } -} diff --git a/internal/protocol/token_exchange.go b/internal/protocol/token_exchange.go index 0aefc5e..f0d5751 100644 --- a/internal/protocol/token_exchange.go +++ b/internal/protocol/token_exchange.go @@ -64,9 +64,12 @@ func ParseExchangeRequest(form url.Values) (ExchangeRequest, error) { if len(request.Scopes) == 0 { return ExchangeRequest{}, requestError("invalid_scope", "scope is required") } - for _, unsupported := range []string{"resource", "actor_token", "actor_token_type"} { - if form.Has(unsupported) { - return ExchangeRequest{}, requestError("invalid_request", unsupported+" is not supported") + if form.Has("resource") { + return ExchangeRequest{}, requestError("invalid_target", "resource target is not supported") + } + for _, actorParameter := range []string{"actor_token", "actor_token_type"} { + if form.Has(actorParameter) { + return ExchangeRequest{}, requestError("invalid_request", "actor token is not supported") } } diff --git a/internal/protocol/token_exchange_test.go b/internal/protocol/token_exchange_test.go index 7b2e9b0..7a3564e 100644 --- a/internal/protocol/token_exchange_test.go +++ b/internal/protocol/token_exchange_test.go @@ -36,6 +36,7 @@ func TestParseExchangeRequestRejectsInvalidInput(t *testing.T) { {name: "missing audience", mutate: func(v url.Values) { v.Del("audience") }, code: "invalid_target"}, {name: "multiple audiences", mutate: func(v url.Values) { v.Add("audience", "second") }, code: "invalid_target"}, {name: "missing scope", mutate: func(v url.Values) { v.Del("scope") }, code: "invalid_scope"}, + {name: "resource target", mutate: func(v url.Values) { v.Set("resource", "https://example.test") }, code: "invalid_target"}, {name: "actor token", mutate: func(v url.Values) { v.Set("actor_token", "not-supported") }, code: "invalid_request"}, } @@ -56,6 +57,14 @@ func TestParseExchangeRequestRejectsInvalidInput(t *testing.T) { } } +func TestParseExchangeRequestIgnoresUnknownParameters(t *testing.T) { + form := validForm() + form.Set("future_extension", "ignored") + if _, err := ParseExchangeRequest(form); err != nil { + t.Fatalf("ParseExchangeRequest() error = %v", err) + } +} + func validForm() url.Values { return url.Values{ "grant_type": {TokenExchangeGrantType}, diff --git a/internal/signing/jwt.go b/internal/signing/jwt.go index 7cf5e74..31211bc 100644 --- a/internal/signing/jwt.go +++ b/internal/signing/jwt.go @@ -2,15 +2,14 @@ package signing import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" "strings" "time" -) -var rawURLEncoding = base64.RawURLEncoding + "github.com/go-jose/go-jose/v4" +) type RS256Signer interface { ActiveKey(ctx context.Context) (SigningKey, error) @@ -59,25 +58,49 @@ func (i *Issuer) Sign(ctx context.Context, claims Claims) (string, error) { return "", errors.New("signer returned an invalid signing key") } - header, err := json.Marshal(map[string]string{ - "alg": "RS256", - "kid": key.ID, - "typ": "at+jwt", - }) - if err != nil { - return "", fmt.Errorf("encode protected header: %w", err) - } payload, err := json.Marshal(claims) if err != nil { return "", fmt.Errorf("encode claims: %w", err) } - signingInput := rawURLEncoding.EncodeToString(header) + "." + rawURLEncoding.EncodeToString(payload) - signature, err := i.signer.SignRS256(ctx, key, []byte(signingInput)) + opaque := &contextSigner{ctx: ctx, signer: i.signer, key: key} + options := (&jose.SignerOptions{}). + WithType(jose.ContentType("at+jwt")). + WithHeader(jose.HeaderKey("kid"), key.ID) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: opaque}, options) + if err != nil { + return "", fmt.Errorf("create JWT signer: %w", err) + } + jws, err := signer.Sign(payload) if err != nil { return "", fmt.Errorf("sign JWT: %w", err) } - return signingInput + "." + rawURLEncoding.EncodeToString(signature), nil + compact, err := jws.CompactSerialize() + if err != nil { + return "", fmt.Errorf("serialize JWT: %w", err) + } + return compact, nil +} + +type contextSigner struct { + ctx context.Context + signer RS256Signer + key SigningKey +} + +func (*contextSigner) Public() *jose.JSONWebKey { + return nil +} + +func (*contextSigner) Algs() []jose.SignatureAlgorithm { + return []jose.SignatureAlgorithm{jose.RS256} +} + +func (s *contextSigner) SignPayload(payload []byte, algorithm jose.SignatureAlgorithm) ([]byte, error) { + if algorithm != jose.RS256 { + return nil, errors.New("unsupported signing algorithm") + } + return s.signer.SignRS256(s.ctx, s.key, payload) } func validateClaims(claims Claims, now time.Time) error { diff --git a/internal/telemetry/http.go b/internal/telemetry/http.go new file mode 100644 index 0000000..3443a0c --- /dev/null +++ b/internal/telemetry/http.go @@ -0,0 +1,23 @@ +package telemetry + +import ( + "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/propagation" +) + +func WrapHTTP(handler http.Handler, providers *Providers, propagators propagation.TextMapPropagator) http.Handler { + if providers == nil { + return handler + } + + options := []otelhttp.Option{ + otelhttp.WithTracerProvider(providers.Tracer), + otelhttp.WithMeterProvider(providers.Meter), + } + if propagators != nil { + options = append(options, otelhttp.WithPropagators(propagators)) + } + return otelhttp.NewHandler(handler, "workload-sts.http", options...) +} diff --git a/internal/telemetry/http_test.go b/internal/telemetry/http_test.go new file mode 100644 index 0000000..313b0af --- /dev/null +++ b/internal/telemetry/http_test.go @@ -0,0 +1,45 @@ +package telemetry + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + metricnoop "go.opentelemetry.io/otel/metric/noop" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestWrapHTTPWithoutProvidersReturnsOriginalHandler(t *testing.T) { + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + if got := WrapHTTP(handler, nil, nil); got == nil { + t.Fatal("WrapHTTP() returned nil") + } +} + +func TestWrapHTTPDoesNotCaptureCredentialValues(t *testing.T) { + const canary = "canary-secret-token" + recorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { _ = tracerProvider.Shutdown(t.Context()) }) + + handler := WrapHTTP(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + }), &Providers{Tracer: tracerProvider, Meter: metricnoop.NewMeterProvider()}, nil) + + request := httptest.NewRequest(http.MethodGet, "/oauth2/jwks?subject_token="+canary, nil) + request.Header.Set("Authorization", "Bearer "+canary) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + spans := recorder.Ended() + if len(spans) != 1 { + t.Fatalf("ended spans = %d, want 1", len(spans)) + } + for _, attribute := range spans[0].Attributes() { + if strings.Contains(attribute.Value.Emit(), canary) { + t.Fatalf("span attribute %q captured credential canary", attribute.Key) + } + } +}