Archived
修正协议处理并解耦可选遥测
This commit is contained in:
@@ -52,7 +52,8 @@ exchange、client store 适配和 Transit signer,不能减少本项目的核
|
|||||||
不引入完整 Authorization Server 框架。业务代码仍将 credential verification、授权和
|
不引入完整 Authorization Server 框架。业务代码仍将 credential verification、授权和
|
||||||
token materialization 保持为独立边界。详见 [`poc.md`](poc.md)。
|
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、
|
固定路由模板及受控低基数字段,不捕获请求/响应 body、Authorization header、
|
||||||
`subject_token`、输出 token、`client_id`、principal 或 `jti`。trace 与 metric provider
|
`subject_token`、输出 token、`client_id`、principal 或 `jti`。trace 与 metric provider
|
||||||
通过启动依赖注入;未配置 exporter 时保持 no-op,不把 telemetry 输出到标准输出。
|
通过启动依赖注入;未配置 exporter 时保持 no-op,不把 telemetry 输出到标准输出。
|
||||||
|
|||||||
@@ -483,7 +483,8 @@ metrics 只能使用 verifier、audience、profile 和结果等受控低基数
|
|||||||
使用 `go-jose/v4` 做 JOSE/JWK 互操作,并通过官方 `openbao/api/v2` 调用 Transit。
|
使用 `go-jose/v4` 做 JOSE/JWK 互操作,并通过官方 `openbao/api/v2` 调用 Transit。
|
||||||
- HTTP 路由层使用 `chi/v5`,保持 handler 和 middleware 与标准 `net/http` 兼容;OAuth
|
- HTTP 路由层使用 `chi/v5`,保持 handler 和 middleware 与标准 `net/http` 兼容;OAuth
|
||||||
表单字段仍由协议层显式解析,不使用自动 request binding。
|
表单字段仍由协议层显式解析,不使用自动 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 或具体后端。
|
exporter/reader 由进程启动配置注入,不在协议层固定 OTLP gRPC、OTLP HTTP 或具体后端。
|
||||||
第一阶段日志使用结构化日志并关联 trace/span ID,不要求启用 OpenTelemetry Logs SDK。
|
第一阶段日志使用结构化日志并关联 trace/span ID,不要求启用 OpenTelemetry Logs SDK。
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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 {
|
type Endpoints struct {
|
||||||
@@ -19,13 +15,7 @@ type Endpoints struct {
|
|||||||
Ready http.Handler
|
Ready http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type Telemetry struct {
|
func NewRouter(endpoints Endpoints) (http.Handler, error) {
|
||||||
TracerProvider trace.TracerProvider
|
|
||||||
MeterProvider metric.MeterProvider
|
|
||||||
Propagators propagation.TextMapPropagator
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(endpoints Endpoints, telemetry Telemetry) (http.Handler, error) {
|
|
||||||
if endpoints.Metadata == nil || endpoints.Token == nil || endpoints.JWKS == nil || endpoints.Health == nil || endpoints.Ready == nil {
|
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")
|
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("/healthz", endpoints.Health.ServeHTTP)
|
||||||
router.Get("/readyz", endpoints.Ready.ServeHTTP)
|
router.Get("/readyz", endpoints.Ready.ServeHTTP)
|
||||||
|
|
||||||
options := make([]otelhttp.Option, 0, 3)
|
return router, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
||||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRouterExposesOnlySpecifiedMethods(t *testing.T) {
|
func TestRouterExposesOnlySpecifiedMethods(t *testing.T) {
|
||||||
@@ -20,7 +16,7 @@ func TestRouterExposesOnlySpecifiedMethods(t *testing.T) {
|
|||||||
JWKS: endpoint,
|
JWKS: endpoint,
|
||||||
Health: endpoint,
|
Health: endpoint,
|
||||||
Ready: endpoint,
|
Ready: endpoint,
|
||||||
}, Telemetry{})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter() error = %v", err)
|
t.Fatalf("NewRouter() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,43 +49,7 @@ func TestRouterExposesOnlySpecifiedMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouterRequiresEveryEndpoint(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")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,9 +64,12 @@ func ParseExchangeRequest(form url.Values) (ExchangeRequest, error) {
|
|||||||
if len(request.Scopes) == 0 {
|
if len(request.Scopes) == 0 {
|
||||||
return ExchangeRequest{}, requestError("invalid_scope", "scope is required")
|
return ExchangeRequest{}, requestError("invalid_scope", "scope is required")
|
||||||
}
|
}
|
||||||
for _, unsupported := range []string{"resource", "actor_token", "actor_token_type"} {
|
if form.Has("resource") {
|
||||||
if form.Has(unsupported) {
|
return ExchangeRequest{}, requestError("invalid_target", "resource target is not supported")
|
||||||
return ExchangeRequest{}, requestError("invalid_request", unsupported+" 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func TestParseExchangeRequestRejectsInvalidInput(t *testing.T) {
|
|||||||
{name: "missing audience", mutate: func(v url.Values) { v.Del("audience") }, code: "invalid_target"},
|
{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: "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: "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"},
|
{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 {
|
func validForm() url.Values {
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"grant_type": {TokenExchangeGrantType},
|
"grant_type": {TokenExchangeGrantType},
|
||||||
|
|||||||
+37
-14
@@ -2,15 +2,14 @@ package signing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
|
||||||
|
|
||||||
var rawURLEncoding = base64.RawURLEncoding
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
)
|
||||||
|
|
||||||
type RS256Signer interface {
|
type RS256Signer interface {
|
||||||
ActiveKey(ctx context.Context) (SigningKey, error)
|
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")
|
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)
|
payload, err := json.Marshal(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("encode claims: %w", err)
|
return "", fmt.Errorf("encode claims: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
signingInput := rawURLEncoding.EncodeToString(header) + "." + rawURLEncoding.EncodeToString(payload)
|
opaque := &contextSigner{ctx: ctx, signer: i.signer, key: key}
|
||||||
signature, err := i.signer.SignRS256(ctx, key, []byte(signingInput))
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("sign JWT: %w", err)
|
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 {
|
func validateClaims(claims Claims, now time.Time) error {
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user