diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 4dade447ac..8ab14082ab 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1190,6 +1190,7 @@ spec: MaxDelegationDepth caps how many nested RFC 8693 "act" entries are recorded in an audit event's delegationChain. Deeper chains are truncated (marked with truncated=true). Defaults to 10 when unset. + minimum: 1 type: integer type: object backends: @@ -4644,6 +4645,7 @@ spec: MaxDelegationDepth caps how many nested RFC 8693 "act" entries are recorded in an audit event's delegationChain. Deeper chains are truncated (marked with truncated=true). Defaults to 10 when unset. + minimum: 1 type: integer type: object backends: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index e708851674..5cdbfe4061 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1193,6 +1193,7 @@ spec: MaxDelegationDepth caps how many nested RFC 8693 "act" entries are recorded in an audit event's delegationChain. Deeper chains are truncated (marked with truncated=true). Defaults to 10 when unset. + minimum: 1 type: integer type: object backends: @@ -4647,6 +4648,7 @@ spec: MaxDelegationDepth caps how many nested RFC 8693 "act" entries are recorded in an audit event's delegationChain. Deeper chains are truncated (marked with truncated=true). Defaults to 10 when unset. + minimum: 1 type: integer type: object backends: diff --git a/docs/arch/02-core-concepts.md b/docs/arch/02-core-concepts.md index 8ec9677ddc..6d3f1d564c 100644 --- a/docs/arch/02-core-concepts.md +++ b/docs/arch/02-core-concepts.md @@ -659,7 +659,7 @@ See `pkg/audit/mcp_events.go` for complete list of event types. **Event data:** - Timestamp, source, outcome - Subjects (user, session) -- Delegation chain (`delegationChain`) — RFC 8693 `act` claim actors, outermost +- Delegation chain (`delegation_chain`) — RFC 8693 `act` claim actors, outermost first, when the caller used a delegated token - Target (endpoint, method, resource) - Request/response data (configurable) diff --git a/docs/middleware.md b/docs/middleware.md index 573bc7808b..523008ec48 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -45,6 +45,8 @@ Multiple webhook definitions of the same type run in configuration order. When m Configuration files may be written in YAML or JSON. Duration values such as `timeout` accept strings like `5s`, and omitted timeouts default to `10s`. +When the caller authenticated with an RFC 8693 delegated token, the request payload sent to webhook receivers includes a `delegation_chain` field on the principal object, with the same shape as the `delegation_chain` field documented under [Audit Middleware](#9-audit-middleware) below. + Example: ```bash @@ -527,14 +529,26 @@ Audit events are logged as structured JSON objects: - `delegation_chain`: RFC 8693 delegation chain, present only when the caller authenticated with a delegated token (i.e. the JWT carries an `act` claim) - `actors`: Acting parties, outermost (most recent) first — `actors[0]` is - the direct delegate that presented the token + the direct delegate that presented the token. Empty array (not omitted) + when `malformed` is `true` and no actor could be parsed at all - `sub`: Acting party identifier (from the `act` claim's `sub` member) - - `act_claims`: Additional `act` claim members (e.g. `iss`), when present + - `act_claims`: Additional `act` claim members (e.g. `iss`), when present. + May also carry `sub` when the token's `act.sub` was present but not a + string (RFC 7519 §4.1.2 requires a string); such a value is + deliberately not reported as the actor's `sub` and must not be treated + as an actor identifier - `truncated`: `true` when the chain exceeded the configured maximum depth (`maxDelegationDepth` in the audit config, default 10) and trailing actors were dropped - `dropped_count`: Number of actors dropped due to the depth cap, present only when `truncated` is `true` + - `malformed`: `true` when the token's `act` claim (at the top level or at + some nesting depth) was present and non-null but not a JSON object, per + RFC 8693 §4.1; present only when `true`. Actors already parsed from + shallower levels of the chain (if any) are still reported alongside it. + When the malformed value sits immediately past the depth cap, + `dropped_count` is deliberately 0 and `truncated` is `false` — unreadable + data past the cap is not a countable actor - `target`: Information about the operation target - `endpoint`: HTTP endpoint path - `method`: HTTP method diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 7103df642d..49e7f5f52e 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -42,7 +42,7 @@ _Appears in:_ | `includeResponseData` _boolean_ | IncludeResponseData determines whether to include response data in audit logs. | false | Optional: \{\}
| | `detectApplicationErrors` _boolean_ | DetectApplicationErrors controls whether the audit middleware inspects
JSON-RPC response bodies for application-level errors when the HTTP
status code indicates success (2xx). When enabled, a small prefix of
the response body is buffered to detect JSON-RPC error fields,
independent of the IncludeResponseData setting. | true | Optional: \{\}
| | `maxDataSize` _integer_ | MaxDataSize limits the size of request/response data included in audit logs (in bytes). | 1024 | Optional: \{\}
| -| `maxDelegationDepth` _integer_ | MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset. | 10 | Optional: \{\}
| +| `maxDelegationDepth` _integer_ | MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset. | 10 | Minimum: 1
Optional: \{\}
| | `logFile` _string_ | LogFile specifies the file path for audit logs. If empty, logs to stdout. | | Optional: \{\}
| diff --git a/docs/server/docs.go b/docs/server/docs.go index f1a127a2b1..7158b77090 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -220,7 +220,7 @@ const docTemplate = `{ "type": "integer" }, "maxDelegationDepth": { - "description": "MaxDelegationDepth caps how many nested RFC 8693 \"act\" entries are\nrecorded in an audit event's delegationChain. Deeper chains are\ntruncated (marked with truncated=true). Defaults to 10 when unset.\n+kubebuilder:default=10\n+optional", + "description": "MaxDelegationDepth caps how many nested RFC 8693 \"act\" entries are\nrecorded in an audit event's delegationChain. Deeper chains are\ntruncated (marked with truncated=true). Defaults to 10 when unset.\n+kubebuilder:validation:Minimum=1\n+kubebuilder:default=10\n+optional", "type": "integer" } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index b16b3af016..acfe5ba218 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -213,7 +213,7 @@ "type": "integer" }, "maxDelegationDepth": { - "description": "MaxDelegationDepth caps how many nested RFC 8693 \"act\" entries are\nrecorded in an audit event's delegationChain. Deeper chains are\ntruncated (marked with truncated=true). Defaults to 10 when unset.\n+kubebuilder:default=10\n+optional", + "description": "MaxDelegationDepth caps how many nested RFC 8693 \"act\" entries are\nrecorded in an audit event's delegationChain. Deeper chains are\ntruncated (marked with truncated=true). Defaults to 10 when unset.\n+kubebuilder:validation:Minimum=1\n+kubebuilder:default=10\n+optional", "type": "integer" } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 35b73f19c2..42103ee47d 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -227,6 +227,7 @@ components: MaxDelegationDepth caps how many nested RFC 8693 "act" entries are recorded in an audit event's delegationChain. Deeper chains are truncated (marked with truncated=true). Defaults to 10 when unset. + +kubebuilder:validation:Minimum=1 +kubebuilder:default=10 +optional type: integer diff --git a/pkg/audit/auditor.go b/pkg/audit/auditor.go index f321edc988..330fb42e60 100644 --- a/pkg/audit/auditor.go +++ b/pkg/audit/auditor.go @@ -113,18 +113,6 @@ func (a *Auditor) Close() error { return nil } -// SetLogWriterForTest redirects the auditor's log output to w. It is intended -// for tests in other packages (e.g. integration tests) that need to capture -// emitted audit events without reimplementing the auditor. -// -// Deprecated: this is test-only surface on a production type. A future -// NewAuditorWithWriter constructor accepting an explicit io.Writer should -// replace it; keep usage confined to tests. -func (a *Auditor) SetLogWriterForTest(w io.Writer) { - a.auditLogger = NewAuditLogger(w) - a.logWriter = w -} - // isSSETransport checks if the current transport is SSE func (a *Auditor) isSSETransport() bool { return a.transportType == types.TransportTypeSSE.String() @@ -558,24 +546,32 @@ func extractSubjectsFromIdentity(identity *auth.Identity) map[string]string { } // extractDelegationChainFromIdentity returns the RFC 8693 delegation chain -// carried by the identity, re-bounded to maxDepth, or nil when the identity -// has no chain. An already-parsed chain is re-bounded by applying maxDepth to -// its actor list directly; an identity carrying only the raw "act" claim is -// parsed once via auth.ParseDelegationChain. In both cases the configured cap -// is enforced — there is no uncapped "honor as-is" fallback. +// carried by the identity, bound to maxDepth, or nil when there is none. +// The identity layer (auth.claimsToIdentity) always parses at +// auth.DefaultMaxDelegationDepth, so a larger maxDepth requires re-parsing the +// raw "act" claim — rebinding an already-parsed chain can only shrink it. The +// re-parse is only trusted when it yields at least as many actors as are +// already parsed, so it can widen the chain but never narrow it. func extractDelegationChainFromIdentity(identity *auth.Identity, maxDepth int) *auth.DelegationChain { if identity == nil { return nil } - if chain := identity.DelegationChain; chain != nil && len(chain.Actors) > 0 { - return rebindDelegationChain(chain, maxDepth) - } - if act, ok := identity.Claims["act"]; ok && act != nil { - chain := auth.ParseDelegationChain(act, maxDepth) - if len(chain.Actors) > 0 { - return &chain + rawAct := identity.Claims["act"] + chain := identity.DelegationChain + existingActors := 0 + if chain != nil { + existingActors = len(chain.Actors) + } + if rawAct != nil && (chain == nil || len(chain.Actors) == 0 || + (chain.Truncated && maxDepth > len(chain.Actors))) { + if parsed := auth.ParseDelegationChain(rawAct, maxDepth); (len(parsed.Actors) > 0 || parsed.Malformed) && + len(parsed.Actors) >= existingActors { + return &parsed } } + if chain != nil && (len(chain.Actors) > 0 || chain.Malformed) { + return rebindDelegationChain(chain, maxDepth) + } return nil } diff --git a/pkg/audit/auditor_test.go b/pkg/audit/auditor_test.go index 5d2e8554b2..d1a883a7b4 100644 --- a/pkg/audit/auditor_test.go +++ b/pkg/audit/auditor_test.go @@ -628,6 +628,42 @@ func TestExtractSubjects(t *testing.T) { assert.Equal(t, 1, chain.DroppedCount, "one dropped actor must be reported") }) + t.Run("delegation chain widens through configured max depth", func(t *testing.T) { + t.Parallel() + maxDepth := 25 + depthAuditor, err := NewAuditorWithTransport(&Config{MaxDelegationDepth: &maxDepth}, "sse") + require.NoError(t, err) + + subs := []string{ + "agent-1", "agent-2", "agent-3", "agent-4", "agent-5", + "agent-6", "agent-7", "agent-8", "agent-9", "agent-10", + "agent-11", "agent-12", "agent-13", "agent-14", + } + claims := jwt.MapClaims{ + "sub": "user123", + "act": nestedActClaim(subs...), + } + + req := httptest.NewRequest("GET", "/test", nil) + // Shaped like what auth.claimsToIdentity produces for a 14-hop token: + // the identity-layer parse caps at auth.DefaultMaxDelegationDepth (10), + // truncating the chain even though the raw "act" claim has all 14 hops. + parsed := auth.ParseDelegationChain(claims["act"], auth.DefaultMaxDelegationDepth) + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user123", + Claims: claims, + DelegationChain: &parsed, + }, + } + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + chain := depthAuditor.extractDelegationChain(req) + require.NotNil(t, chain) + require.Len(t, chain.Actors, len(subs), "configured depth must recover actors dropped by the identity-layer parse") + assert.False(t, chain.Truncated) + }) + t.Run("no identity yields nil delegation chain", func(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/test", nil) @@ -702,6 +738,122 @@ func TestExtractDelegationChainFromIdentity(t *testing.T) { } assert.Nil(t, extractDelegationChainFromIdentity(identity, auth.DefaultMaxDelegationDepth)) }) + + t.Run("malformed act with zero actors is not discarded (re-parse guard)", func(t *testing.T) { + t.Parallel() + // No pre-parsed DelegationChain, so the ONLY path that can produce a + // result is the raw-act re-parse guard + // (auth.ParseDelegationChain(rawAct, maxDepth)); the trailing + // "chain != nil" branch is unreachable here (chain is nil), so this + // isolates the re-parse guard's own Malformed handling. + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user123", + Claims: map[string]any{"act": "not-an-object"}, + }, + } + chain := extractDelegationChainFromIdentity(identity, auth.DefaultMaxDelegationDepth) + require.NotNil(t, chain, "a malformed-but-actorless chain must still surface, not be swallowed by the len(Actors) > 0 guards") + assert.True(t, chain.Malformed) + assert.Empty(t, chain.Actors) + }) + + t.Run("malformed act with zero actors is not discarded (trailing raw-claim branch)", func(t *testing.T) { + t.Parallel() + // No raw "act" claim is present (e.g. Claims not carrying the raw + // value, or already stripped), so the re-parse guard's rawAct != nil + // check is skipped entirely: this exercises the OTHER guard, the + // trailing "chain != nil && ..." branch that rebinds an + // already-parsed chain. + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user123", + DelegationChain: &auth.DelegationChain{ + Malformed: true, + }, + }, + } + chain := extractDelegationChainFromIdentity(identity, auth.DefaultMaxDelegationDepth) + require.NotNil(t, chain, "a malformed-but-actorless chain must still surface via the trailing rebind branch") + assert.True(t, chain.Malformed) + assert.Empty(t, chain.Actors) + }) + + // The following two cases share an identity shaped exactly like what + // auth.claimsToIdentity produces for a 14-hop token: the identity-layer + // parse always caps at auth.DefaultMaxDelegationDepth (10), so + // DelegationChain holds only the first 10 actors, truncated, with the + // full 14-hop chain still available in the raw "act" claim. + subs := []string{ + "agent-1", "agent-2", "agent-3", "agent-4", "agent-5", + "agent-6", "agent-7", "agent-8", "agent-9", "agent-10", + "agent-11", "agent-12", "agent-13", "agent-14", + } + truncatedIdentity := func() *auth.Identity { + actors := make([]auth.DelegatedActor, auth.DefaultMaxDelegationDepth) + for i := range actors { + actors[i] = auth.DelegatedActor{Subject: subs[i]} + } + return &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user123", + DelegationChain: &auth.DelegationChain{ + Actors: actors, + Truncated: true, + DroppedCount: len(subs) - auth.DefaultMaxDelegationDepth, + }, + Claims: map[string]any{"act": nestedActClaim(subs...)}, + }, + } + } + + t.Run("configured depth wider than identity-layer parse recovers dropped actors", func(t *testing.T) { + t.Parallel() + chain := extractDelegationChainFromIdentity(truncatedIdentity(), 25) + require.NotNil(t, chain) + require.Len(t, chain.Actors, len(subs)) + assert.False(t, chain.Truncated) + assert.Zero(t, chain.DroppedCount) + assert.Equal(t, subs[0], chain.Actors[0].Subject) + assert.Equal(t, subs[len(subs)-1], chain.Actors[len(subs)-1].Subject) + }) + + t.Run("configured depth narrower than identity-layer parse still rebinds", func(t *testing.T) { + t.Parallel() + chain := extractDelegationChainFromIdentity(truncatedIdentity(), 3) + require.NotNil(t, chain) + require.Len(t, chain.Actors, 3) + assert.True(t, chain.Truncated) + // 4 actors already dropped by the identity-layer parse (14 - 10), + // plus 7 more dropped by the narrower rebind (10 - 3). + assert.Equal(t, 11, chain.DroppedCount) + }) + + t.Run("truncated chain without raw act claim falls back to rebind", func(t *testing.T) { + t.Parallel() + identity := truncatedIdentity() + identity.Claims = nil // widening requested, but no raw claim to re-parse + + chain := extractDelegationChainFromIdentity(identity, 25) + require.NotNil(t, chain) + require.Len(t, chain.Actors, auth.DefaultMaxDelegationDepth) + assert.True(t, chain.Truncated, "without the raw claim, the identity-layer truncation cannot be recovered") + assert.Equal(t, 4, chain.DroppedCount) + }) +} + +// nestedActClaim builds a raw RFC 8693 "act" claim nesting subs in order, +// outermost first, matching the shape auth.ParseDelegationChain expects. +func nestedActClaim(subs ...string) map[string]any { + var current map[string]any + for i := len(subs) - 1; i >= 0; i-- { + m := map[string]any{"sub": subs[i]} + if current != nil { + m["act"] = current + } + current = m + } + return current } func TestDetermineComponent(t *testing.T) { @@ -1191,7 +1343,14 @@ func TestAuditLoggerLevelFormat(t *testing.T) { // buffer, for tests asserting on emitted events. func newBufferAuditor(t *testing.T) (*Auditor, *bytes.Buffer) { t.Helper() - auditor, err := NewAuditorWithTransport(&Config{Component: "test"}, "streamable-http") + return newBufferAuditorWithConfig(t, &Config{Component: "test"}) +} + +// newBufferAuditorWithConfig is newBufferAuditor for tests that need a +// non-default Config (e.g. MaxDelegationDepth). +func newBufferAuditorWithConfig(t *testing.T, cfg *Config) (*Auditor, *bytes.Buffer) { + t.Helper() + auditor, err := NewAuditorWithTransport(cfg, "streamable-http") require.NoError(t, err) var logBuf bytes.Buffer auditor.auditLogger = NewAuditLogger(&logBuf) @@ -1301,6 +1460,150 @@ func TestMiddlewareAuditsInnerChainOutcomes(t *testing.T) { }) } +// TestLogAuditEventDelegationChain pins the emitted delegation_chain shape on +// the primary logAuditEvent path (every ordinary POST). The SSE path already +// has an equivalent pin (see "delegated token stream open carries the +// delegation chain" in TestStreamOpenAuditEvents) and workflow auditing has +// its own (workflow_auditor_test.go), but logAuditEvent had none, so a +// pkg/audit regression here was only ever caught by running pkg/authserver's +// suite. What this test uniquely pins beyond those two siblings is that the +// POST path honours Config.MaxDelegationDepth via +// Config.MaxDelegationDepthOrDefault at this specific call site. +func TestLogAuditEventDelegationChain(t *testing.T) { + t.Parallel() + + // twoHopIdentity builds an auth.Identity carrying a hand-built two-actor + // RFC 8693 chain, outermost (most recent) first: agent-2 delegated from + // agent-1, with agent-1's act claim also carrying an extra "iss" member to + // pin the documented act_claims shape. + twoHopIdentity := func() *auth.Identity { + act := map[string]any{ + "sub": "agent-2", + "act": map[string]any{"sub": "agent-1", "iss": "https://issuer.example"}, + } + chain := auth.ParseDelegationChain(act, auth.DefaultMaxDelegationDepth) + return &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user-123", + Claims: map[string]any{"act": act}, + DelegationChain: &chain, + }} + } + + t.Run("full documented shape: two actors, one carrying act_claims", func(t *testing.T) { + t.Parallel() + auditor, logBuf := newBufferAuditor(t) + identity := twoHopIdentity() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = auth.WithIdentity(r.Context(), identity) + w.WriteHeader(http.StatusOK) + }) + auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), newToolsCallRequest()) + + events := decodeAuditEvents(t, logBuf) + require.Len(t, events, 1) + + chain, ok := events[0]["delegation_chain"].(map[string]any) + require.True(t, ok, "the POST/logAuditEvent path must carry the delegation chain") + assert.Equal(t, false, chain["truncated"]) + _, hasDropped := chain["dropped_count"] + assert.False(t, hasDropped, "dropped_count must be omitted when nothing was dropped") + + actors, ok := chain["actors"].([]any) + require.True(t, ok, "actors should be an array") + require.Len(t, actors, 2) + + outer, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-2", outer["sub"], "actors[0] must be the outermost/most recent actor") + _, hasClaims := outer["act_claims"] + assert.False(t, hasClaims, "the outer actor here carries no extra act members") + + inner, ok := actors[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-1", inner["sub"]) + actClaims, ok := inner["act_claims"].(map[string]any) + require.True(t, ok, "the documented act_claims member must surface extra act-claim data") + assert.Equal(t, "https://issuer.example", actClaims["iss"]) + }) + + t.Run("MaxDelegationDepth truncates the chain and keeps the outermost actor", func(t *testing.T) { + t.Parallel() + maxDepth := 1 + auditor, logBuf := newBufferAuditorWithConfig(t, &Config{Component: "test", MaxDelegationDepth: &maxDepth}) + identity := twoHopIdentity() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = auth.WithIdentity(r.Context(), identity) + w.WriteHeader(http.StatusOK) + }) + auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), newToolsCallRequest()) + + events := decodeAuditEvents(t, logBuf) + require.Len(t, events, 1) + + chain, ok := events[0]["delegation_chain"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, chain["truncated"]) + assert.Equal(t, float64(1), chain["dropped_count"], + "one of the two actors must be reported dropped") + + actors, ok := chain["actors"].([]any) + require.True(t, ok) + require.Len(t, actors, 1, "MaxDelegationDepth=1 must leave exactly one surviving actor") + surviving, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-2", surviving["sub"], "the surviving actor must be the outermost one") + }) + + t.Run("plain identity omits the delegation chain", func(t *testing.T) { + t.Parallel() + auditor, logBuf := newBufferAuditor(t) + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = auth.WithIdentity(r.Context(), identity) + w.WriteHeader(http.StatusOK) + }) + auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), newToolsCallRequest()) + + events := decodeAuditEvents(t, logBuf) + require.Len(t, events, 1) + + _, exists := events[0]["delegation_chain"] + assert.False(t, exists, + "a non-delegated identity must not produce a delegation_chain member on the POST path") + }) + + t.Run("malformed act claim is pinned as malformed:true with zero actors", func(t *testing.T) { + t.Parallel() + auditor, logBuf := newBufferAuditor(t) + act := "not-an-object" + chain := auth.ParseDelegationChain(act, auth.DefaultMaxDelegationDepth) + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user-123", + Claims: map[string]any{"act": act}, + DelegationChain: &chain, + }} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = auth.WithIdentity(r.Context(), identity) + w.WriteHeader(http.StatusOK) + }) + auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), newToolsCallRequest()) + + events := decodeAuditEvents(t, logBuf) + require.Len(t, events, 1) + + logged, ok := events[0]["delegation_chain"].(map[string]any) + require.True(t, ok, "a malformed act claim must still produce a delegation_chain member") + assert.Equal(t, true, logged["malformed"]) + actors, ok := logged["actors"].([]any) + require.True(t, ok, "actors must marshal as a JSON array, not null") + assert.Len(t, actors, 0) + }) +} + // TestStreamOpenAuditEvents pins the deferred stream-open logging: the // connection event for SSE / streamable GET requests is logged on the FIRST // response write, so it reflects the real outcome and the identity attached by @@ -1426,7 +1729,9 @@ func TestStreamOpenAuditEvents(t *testing.T) { } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Emulate inner auth attaching a delegated identity before the stream starts. + // Emulate inner auth attaching the identity before the stream starts. + // The returned context is intentionally discarded: only WithIdentity's + // side effect of filling the audit-injected IdentityHolder matters here. _ = auth.WithIdentity(r.Context(), delegated) w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) @@ -1451,4 +1756,34 @@ func TestStreamOpenAuditEvents(t *testing.T) { require.True(t, ok) assert.Equal(t, "agent-2", second["sub"]) }) + + t.Run("non-delegated identity stream open omits the delegation chain", func(t *testing.T) { + t.Parallel() + auditor, logBuf := newBufferAuditor(t) + + plain := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Emulate inner auth attaching the identity before the stream starts. + // The returned context is intentionally discarded: only WithIdentity's + // side effect of filling the audit-injected IdentityHolder matters here. + _ = auth.WithIdentity(r.Context(), plain) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("event: message\ndata: {}\n\n")) + }) + auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), newStreamRequest()) + + events := decodeAuditEvents(t, logBuf) + require.Len(t, events, 1) + assert.Equal(t, EventTypeSSEConnection, events[0]["type"]) + + subjects, ok := events[0]["subjects"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user-123", subjects[SubjectKeyUserID], "the identity must have landed") + + _, exists := events[0]["delegation_chain"] + assert.False(t, exists, + "an authenticated non-delegated identity must not produce a delegation_chain member on the stream-open path") + }) } diff --git a/pkg/audit/config.go b/pkg/audit/config.go index 7520e406f1..deea901066 100644 --- a/pkg/audit/config.go +++ b/pkg/audit/config.go @@ -57,6 +57,7 @@ type Config struct { // MaxDelegationDepth caps how many nested RFC 8693 "act" entries are // recorded in an audit event's delegationChain. Deeper chains are // truncated (marked with truncated=true). Defaults to 10 when unset. + // +kubebuilder:validation:Minimum=1 // +kubebuilder:default=10 // +optional MaxDelegationDepth *int `json:"maxDelegationDepth,omitempty" yaml:"maxDelegationDepth,omitempty"` @@ -176,6 +177,10 @@ func (c *Config) Validate() error { return fmt.Errorf("maxDataSize cannot be negative") } + if c.MaxDelegationDepth != nil && *c.MaxDelegationDepth <= 0 { + return fmt.Errorf("maxDelegationDepth must be positive") + } + // Validate event types (basic validation - could be extended) validEventTypes := map[string]bool{ EventTypeMCPInitialize: true, diff --git a/pkg/audit/config_test.go b/pkg/audit/config_test.go index 2ec0a477d4..cdd9064666 100644 --- a/pkg/audit/config_test.go +++ b/pkg/audit/config_test.go @@ -16,6 +16,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" ) func TestDefaultConfig(t *testing.T) { @@ -447,6 +449,62 @@ func TestGetLogWriter_WithActualFile(t *testing.T) { }) } +func TestValidateMaxDelegationDepth(t *testing.T) { + t.Parallel() + + depth := func(d int) *int { return &d } + + tests := []struct { + name string + value *int + wantErr bool + }{ + {name: "nil is valid", value: nil, wantErr: false}, + {name: "positive is valid", value: depth(5), wantErr: false}, + {name: "zero is rejected", value: depth(0), wantErr: true}, + {name: "negative is rejected", value: depth(-3), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config := &Config{MaxDelegationDepth: tt.value} + err := config.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), "maxDelegationDepth must be positive") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMaxDelegationDepthOrDefault(t *testing.T) { + t.Parallel() + + depth := func(d int) *int { return &d } + + tests := []struct { + name string + cfg *Config + want int + }{ + {name: "nil receiver", cfg: nil, want: auth.DefaultMaxDelegationDepth}, + {name: "nil MaxDelegationDepth field", cfg: &Config{}, want: auth.DefaultMaxDelegationDepth}, + {name: "zero MaxDelegationDepth", cfg: &Config{MaxDelegationDepth: depth(0)}, want: auth.DefaultMaxDelegationDepth}, + {name: "negative MaxDelegationDepth", cfg: &Config{MaxDelegationDepth: depth(-3)}, want: auth.DefaultMaxDelegationDepth}, + {name: "positive MaxDelegationDepth is honored", cfg: &Config{MaxDelegationDepth: depth(5)}, want: 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.cfg.MaxDelegationDepthOrDefault()) + }) + } +} + // waitForAuditLog polls the audit log file until content is available or timeout is reached. // This is more reliable than a fixed sleep for async log writes. func waitForAuditLog(t *testing.T, logFilePath string, timeout time.Duration) []byte { diff --git a/pkg/audit/workflow_auditor_test.go b/pkg/audit/workflow_auditor_test.go index e2f71c6113..83da6b66c7 100644 --- a/pkg/audit/workflow_auditor_test.go +++ b/pkg/audit/workflow_auditor_test.go @@ -565,31 +565,6 @@ func TestWorkflowAuditor_ExtractSubjects(t *testing.T) { SubjectKeyUser: "johndoe", }, }, - { - name: "identity_with_delegation_chain", - identity: &auth.Identity{ - PrincipalInfo: auth.PrincipalInfo{ - Subject: "user-delegated", - Name: "Delegated User", - Claims: map[string]any{ - "act": map[string]any{ - "sub": "agent-1", - "act": map[string]any{"sub": "agent-2"}, - }, - }, - DelegationChain: &auth.DelegationChain{ - Actors: []auth.DelegatedActor{ - {Subject: "agent-1"}, - {Subject: "agent-2"}, - }, - }, - }, - }, - wantSubjects: map[string]string{ - SubjectKeyUserID: "user-delegated", - SubjectKeyUser: "Delegated User", - }, - }, { name: "anonymous_user", identity: nil, @@ -641,46 +616,86 @@ func TestWorkflowAuditor_DelegationChain(t *testing.T) { }, } - t.Run("workflow event carries delegation chain", func(t *testing.T) { - t.Parallel() - auditor, writer := createTestAuditor(t, DefaultConfig()) - - ctx := auth.WithIdentity(context.Background(), delegatedIdentity) - auditor.LogWorkflowStarted(ctx, "wf-1", "wf", nil, time.Second) - - require.NotEmpty(t, writer.logs, "expected log entry") - entry := parseLogEntry(t, writer.getLastLog()) - - chain, ok := entry["delegation_chain"].(map[string]any) - require.True(t, ok, "delegation_chain should be present in the log output") - assert.Equal(t, false, chain["truncated"]) - actors, ok := chain["actors"].([]any) - require.True(t, ok) - require.Len(t, actors, 2) - first, ok := actors[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "agent-1", first["sub"]) - second, ok := actors[1].(map[string]any) - require.True(t, ok) - assert.Equal(t, "agent-2", second["sub"]) - }) + // attachDelegation is hand-repeated in every Log* method, so each call site is pinned individually. + logMethods := []struct { + name string + logFunc func(a *WorkflowAuditor, ctx context.Context) + }{ + { + name: "LogWorkflowStarted", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogWorkflowStarted(ctx, "wf-1", "wf", nil, time.Second) + }, + }, + { + name: "LogWorkflowCompleted", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogWorkflowCompleted(ctx, "wf-1", "wf", time.Second, 1, nil) + }, + }, + { + name: "LogWorkflowFailed", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogWorkflowFailed(ctx, "wf-1", "wf", time.Second, 1, errors.New("failed")) + }, + }, + { + name: "LogWorkflowTimedOut", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogWorkflowTimedOut(ctx, "wf-1", "wf", time.Second, 1) + }, + }, + { + name: "LogStepStarted", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogStepStarted(ctx, "wf-1", "step-1", "tool", "some-tool") + }, + }, + { + name: "LogStepCompleted", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogStepCompleted(ctx, "wf-1", "step-1", time.Second, 0) + }, + }, + { + name: "LogStepFailed", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogStepFailed(ctx, "wf-1", "step-1", time.Second, 0, errors.New("failed")) + }, + }, + { + name: "LogStepSkipped", + logFunc: func(a *WorkflowAuditor, ctx context.Context) { + a.LogStepSkipped(ctx, "wf-1", "step-1", "condition") + }, + }, + } - t.Run("step event carries delegation chain", func(t *testing.T) { - t.Parallel() - auditor, writer := createTestAuditor(t, DefaultConfig()) + for _, tt := range logMethods { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + auditor, writer := createTestAuditor(t, DefaultConfig()) - ctx := auth.WithIdentity(context.Background(), delegatedIdentity) - auditor.LogStepStarted(ctx, "wf-1", "step-1", "tool", "some-tool") + ctx := auth.WithIdentity(context.Background(), delegatedIdentity) + tt.logFunc(auditor, ctx) - require.NotEmpty(t, writer.logs, "expected log entry") - entry := parseLogEntry(t, writer.getLastLog()) + require.NotEmpty(t, writer.logs, "expected log entry") + entry := parseLogEntry(t, writer.getLastLog()) - chain, ok := entry["delegation_chain"].(map[string]any) - require.True(t, ok, "delegation_chain should be present in the log output") - actors, ok := chain["actors"].([]any) - require.True(t, ok) - assert.Len(t, actors, 2) - }) + chain, ok := entry["delegation_chain"].(map[string]any) + require.True(t, ok, "delegation_chain should be present in the log output") + assert.Equal(t, false, chain["truncated"]) + actors, ok := chain["actors"].([]any) + require.True(t, ok) + require.Len(t, actors, 2) + first, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-1", first["sub"]) + second, ok := actors[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-2", second["sub"]) + }) + } t.Run("no identity omits delegation chain", func(t *testing.T) { t.Parallel() @@ -711,7 +726,11 @@ func TestWorkflowAuditor_DelegationChain(t *testing.T) { assert.Equal(t, true, chain["truncated"]) actors, ok := chain["actors"].([]any) require.True(t, ok) - assert.Len(t, actors, 1) + require.Len(t, actors, 1) + first, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "agent-1", first["sub"]) + assert.Equal(t, float64(1), chain["dropped_count"]) }) } diff --git a/pkg/auth/context.go b/pkg/auth/context.go index 6c3bddf8ed..85e1546652 100644 --- a/pkg/auth/context.go +++ b/pkg/auth/context.go @@ -203,12 +203,13 @@ func claimsToIdentity(claims jwt.MapClaims, token string) (*Identity, error) { identity.Email = email } - // Parse the RFC 8693 "act" claim (if present) into a delegation chain so - // downstream consumers (audit logs, authorizers) see the full chain of - // parties acting on behalf of the subject. + // Parse the RFC 8693 "act" claim (if present) into a delegation chain. + // This is primarily for audit: per RFC 8693 §4.1, an authorizer applying + // access control MUST consider only the current actor (chain.Actors[0]); + // any nested actors in chain.Actors[1:] are informational only. if act, ok := claims["act"]; ok && act != nil { chain := ParseDelegationChain(act, DefaultMaxDelegationDepth) - if len(chain.Actors) > 0 { + if len(chain.Actors) > 0 || chain.Malformed { identity.DelegationChain = &chain } } diff --git a/pkg/auth/context_test.go b/pkg/auth/context_test.go index 8f920b406d..6fabf5b435 100644 --- a/pkg/auth/context_test.go +++ b/pkg/auth/context_test.go @@ -168,11 +168,13 @@ func TestClaimsToIdentity_ParsesActClaim(t *testing.T) { assert.Nil(t, id.DelegationChain) }) - t.Run("non-map act claim", func(t *testing.T) { + t.Run("non-map act claim is surfaced as malformed, not silently dropped", func(t *testing.T) { t.Parallel() id, err := claimsToIdentity(jwt.MapClaims{"sub": "user123", "act": "agent-1"}, "tok") require.NoError(t, err) - assert.Nil(t, id.DelegationChain) + require.NotNil(t, id.DelegationChain, "a malformed act must still produce a chain so the issue is visible") + assert.True(t, id.DelegationChain.Malformed) + assert.Empty(t, id.DelegationChain.Actors) }) } diff --git a/pkg/auth/delegation.go b/pkg/auth/delegation.go index 6e97b9d920..49cbf0472a 100644 --- a/pkg/auth/delegation.go +++ b/pkg/auth/delegation.go @@ -3,6 +3,8 @@ package auth +import "slices" + // DefaultMaxDelegationDepth is the default cap on how many nested RFC 8693 // "act" entries ParseDelegationChain will follow. It is aligned with the // issuance-side cap in pkg/authserver/server/tokenexchange/handler.go @@ -14,13 +16,18 @@ const DefaultMaxDelegationDepth = 10 // chain (one "act" claim value). type DelegatedActor struct { // Subject identifies the acting party (the "sub" member of the act claim). - // Empty when the act claim carries no "sub". + // Empty both when the act claim carries no "sub" and when "sub" is present + // but not a string; the latter case is distinguishable from the former by + // its raw value surfacing under Claims instead of vanishing. Subject string `json:"sub,omitempty"` // Claims holds the remaining members of the act claim (e.g. "iss", // "client_id"), preserving forward-compatibility with act claims that // carry more than a subject. The "act_claims" tag disambiguates these - // per-actor members from the identity's top-level claims. + // per-actor members from the identity's top-level claims. A non-string + // "sub" is included here rather than dropped, so a malformed subject is + // still visible. Internal claims (see internalClaims in context.go) are + // filtered out the same as they are from top-level Identity.Claims. Claims map[string]any `json:"act_claims,omitempty"` } @@ -39,41 +46,40 @@ type DelegationChain struct { // DroppedCount reports how many actors were dropped due to the depth cap. // Zero (and omitted from JSON) when nothing was dropped. DroppedCount int `json:"dropped_count,omitempty"` + + // Malformed is true when a present, non-nil "act" value (top-level or + // nested) was not a JSON object, per RFC 8693 §4.1. This is distinct from + // a legitimately absent/null "act": that case leaves Malformed false, with + // or without actors already parsed from shallower levels of the chain. + Malformed bool `json:"malformed,omitempty"` } // ParseDelegationChain parses a JWT "act" claim value into a DelegationChain. // The act value is expected to be a map (as produced by JSON decoding); any -// other shape yields an empty, non-truncated chain. Nested "act" members are -// followed up to maxDepth actors; maxDepth <= 0 uses DefaultMaxDelegationDepth. -// A nil/absent act value yields the zero chain (no actors, not truncated). +// other non-nil shape yields Malformed=true, with whatever actors were parsed +// from shallower levels preserved. Nested "act" members are followed up to +// maxDepth actors; maxDepth <= 0 uses DefaultMaxDelegationDepth. A nil/absent +// act value yields the zero chain (no actors, not truncated, not malformed). func ParseDelegationChain(act any, maxDepth int) DelegationChain { if maxDepth <= 0 { maxDepth = DefaultMaxDelegationDepth } - chain := DelegationChain{} + chain := DelegationChain{Actors: []DelegatedActor{}} current := act for len(chain.Actors) < maxDepth { m, ok := current.(map[string]any) if !ok { + // A nil current here means act was absent/null (either at the + // top level, or the legitimate end of a chain checked below) — + // not malformed. Anything else present but not an object is. + if current != nil { + chain.Malformed = true + } return chain } - actor := DelegatedActor{} - if sub, ok := m["sub"].(string); ok { - actor.Subject = sub - } - claims := make(map[string]any, len(m)) - for k, v := range m { - if k == "sub" || k == "act" { - continue - } - claims[k] = v - } - if len(claims) > 0 { - actor.Claims = claims - } - chain.Actors = append(chain.Actors, actor) + chain.Actors = append(chain.Actors, parseActor(m)) next, ok := m["act"] if !ok || next == nil { @@ -88,6 +94,10 @@ func ParseDelegationChain(act any, maxDepth int) DelegationChain { for { m, ok := current.(map[string]any) if !ok { + // current is guaranteed non-nil here (the loop above only + // advances current to a checked, present, non-nil "act" value), + // so a failed assertion means unparsable data beyond the cap. + chain.Malformed = true break } chain.Truncated = true @@ -101,3 +111,26 @@ func ParseDelegationChain(act any, maxDepth int) DelegationChain { return chain } + +// parseActor builds one DelegatedActor from a decoded "act" claim object: the +// "sub" member (if a string) becomes Subject, and every other member except +// "act" and internalClaims (see context.go) is copied into Claims. A +// non-string "sub" is preserved under Claims rather than dropped. +func parseActor(m map[string]any) DelegatedActor { + actor := DelegatedActor{} + sub, subIsString := m["sub"].(string) + if subIsString { + actor.Subject = sub + } + claims := make(map[string]any, len(m)) + for k, v := range m { + if k == "act" || (k == "sub" && subIsString) || slices.Contains(internalClaims, k) { + continue + } + claims[k] = v + } + if len(claims) > 0 { + actor.Claims = claims + } + return actor +} diff --git a/pkg/auth/delegation_test.go b/pkg/auth/delegation_test.go index a1d303ded1..7e458ddd4f 100644 --- a/pkg/auth/delegation_test.go +++ b/pkg/auth/delegation_test.go @@ -132,6 +132,121 @@ func TestParseDelegationChain(t *testing.T) { } } +// TestParseDelegationChain_Malformed pins RFC 8693 §4.1 non-object "act" +// handling: a malformed act must be distinguishable from a legitimately +// absent/ended one, and must never be conflated with it. +func TestParseDelegationChain_Malformed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + act any + maxDepth int + wantSubjects []string + wantMalformed bool + }{ + { + name: "absent act is not malformed", + act: nil, + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: nil, + wantMalformed: false, + }, + { + name: "top-level non-object act is malformed with zero actors", + act: "agent-1", + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: nil, + wantMalformed: true, + }, + { + name: "top-level array act is malformed with zero actors", + act: []any{"agent-1"}, + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: nil, + wantMalformed: true, + }, + { + name: "nested non-object act is malformed with actors parsed so far", + act: map[string]any{"sub": "agent-1", "act": "junk"}, + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: []string{"agent-1"}, + wantMalformed: true, + }, + { + name: "non-object act exactly at the depth cap is malformed", + act: map[string]any{ + "sub": "agent-1", + "act": map[string]any{"sub": "agent-2", "act": "junk"}, + }, + maxDepth: 2, + wantSubjects: []string{"agent-1", "agent-2"}, + wantMalformed: true, + }, + { + name: "legitimately ended chain (no act member) is not malformed", + act: map[string]any{"sub": "agent-1"}, + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: []string{"agent-1"}, + wantMalformed: false, + }, + { + name: "legitimately ended chain (explicit null act) is not malformed", + act: map[string]any{"sub": "agent-1", "act": nil}, + maxDepth: DefaultMaxDelegationDepth, + wantSubjects: []string{"agent-1"}, + wantMalformed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + chain := ParseDelegationChain(tt.act, tt.maxDepth) + + assert.Equal(t, tt.wantSubjects, actorSubjects(chain)) + assert.Equal(t, tt.wantMalformed, chain.Malformed) + }) + } +} + +// TestParseDelegationChain_NonStringSub pins that a non-string "sub" is +// preserved under act_claims instead of silently vanishing: an actor whose +// identifier was the wrong JSON type must stay distinguishable from one with +// no identifier at all. +func TestParseDelegationChain_NonStringSub(t *testing.T) { + t.Parallel() + + chain := ParseDelegationChain(map[string]any{"sub": float64(123)}, DefaultMaxDelegationDepth) + + require.Len(t, chain.Actors, 1) + assert.Empty(t, chain.Actors[0].Subject, "a non-string sub must not populate Subject") + assert.Equal(t, map[string]any{"sub": float64(123)}, chain.Actors[0].Claims, + "a non-string sub must be preserved under act_claims, not dropped") +} + +// TestParseDelegationChain_FiltersInternalClaims pins that act_claims goes +// through the same internal-claim filter as the identity's top-level claims, +// so credential-adjacent claims (e.g. "tsid") never reach an external sink +// through this path even if act construction grows to mirror subject-token +// claims in the future. +func TestParseDelegationChain_FiltersInternalClaims(t *testing.T) { + t.Parallel() + + act := map[string]any{ + "sub": "agent-1", + "tsid": "session-secret", + "iss": "https://issuer.example", + } + + chain := ParseDelegationChain(act, DefaultMaxDelegationDepth) + + require.Len(t, chain.Actors, 1) + assert.Equal(t, map[string]any{"iss": "https://issuer.example"}, chain.Actors[0].Claims, + "tsid must be filtered out of act_claims the same as top-level Claims") +} + func TestParseDelegationChain_ActorClaims(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/integration_delegation_audit_test.go b/pkg/authserver/integration_delegation_audit_test.go new file mode 100644 index 0000000000..abad93fcaf --- /dev/null +++ b/pkg/authserver/integration_delegation_audit_test.go @@ -0,0 +1,339 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "context" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// readSingleAuditEvent reads the audit log file at path and decodes exactly +// one newline-delimited JSON event, failing the test if there isn't exactly one. +func readSingleAuditEvent(t *testing.T, path string) map[string]any { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.NotEmpty(t, strings.TrimSpace(string(data)), "audit log is empty; expected one event") + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + require.Len(t, lines, 1, "expected exactly one audit event, got: %q", string(data)) + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &event)) + return event +} + +// signTestJWT signs a JWT with key using the standard test issuer/audience and +// a 30-minute expiry, embedding subject and any extraClaims (e.g. "client_id" +// for a delegation act claim). extraClaims may be nil. +func signTestJWT(t *testing.T, key *rsa.PrivateKey, subject string, extraClaims map[string]any) string { + t.Helper() + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key"), + ) + require.NoError(t, err) + + now := time.Now() + builder := jwt.Signed(signer). + Claims(jwt.Claims{ + Issuer: testIssuer, + Subject: subject, + Audience: jwt.Audience{testAudience}, + Expiry: jwt.NewNumericDate(now.Add(30 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }) + if extraClaims != nil { + builder = builder.Claims(extraClaims) + } + token, err := builder.Serialize() + require.NoError(t, err) + return token +} + +// exchangeForDelegatedToken performs an RFC 8693 token exchange against +// serverURL using the given confidential client credentials and subject +// token, and returns the resulting delegated access token. +func exchangeForDelegatedToken(t *testing.T, serverURL, clientID, clientSecret, subjectToken string) string { + t.Helper() + + resp := makeTokenRequest(t, serverURL, url.Values{ + "grant_type": {oauthproto.GrantTypeTokenExchange}, + "subject_token": {subjectToken}, + "subject_token_type": {oauthproto.TokenTypeAccessToken}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + }) + defer resp.Body.Close() + body := parseTokenResponse(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode, + "token exchange should succeed, got %d (body: %v)", resp.StatusCode, body) + delegated, ok := body["access_token"].(string) + require.True(t, ok, "access_token should be a string") + require.NotEmpty(t, delegated) + return delegated +} + +// auditEventForToken runs token through the audit(auth(stub)) middleware +// chain — audit outermost, auth innermost — and returns the single resulting +// audit event. The stub handler does not re-publish the identity itself: this +// pins that TokenValidator.Middleware alone publishes the validated identity +// to the audit holder via the IdentityHolder read-back. +func auditEventForToken(t *testing.T, key *rsa.PrivateKey, token, validationMsg string) map[string]any { + t.Helper() + return auditEventForTokenWithConfig(t, key, token, validationMsg, audit.Config{}) +} + +// auditEventForTokenWithConfig is auditEventForToken for tests that need a +// non-default audit.Config (e.g. MaxDelegationDepth). cfg is taken by value +// and its LogFile field is set on the local copy, so the caller's config is +// never mutated. +func auditEventForTokenWithConfig( + t *testing.T, key *rsa.PrivateKey, token, validationMsg string, cfg audit.Config, +) map[string]any { + t.Helper() + + // Build a validator that trusts the server's issuer and keys, exactly like + // the middleware in front of a protected resource server. The in-process + // key provider avoids self-referential HTTP JWKS fetches. + validator, err := auth.NewTokenValidator(context.Background(), auth.TokenValidatorConfig{ + Issuer: testIssuer, + Audience: testAudience, + }, auth.WithKeyProvider(&testKeyProvider{key: key})) + require.NoError(t, err) + + cfg.LogFile = filepath.Join(t.TempDir(), "audit.log") + auditor, err := audit.NewAuditorWithTransport(&cfg, "streamable-http") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, auditor.Close()) + }) + + stub := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/messages", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + auditor.Middleware(validator.Middleware(stub)).ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, validationMsg) + + return readSingleAuditEvent(t, cfg.LogFile) +} + +// TestIntegration_TokenExchange_AuditDelegationChain proves the end-to-end +// audit story for RFC 8693 delegation: a delegated token minted by the real +// token-exchange handler is validated by the auth middleware, and the audit +// middleware emits an event whose delegationChain records the acting agent. +func TestIntegration_TokenExchange_AuditDelegationChain(t *testing.T) { + t.Parallel() + + const ( + agentClientID = "test-audit-agent-client" + agentClientSecret = "test-audit-agent-secret" + delegatedUserSub = "audit-delegated-user-sub" + ) + + agentClient, err := registration.New(registration.Config{ + ID: agentClientID, + Secret: agentClientSecret, + Public: false, + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + Scopes: registration.DefaultScopes, + Audience: []string{testAudience}, + }) + require.NoError(t, err) + + m := startMockOIDC(t) + ts := setupTestServerWithMockOIDC(t, m, withExtraClient(agentClient)) + + // Mint the subject token (user) signed by the server's key. + subjectToken := signTestJWT(t, ts.PrivateKey, delegatedUserSub, map[string]any{ + "client_id": agentClientID, + }) + + // Exchange it for a delegated token carrying act.sub = agent. + delegated := exchangeForDelegatedToken(t, ts.Server.URL, agentClientID, agentClientSecret, subjectToken) + + // Chain: audit (outer) -> auth -> bare stub handler. + event := auditEventForToken(t, ts.PrivateKey, delegated, + "delegated token must validate through the auth middleware") + + subjects, ok := event["subjects"].(map[string]any) + require.True(t, ok, "subjects should be a map") + assert.Equal(t, delegatedUserSub, subjects["user_id"], + "audit subject must be the delegated user, not the agent") + + chain, ok := event["delegation_chain"].(map[string]any) + require.True(t, ok, "delegation_chain must be present in the audit event") + assert.Equal(t, false, chain["truncated"]) + actors, ok := chain["actors"].([]any) + require.True(t, ok, "actors should be an array") + require.Len(t, actors, 1, "a single exchange yields a single actor") + actor, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, agentClientID, actor["sub"], + "the delegation chain must record the acting agent client") +} + +// TestIntegration_AuditMiddleware_NonDelegatedTokenOmitsDelegationChain is the +// negative companion to TestIntegration_TokenExchange_AuditDelegationChain: a +// plain (non-delegated) token through the same audit(auth(stub)) chain must +// produce an audit event with NO delegation_chain member. +func TestIntegration_AuditMiddleware_NonDelegatedTokenOmitsDelegationChain(t *testing.T) { + t.Parallel() + + const plainUserSub = "audit-plain-user-sub" + + m := startMockOIDC(t) + ts := setupTestServerWithMockOIDC(t, m) + + // Mint a plain subject token signed by the server's key (no act claim). + plainToken := signTestJWT(t, ts.PrivateKey, plainUserSub, nil) + + event := auditEventForToken(t, ts.PrivateKey, plainToken, + "plain token must validate through the auth middleware") + + subjects, ok := event["subjects"].(map[string]any) + require.True(t, ok, "subjects should be a map") + assert.Equal(t, plainUserSub, subjects["user_id"], + "audit subject must be the authenticated user") + + _, exists := event["delegation_chain"] + assert.False(t, exists, + "a non-delegated token must not produce a delegation_chain member") +} + +// mintTwoHopDelegatedToken produces a token whose second hop is genuine +// production output of a real RFC 8693 token-exchange call — firstAgentClientID +// "delegates" to secondAgentClientID — while the first hop is a hand-signed +// stand-in for an earlier exchange this test never performs. What this pins +// is the handler's nesting direction: the second (real) actor must land at +// actors[0], outermost, with the hand-signed first actor at actors[1]. +// +// This AS can honour an RFC 8693 §4.4 may_act claim — checkDelegationConsent +// (handler.go) treats it as the authoritative consent signal and skips the +// client_id binding entirely when present — but no code path anywhere writes +// may_act, and the exchange does not propagate an inbound one: +// pkg/authserver/server/tokenexchange/validator.go:276-281 routes may_act +// into the structured MayAct field so it never lands in Extra, and +// handler.go:160 only ever copies Extra["act"]. So the subject token here +// hand-signs the one claim a may_act-emitting issuer would have written +// (plus a prior "act" entry standing in for that earlier hop); everything +// downstream of it — the consent check, the act-claim nesting, the issued +// token — is genuine production output of a single real exchange. +func mintTwoHopDelegatedToken(t *testing.T) (ts *testServerWithUpstream, delegated, firstAgentClientID, secondAgentClientID string) { + t.Helper() + + const ( + firstAgent = "test-first-agent-client" + secondAgent = "test-second-agent-client" + secondAgentSecret = "test-second-agent-secret" //nolint:gosec // test fixture, not a real credential + delegatedUserSub = "audit-multi-actor-user-sub" + ) + + secondAgentClient, err := registration.New(registration.Config{ + ID: secondAgent, + Secret: secondAgentSecret, + Public: false, + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + Scopes: registration.DefaultScopes, + Audience: []string{testAudience}, + }) + require.NoError(t, err) + + m := startMockOIDC(t) + ts = setupTestServerWithMockOIDC(t, m, withExtraClient(secondAgentClient)) + + subjectToken := signTestJWT(t, ts.PrivateKey, delegatedUserSub, map[string]any{ + "client_id": firstAgent, // ignored: may_act wins + "act": map[string]any{"sub": firstAgent}, // prior hop + "may_act": map[string]any{"sub": secondAgent}, // RFC 8693 §4.4 consent + }) + + delegated = exchangeForDelegatedToken(t, ts.Server.URL, secondAgent, secondAgentSecret, subjectToken) + return ts, delegated, firstAgent, secondAgent +} + +// TestIntegration_TokenExchange_MultiActorDelegationChain proves the +// handler's nesting direction end to end: hop 2 (secondAgentClientID) is +// real handler output from an actual token-exchange call, hop 1 +// (firstAgentClientID) is the hand-signed stand-in described on +// mintTwoHopDelegatedToken, and the audit event for the resulting token +// orders the two actors outermost (most recent) first. +func TestIntegration_TokenExchange_MultiActorDelegationChain(t *testing.T) { + t.Parallel() + + ts, delegated, firstAgentClientID, secondAgentClientID := mintTwoHopDelegatedToken(t) + + event := auditEventForToken(t, ts.PrivateKey, delegated, + "re-delegated token must validate through the auth middleware") + + chain, ok := event["delegation_chain"].(map[string]any) + require.True(t, ok, "delegation_chain must be present in the audit event") + assert.Equal(t, false, chain["truncated"]) + actors, ok := chain["actors"].([]any) + require.True(t, ok, "actors should be an array") + require.Len(t, actors, 2, "a re-delegated token yields two actors") + + actor0, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, secondAgentClientID, actor0["sub"], + "actors[0] (outermost) must be the client that performed the second exchange") + actor1, ok := actors[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, firstAgentClientID, actor1["sub"], + "actors[1] (innermost) must be the earlier, first-hop actor") +} + +// TestIntegration_TokenExchange_TruncatedDelegationChainAuditEvent proves +// that an auditor configured with MaxDelegationDepth: 1 truncates a genuine +// 2-actor delegation chain down to its outermost (most recent) actor. +func TestIntegration_TokenExchange_TruncatedDelegationChainAuditEvent(t *testing.T) { + t.Parallel() + + ts, delegated, _, secondAgentClientID := mintTwoHopDelegatedToken(t) + + maxDepth := 1 + event := auditEventForTokenWithConfig(t, ts.PrivateKey, delegated, + "re-delegated token must validate through the auth middleware", + audit.Config{MaxDelegationDepth: &maxDepth}) + + chain, ok := event["delegation_chain"].(map[string]any) + require.True(t, ok, "delegation_chain must be present in the audit event") + assert.Equal(t, true, chain["truncated"]) + assert.Equal(t, float64(1), chain["dropped_count"], + "one of the two actors must be reported dropped") + + actors, ok := chain["actors"].([]any) + require.True(t, ok, "actors should be an array") + require.Len(t, actors, 1, "MaxDelegationDepth=1 must leave exactly one surviving actor") + actor0, ok := actors[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, secondAgentClientID, actor0["sub"], + "the surviving actor must be the outermost one") +} diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index f83e406571..014954ac2a 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -29,7 +29,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" @@ -712,7 +711,7 @@ func TestIntegration_TokenExchange_ConfidentialClientHappyPath(t *testing.T) { // Mint a subject token signed by the server's own key (the validator verifies // against the server's JWKS). client_id must equal the acting client so the - // RFC 8693 §4.1 delegation-consent check (client_id binding) passes. + // RFC 8693 §4.4 delegation-consent check (client_id binding) passes. signer, err := jose.NewSigner( jose.SigningKey{Algorithm: jose.RS256, Key: ts.PrivateKey}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key"), @@ -790,193 +789,6 @@ func TestIntegration_TokenExchange_ConfidentialClientHappyPath(t *testing.T) { "delegated token exp must be capped at the 15m delegation lifespan, not the subject token's 30m") } -// TestIntegration_TokenExchange_AuditDelegationChain proves the end-to-end -// audit story for RFC 8693 delegation: a delegated token minted by the real -// token-exchange handler is validated by the auth middleware, and the audit -// middleware emits an event whose delegationChain records the acting agent. -func TestIntegration_TokenExchange_AuditDelegationChain(t *testing.T) { - t.Parallel() - - const ( - agentClientID = "test-audit-agent-client" - agentClientSecret = "test-audit-agent-secret" - delegatedUserSub = "audit-delegated-user-sub" - ) - - agentClient, err := registration.New(registration.Config{ - ID: agentClientID, - Secret: agentClientSecret, - Public: false, - GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, - Scopes: registration.DefaultScopes, - Audience: []string{testAudience}, - }) - require.NoError(t, err) - - m := startMockOIDC(t) - ts := setupTestServerWithMockOIDC(t, m, withExtraClient(agentClient)) - - // Mint the subject token (user) signed by the server's key. - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.RS256, Key: ts.PrivateKey}, - (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key"), - ) - require.NoError(t, err) - - now := time.Now() - subjectToken, err := jwt.Signed(signer). - Claims(jwt.Claims{ - Issuer: testIssuer, - Subject: delegatedUserSub, - Audience: jwt.Audience{testAudience}, - Expiry: jwt.NewNumericDate(now.Add(30 * time.Minute)), - IssuedAt: jwt.NewNumericDate(now), - }). - Claims(map[string]any{ - "client_id": agentClientID, - }). - Serialize() - require.NoError(t, err) - - // Exchange it for a delegated token carrying act.sub = agent. - resp := makeTokenRequest(t, ts.Server.URL, url.Values{ - "grant_type": {oauthproto.GrantTypeTokenExchange}, - "subject_token": {subjectToken}, - "subject_token_type": {oauthproto.TokenTypeAccessToken}, - "client_id": {agentClientID}, - "client_secret": {agentClientSecret}, - }) - defer resp.Body.Close() - body := parseTokenResponse(t, resp) - require.Equal(t, http.StatusOK, resp.StatusCode, - "token exchange should succeed, got %d (body: %v)", resp.StatusCode, body) - delegated, ok := body["access_token"].(string) - require.True(t, ok, "access_token should be a string") - require.NotEmpty(t, delegated) - - // Build a validator that trusts the server's issuer and keys, exactly like - // the middleware in front of a protected resource server. The in-process - // key provider avoids self-referential HTTP JWKS fetches. - validator, err := auth.NewTokenValidator(context.Background(), auth.TokenValidatorConfig{ - Issuer: testIssuer, - Audience: testAudience, - }, auth.WithKeyProvider(&testKeyProvider{key: ts.PrivateKey})) - require.NoError(t, err) - - // Chain: audit (outer) -> auth -> bare stub handler. The stub does NOT - // re-publish the identity: this test pins that TokenValidator.Middleware - // alone publishes the validated identity to the audit holder. - var auditBuf bytes.Buffer - auditor, err := audit.NewAuditorWithTransport(&audit.Config{}, "streamable-http") - require.NoError(t, err) - auditor.SetLogWriterForTest(&auditBuf) //nolint:staticcheck // test-only writer capture until NewAuditorWithWriter exists - - stub := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/messages", strings.NewReader("{}")) - req.Header.Set("Authorization", "Bearer "+delegated) - rr := httptest.NewRecorder() - auditor.Middleware(validator.Middleware(stub)).ServeHTTP(rr, req) - - require.Equal(t, http.StatusOK, rr.Code, - "delegated token must validate through the auth middleware") - - // Exactly one audit event, carrying the delegation chain. - lines := strings.Split(strings.TrimSpace(auditBuf.String()), "\n") - require.Len(t, lines, 1, "expected exactly one audit event, got: %q", auditBuf.String()) - - var event map[string]any - require.NoError(t, json.Unmarshal([]byte(lines[0]), &event)) - - subjects, ok := event["subjects"].(map[string]any) - require.True(t, ok, "subjects should be a map") - assert.Equal(t, delegatedUserSub, subjects["user_id"], - "audit subject must be the delegated user, not the agent") - - chain, ok := event["delegation_chain"].(map[string]any) - require.True(t, ok, "delegation_chain must be present in the audit event") - assert.Equal(t, false, chain["truncated"]) - actors, ok := chain["actors"].([]any) - require.True(t, ok, "actors should be an array") - require.Len(t, actors, 1, "a single exchange yields a single actor") - actor, ok := actors[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, agentClientID, actor["sub"], - "the delegation chain must record the acting agent client") -} - -// TestIntegration_AuditMiddleware_NonDelegatedTokenOmitsDelegationChain is the -// negative companion to TestIntegration_TokenExchange_AuditDelegationChain: a -// plain (non-delegated) token through the same audit(auth(stub)) chain must -// produce an audit event with NO delegation_chain member. -func TestIntegration_AuditMiddleware_NonDelegatedTokenOmitsDelegationChain(t *testing.T) { - t.Parallel() - - const plainUserSub = "audit-plain-user-sub" - - m := startMockOIDC(t) - ts := setupTestServerWithMockOIDC(t, m) - - // Mint a plain subject token signed by the server's key (no act claim). - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.RS256, Key: ts.PrivateKey}, - (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key"), - ) - require.NoError(t, err) - - now := time.Now() - plainToken, err := jwt.Signed(signer). - Claims(jwt.Claims{ - Issuer: testIssuer, - Subject: plainUserSub, - Audience: jwt.Audience{testAudience}, - Expiry: jwt.NewNumericDate(now.Add(30 * time.Minute)), - IssuedAt: jwt.NewNumericDate(now), - }). - Serialize() - require.NoError(t, err) - - validator, err := auth.NewTokenValidator(context.Background(), auth.TokenValidatorConfig{ - Issuer: testIssuer, - Audience: testAudience, - }, auth.WithKeyProvider(&testKeyProvider{key: ts.PrivateKey})) - require.NoError(t, err) - - var auditBuf bytes.Buffer - auditor, err := audit.NewAuditorWithTransport(&audit.Config{}, "streamable-http") - require.NoError(t, err) - auditor.SetLogWriterForTest(&auditBuf) //nolint:staticcheck // test-only writer capture until NewAuditorWithWriter exists - - stub := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/messages", strings.NewReader("{}")) - req.Header.Set("Authorization", "Bearer "+plainToken) - rr := httptest.NewRecorder() - auditor.Middleware(validator.Middleware(stub)).ServeHTTP(rr, req) - - require.Equal(t, http.StatusOK, rr.Code, - "plain token must validate through the auth middleware") - - lines := strings.Split(strings.TrimSpace(auditBuf.String()), "\n") - require.Len(t, lines, 1, "expected exactly one audit event, got: %q", auditBuf.String()) - - var event map[string]any - require.NoError(t, json.Unmarshal([]byte(lines[0]), &event)) - - subjects, ok := event["subjects"].(map[string]any) - require.True(t, ok, "subjects should be a map") - assert.Equal(t, plainUserSub, subjects["user_id"], - "audit subject must be the authenticated user") - - _, exists := event["delegation_chain"] - assert.False(t, exists, - "a non-delegated token must not produce a delegation_chain member") -} - // ============================================================================ // Full PKCE Flow Integration Tests with Mock Upstream IDP (using mockoidc) // ============================================================================ diff --git a/pkg/authserver/server/tokenexchange/handler.go b/pkg/authserver/server/tokenexchange/handler.go index a7c0b614ce..35678284f6 100644 --- a/pkg/authserver/server/tokenexchange/handler.go +++ b/pkg/authserver/server/tokenexchange/handler.go @@ -284,7 +284,7 @@ func validateExchangeParams(form url.Values) (string, error) { return subjectToken, nil } -// checkDelegationConsent enforces the RFC 8693 §4.1 delegation consent check. +// checkDelegationConsent enforces the RFC 8693 §4.4 delegation consent check. // // If the subject token carries a may_act claim, it is the authoritative // consent signal: only the party named in may_act.sub may delegate. The diff --git a/pkg/authserver/server/tokenexchange/validator.go b/pkg/authserver/server/tokenexchange/validator.go index 07a0822043..a1a3d6bd76 100644 --- a/pkg/authserver/server/tokenexchange/validator.go +++ b/pkg/authserver/server/tokenexchange/validator.go @@ -53,14 +53,14 @@ type ValidatedClaims struct { // Scopes is the space-delimited scope string from the "scope" claim. // Empty if the subject token carries no scope claim. Scopes string - // MayAct holds the authorized actor from the "may_act" claim (RFC 8693 §4.1). + // MayAct holds the authorized actor from the "may_act" claim (RFC 8693 §4.4). // Nil when the subject token does not carry a may_act claim. MayAct *MayActClaim // Extra contains all non-standard claims not captured by other fields. Extra map[string]any } -// MayActClaim represents the RFC 8693 §4.1 may_act claim from a subject token. +// MayActClaim represents the RFC 8693 §4.4 may_act claim from a subject token. // It identifies the party authorized to act on behalf of the subject. type MayActClaim struct { Sub string `json:"sub"`