Skip to content

Harden external may_act delegation - #6353

Merged
jhrozek merged 3 commits into
stacklok:mainfrom
jhrozek:6323-6113-mayact-hardening
Aug 18, 2026
Merged

Harden external may_act delegation#6353
jhrozek merged 3 commits into
stacklok:mainfrom
jhrozek:6323-6113-mayact-hardening

Conversation

@jhrozek

Copy link
Copy Markdown
Contributor

Summary

  • External issuers could authorize ToolHive delegation through a well-formed may_act claim without an explicit issuer-level opt-in, allowing that path to bypass allowedActors.
  • Require trusted issuers to set allowMayAct: true before accepting their may_act claims, require named ToolHive delegate clients on that path, and reject the unsafe wildcard combination.
  • Expose trusted-issuer policy through the embedded auth-server CRD, validate the assembled runtime policy during reconciliation, normalize re-signed act history to iss/sub, and add unit, integration, and operator E2E coverage.

Closes#6323
Closes#6113

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

task test-e2e was attempted but could not complete because the locally installed ToolHive Desktop application rejects the workspace binary as a CLI conflict before the relevant test can run.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

AreaChange
Token exchangeGate external may_act, qualify issued actor hops, and normalize prior act chains before re-signing.
Operator APIAdd trustedIssuers and allowMayAct to embedded auth-server configuration and generated CRDs.
ReconciliationProject and validate issuer policy before creating an auth-server workload.
Tests and docsCover runtime, integration, and operator paths; document the delegation model and CRD API.

Does this introduce a user-facing change?

Yes. Cluster operators can configure trusted external OIDC issuers under embeddedAuthServer.trustedIssuers. External may_act delegation is now disabled unless the issuer explicitly sets allowMayAct: true; configurations combining that opt-in with allowedDelegateClients: ["*"] are rejected.

Special notes for reviewers

Review the consent boundary in pkg/authserver/server/tokenexchange/multi_issuer_validator.go and handler.go: may_act remains authoritative only after issuer opt-in and authenticated-client containment. The commit message includes concrete input and issued-token shapes.

Generated with Claude Code

@codecov

codecovBot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.34513% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.99%. Comparing base (55feedf) to head (e1001dc).
⚠️ Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
pkg/authserver/server/tokenexchange/handler.go90.32%2 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #6353 +/- ##
==========================================
+ Coverage 72.98% 72.99% +0.01% 
==========================================
Files 742 742 Lines 78398 78491 +93 ==========================================
+ Hits 57216 57297 +81 - Misses 17193 17204 +11 - Partials 3989 3990 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek
jhrozekforce-pushed the 6323-6113-mayact-hardening branch from c2005b8 to 38aa57cCompareAugust 17, 2026 20:43

@JAORMXJAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel review

I reviewed commit 38aa57ce3a558195a4b275afe33c8f5d421bd034 against origin/main with independent OAuth, application-security, threat-model, MCP, operator/CRD, ToolHive-conventions, test-coverage, architecture, reuse, and duplication passes. I also checked the current primary standards: RFC 8693, RFC 9700 (OAuth Security BCP), RFC 8725 (JWT BCP), RFC 7519, and OIDC Core.

The first-hop may_act hardening itself is sound: opt-in is fail-closed, external may_act.iss is required and bound to the ToolHive issuer, allowedDelegateClients is still enforced against the authenticated client, wildcard + allowMayAct is rejected, and inherited actor claims are reduced to iss/sub with depth and size bounds. CI is fully green (44/44 checks, including unit, lint, operator/core E2E, CRD compatibility, codegen, and vulnerability scanning).

I found the following issues that should be addressed before merge:

1. High — MCPExternalAuthConfig can report Valid=True for an invalid trusted-issuer policy

TrustedIssuers is added to EmbeddedAuthServerConfig, but MCPExternalAuthConfig.validateEmbeddedAuthServer never validates it. Deterministically invalid policies (duplicate/self issuer, unsupported actorClaim, invalid URL, wildcard mixed with named delegate clients, etc.) therefore pass the config controller and receive ConditionTypeValid=True; they fail only later when a consuming workload assembles its RunConfig.

Locations: cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:687, :1852-1900; success status at cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go:156-175.

Please validate trusted issuers as part of the shared config's own Validate() path, reusing the runtime validator so the policy cannot drift.

2. High — new terminal config failures can leave Ready=True stale

The new InvalidEmbeddedAuthServerConfigError path sets Phase=Failed for VirtualMCPServer and MCPRemoteProxy, but neither handler clears a previously true Ready condition. Editing a ready resource to an invalid trusted-issuer policy can therefore produce Phase=Failed, validation=False, and Ready=True simultaneously. MCPServer already clears Ready in its equivalent path.

Locations: cmd/thv-operator/controllers/virtualmcpserver_controller.go:170-194, cmd/thv-operator/controllers/mcpremoteproxy_controller.go:69-99, comparison at cmd/thv-operator/controllers/mcpserver_controller.go:180-185.

Please set Ready=False with the terminal validation reason and add ready→invalid transition coverage.

3. Medium — the issuance path trusts a deliberately tolerant audit parser and can re-sign an empty actor hop

coreaudit.ParseDelegationChain explicitly accepts an act object with no iss or sub (for forensic logging). chainToAct then turns {"role":"admin"} into {} and ToolHive signs that empty object as a prior actor hop. Audit parsing is intentionally tolerant; issuance validation should be strict.

Locations: pkg/authserver/server/tokenexchange/handler.go:496-540, :562-577; parser contract in toolhive-core/audit/delegation.go says a map with no iss/sub/act yields an empty-identity hop.

Please reject any normalized hop that has neither a non-empty iss nor sub before minting. A regression test using an extras-only outer and nested hop would also prove that inner attributes are stripped without preserving meaningless {} hops.

4. Medium — newly added token-rejection paths use the wrong RFC 8693 error

The new malformed-chain and oversized-chain branches return invalid_grant. RFC 8693 §2.2.2 says an invalid subject/actor token, including one unacceptable by policy, MUST use invalid_request. The code comment relies on the later general allowance for other errors, but that does not override the preceding explicit requirement for invalid subject tokens.

Locations: pkg/authserver/server/tokenexchange/handler.go:503-513, :542-549.

Please use the RFC-defined error for these new rejection paths (and consider aligning the adjacent pre-existing token-policy failures separately).

5. Medium — admission validation omits deterministic policy invariants

The new CRD CEL checks only allowMayAct + "*" and allowPrivateIPs without jwksUrl. The API still admits wildcard mixed with named delegate clients, duplicate issuer URLs, self-issuer collisions, and reserved/unreadable actorClaim values, all of which runtime validation deterministically rejects. The operator rule requires constraints expressible in OpenAPI/CEL to be rejected at admission rather than discovered during reconciliation.

Location: cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:389-477.

At minimum add wildcard exclusivity. Prefer map-list/uniqueness validation for issuer URLs and CEL for the other spec-only invariants where practical; retain runtime validation as defense in depth.

6. Low — architecture example contradicts the newly issued token shape

buildActClaim now always emits act.iss, but the self-issued delegate example still shows "act": {"sub":"coding-agent"}.

Location: docs/arch/17-token-exchange-delegation.md:358-370.

Please update it to include the ToolHive issuer.

Targeted coverage gaps

The current tests are broad, but two security boundaries deserve direct coverage:

  • configure issuer A with allowMayAct=true and issuer B with the default false, then prove identical claims are isolated per issuer;
  • exercise MCPExternalAuthConfig.spec.embeddedAuthServer.trustedIssuers through a referenced consumer, not only the inline VirtualMCPServer surface.

Non-blocking broader risks observed

These predate this diff, so I am not using them to block this PR, but they merit follow-up issues: external ID/access-token type confusion when expectedAudience is a client ID and the token lacks optional at_hash/c_hash (RFC 8725 §§2.8, 3.11–3.12), and loss of per-external-issuer delegate containment on later cross-client re-exchange after the first token becomes self-issued.

@jhrozek
jhrozekforce-pushed the 6323-6113-mayact-hardening branch from 38aa57c to 1655de1CompareAugust 18, 2026 09:46
External subject tokens could use a well-formed `may_act` claim to bypass an issuer's `AllowedActors` policy. Make that consent path an explicit per-issuer decision, constrain it to named ToolHive delegate clients, and expose the policy through the operator CRD.
## Delegation policy
An external issuer must opt in before ToolHive will accept its `may_act` claims:
```yaml
trustedIssuers:
- issuerUrl: https://idp.example.com
expectedAudience: https://toolhive.example.com
allowedDelegateClients: [agent-a]
allowMayAct: true
```
`allowMayAct` defaults to false. The wildcard configuration is rejected when `may_act` is enabled:
```yaml
allowMayAct: true
allowedDelegateClients: ["*"] # rejected
```
This keeps a foreign issuer from authorizing every ToolHive client merely by emitting an attacker-influenced `may_act` claim.
## Token shape
For an external subject token:
```json
{
"iss": "https://idp.example.com",
"sub": "alice-42",
"may_act": {
"iss": "https://toolhive.example.com",
"sub": "agent-a"
}
}
```
ToolHive issues a token whose external subject is issuer-qualified and whose `act` chain records both namespaces:
```json
{
"sub": "https://idp.example.com#alice-42",
"client_id": "agent-a",
"act": {
"iss": "https://toolhive.example.com",
"sub": "agent-a",
"act": {
"iss": "https://idp.example.com"
}
}
}
```
When re-exchanging a delegated token, rebuild prior `act` hops from only `iss` and `sub`, bound the resulting serialized claim, and reject malformed or over-deep chains rather than re-signing untrusted nested attributes.
Validate trusted-issuer policy during reconciliation so invalid configuration becomes a terminal CR status condition instead of an auth-server crash loop. Add unit, integration, and operator E2E coverage for both the opt-in gate and the operator projection.
Closesstacklok#6323Closesstacklok#6113
@jhrozek
jhrozekforce-pushed the 6323-6113-mayact-hardening branch from 1655de1 to 50e640fCompareAugust 18, 2026 10:30
The rule failed to compile, so the CRDs could not install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JAORMX
JAORMX previously approved these changes Aug 18, 2026
gofmt rewrites a doubled apostrophe in a comment into a curly quote, so
the empty-string comparison could not survive formatting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhrozek
jhrozek merged commit c51d154 into stacklok:mainAug 18, 2026
65 of 67 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 18, 2026
2 tasks
github-actionsBot added a commit to stacklok/docs-website that referenced this pull request Aug 24, 2026
Add an "Accept subject tokens from external issuers" section to
both the vMCP and Kubernetes embedded auth server guides, covering
the new `trustedIssuers` CRD field shipped in toolhive v0.44.0
(stacklok/toolhive#6353) that lets the RFC 8693 token-exchange
grant accept subject tokens minted by an external OIDC issuer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jhrozek pushed a commit to stacklok/docs-website that referenced this pull request Aug 25, 2026
Add an "Accept subject tokens from external issuers" section to
both the vMCP and Kubernetes embedded auth server guides, covering
the new `trustedIssuers` CRD field shipped in toolhive v0.44.0
(stacklok/toolhive#6353) that lets the RFC 8693 token-exchange
grant accept subject tokens minted by an external OIDC issuer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jerm-dro pushed a commit to stacklok/docs-website that referenced this pull request Aug 25, 2026
* Update stacklok/toolhive to v0.44.0
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* Refresh reference assets for toolhive v0.44.0
* Document trustedIssuers for embedded auth server
Add an "Accept subject tokens from external issuers" section to
both the vMCP and Kubernetes embedded auth server guides, covering
the new `trustedIssuers` CRD field shipped in toolhive v0.44.0
(stacklok/toolhive#6353) that lets the RFC 8693 token-exchange
grant accept subject tokens minted by an external OIDC issuer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Apply prettier and eslint fixups to skill output
* Trim duplicated delegation guidance
Replace the hand-written trustedIssuers sections with pointers to the dedicated token exchange guide. Keep generated CRD references as the single source for field details and avoid duplicating the conceptual delegation guidance.
---------
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jakub Hrozek <jakub@stacklok.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@jhrozek@JAORMX