diff --git a/.claude/agents/oauth-expert.md b/.claude/agents/oauth-expert.md index 9d8cc047b0..08bc5e1692 100644 --- a/.claude/agents/oauth-expert.md +++ b/.claude/agents/oauth-expert.md @@ -46,7 +46,7 @@ Before providing guidance on OAuth/OIDC details, use WebFetch to verify RFC or s - `pkg/auth/token.go`: JWT parsing, validation, claims extraction - `pkg/auth/middleware.go`: HTTP authentication middleware - `pkg/auth/oauth/`: OAuth 2.0 and OIDC client implementations -- `pkg/auth/tokenexchange/`: RFC 8693 token exchange +- `pkg/oauthproto/tokenexchange/`: RFC 8693 token exchange - `pkg/auth/discovery/`: OAuth/OIDC discovery, RFC 9728 support - `pkg/authserver/`: OAuth2 authorization server (Ory Fosite, PKCE, JWT/JWKS) diff --git a/.claude/rules/operator.md b/.claude/rules/operator.md index 40946e966c..c805b8036f 100644 --- a/.claude/rules/operator.md +++ b/.claude/rules/operator.md @@ -36,15 +36,27 @@ See `cmd/thv-operator/DESIGN.md` for detailed decision guidelines. - Always run `task operator-generate` after modifying CRD types - Always run `task operator-manifests` after adding kubebuilder markers -- Always run `task crdref-gen` from `cmd/thv-operator/` after CRD changes to regenerate API docs (uses relative paths) +- Always run `task crdref-gen` from the repo root after CRD changes to regenerate API docs (running it from `cmd/thv-operator/` fails — the task resolves the config path relative to the repo root) - Use `envtest` for integration testing, not real clusters - Chainsaw tests require a real Kubernetes cluster -- Status updates require a separate client patch (`r.Status().Update()`) +- Status writes must go through `controllerutil.MutateAndPatchStatus` — see the Status Writes section below ## Status Condition Parity When adding a status condition to one CRD type, check all parallel types (e.g., `MCPServer` and `VirtualMCPServer`) for the same condition. Conditions that warn about misconfiguration or unsupported states should be consistent across types that share the same feature set — a gap means one type silently accepts invalid config that the other rejects. +## Status Writes + +Use `controllerutil.MutateAndPatchStatus` for every status write — not `r.Status().Update` or inline `client.Status().Patch` (see #4633). The helper's doc comment is the authoritative spec. + +When adding a status-write call site, check three things: + +1. **Caller holds a freshly-`Get`ted object.** Reconciler-start writers do; writers that iterate `List` results (e.g., deletion-path fan-out in `MCPGroupReconciler`) do not and need a fresh `Get` before calling the helper. +2. **Caller is the sole owner of the entire `Status.Conditions` array.** Per-condition-type ownership is NOT enough. JSON merge-patch replaces the array wholesale for CRDs (the `+listType=map` marker is only honored by strategic-merge-patch), so any concurrent writer whose Patch lands between this caller's Get and Patch — on any condition type, not just the ones this caller touches — will be erased. A fresh `Get` narrows the TOCTOU window but does not eliminate it. If two code paths must write conditions on the same CRD (e.g., operator reconciler + in-pod `K8sReporter`), fix at the design level: consolidate to a single owner, or move one writer to a dedicated status field outside the array. +3. **Scalar fields the writer touches are not co-owned.** A stale-computed value different from the caller's snapshot will overwrite the live value — the helper cannot defend against this. + +Do not use `MutateAndPatchStatus` for spec or metadata writes — those require optimistic locking (`client.MergeFromWithOptions(..., MergeFromWithOptimisticLock{})`). See #4767. + ## Key Operator Commands ```bash @@ -53,5 +65,41 @@ task operator-generate # Generate deepcopy, client code task operator-manifests # Generate CRD YAML, RBAC task operator-test # Run unit tests task operator-e2e-test # Run e2e tests -task crdref-gen # Generate CRD API docs (run from cmd/thv-operator/) +task crdref-gen # Generate CRD API docs (run from the repo root) ``` + +## Spec / metadata patching + +Never use `r.Update` on a CR spec or metadata: `Update` is a full PUT, +so any field our local copy does not track (e.g. `spec.authzConfig` +written by a separate authorization controller) gets zeroed on every +reconcile. + +Use `controllerutil.MutateAndPatchSpec` instead. The helper wraps an +optimistic-lock merge patch: the body only contains fields the caller +changed, and `MergeFromWithOptimisticLock` sends `resourceVersion` as a +precondition, so if the server moved between our Get and Patch the +apiserver returns 409 and controller-runtime requeues with a fresh Get. + +This is what protects `metadata.finalizers`. Merge-patch has no +array-append semantics — arrays are replaced wholesale — so when our +diff includes `finalizers` (e.g. an `AddFinalizer` call) it must have +been computed from an up-to-date snapshot. The 409 + requeue is what +guarantees that: any concurrent finalizer added by another controller +fails our precondition, and the next reconcile observes it via a fresh +Get before recomputing the diff. + +```go +if err := ctrlutil.MutateAndPatchSpec(ctx, r.Client, mcpServer, func(m *mcpv1beta1.MCPServer) { + controllerutil.AddFinalizer(m, MCPServerFinalizerName) +}); err != nil { + return ctrl.Result{}, err +} +``` + +Expect 409s as routine log noise once the external controller lands — +the guard doing its job, not a bug. + +Status-subresource patching uses the sibling helper +`controllerutil.MutateAndPatchStatus` (see the "Status Writes" section +above). diff --git a/.claude/skills/check-contribution/SKILL.md b/.claude/skills/check-contribution/SKILL.md index b138c8653c..b36b181564 100644 --- a/.claude/skills/check-contribution/SKILL.md +++ b/.claude/skills/check-contribution/SKILL.md @@ -1,6 +1,6 @@ --- name: check-contribution -description: Validates operator chart contribution practices (helm template, ct lint, docs generation, version bump) before committing changes. +description: Validates operator chart contribution practices (helm template, ct lint, docs generation) before committing changes. allowed-tools: [Bash, Read] --- @@ -28,19 +28,14 @@ helm-docs --dry-run ``` Verify that `values.yaml` variables are documented and the generated README.md matches. -### 4. Chart Version Bump -If chart files changed, verify: -- `deploy/charts/operator/Chart.yaml` version is bumped for operator changes -- `deploy/charts/operator-crds/Chart.yaml` version is bumped for CRD changes -- Version follows [SemVer](https://semver.org/) and bump type matches the change scope - ## Output Format ``` ✅ or ❌ Helm template renders successfully ✅ or ❌ Chart linting passes ✅ or ❌ Documentation up-to-date -✅ or ❌ Chart version bumped appropriately + +Note: Chart version bumps are automated by the release bot. Feature PRs should NOT modify Chart.yaml. ``` Include specific errors for any failing checks with actionable remediation commands. diff --git a/.claude/skills/pr-review/EXAMPLES-INLINE.md b/.claude/skills/pr-review/EXAMPLES-INLINE.md deleted file mode 100644 index 5198c3651f..0000000000 --- a/.claude/skills/pr-review/EXAMPLES-INLINE.md +++ /dev/null @@ -1,231 +0,0 @@ -# PR Inline Review Examples - -Common use cases and examples for submitting PR reviews with inline comments. - -## Example 1: Simple Inline Review (No Suggestions) - -**Use case**: Pointing out issues that require discussion or complex fixes - -**Command:** -```bash -gh api -X POST repos/stacklok/toolhive/pulls/2165/reviews --input /tmp/pr-review-comments.json -``` - -**JSON:** -```json -{ - "body": "Found several architectural concerns that need discussion", - "event": "COMMENT", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 605, - "body": "This diagram doesn't accurately reflect the actual architecture. The Workload struct only contains metadata, not direct references to Runtime and Transport. These relationships are managed by WorkloadManager and Runner.\n\nWe should discuss how to simplify this while keeping it accurate.\n\nEvidence: pkg/core/workload.go, pkg/workloads/manager.go" - }, - { - "path": "pkg/runner/config.go", - "line": 136, - "body": "The documentation mentions only 8 fields but RunConfig has 39 serializable fields. Should we document all of them or create a categorized reference?\n\nEvidence: pkg/runner/config.go:32-157" - } - ] -} -``` - -**When to use:** -- Issues require discussion or design decisions -- Changes are too complex for inline suggestions -- Multiple files need coordinated changes -- User needs to provide context or make choices - ---- - -## Example 2: Quick Fixes with Suggestions - -**Use case**: Simple corrections that can be committed directly - -**JSON:** -```json -{ - "body": "Documentation corrections with suggested fixes", - "event": "COMMENT", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 238, - "body": "File path reference is incorrect: `pkg/registry/registry.go` does not exist.\n\n```suggestion\n- Registry manager: `pkg/registry/provider.go`\n```\n\nThe registry functionality is split across multiple files in `pkg/registry/`.\n\nEvidence: Verified via codebase exploration" - }, - { - "path": "docs/arch/02-core-concepts.md", - "line": 597, - "body": "File path is incorrect.\n\n```suggestion\n- Health checker: `pkg/healthcheck/healthcheck.go`\n```\n\nEvidence: Verified via codebase exploration" - }, - { - "path": "docs/arch/02-core-concepts.md", - "line": 127, - "body": "Middleware type name is incorrect. The code uses `authorization`, not `authz`.\n\n```suggestion\n7. **Authorization** (`authorization`) - Cedar policy evaluation\n```\n\nEvidence: pkg/authz/middleware.go:211" - } - ] -} -``` - -**When to use:** -- Typos or incorrect file paths -- Simple one-line corrections -- Version numbers or constants -- Formatting fixes - ---- - -## Example 3: Mixed Review (Some with Suggestions, Some Without) - -**Use case**: Combination of quick fixes and items needing discussion - -**JSON:** -```json -{ - "body": "Documentation review: found quick fixes and items for discussion", - "event": "COMMENT", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 329, - "body": "Command examples are incorrect:\n\n```suggestion\n- `thv client list-registered` - List all registered clients\n- `thv client setup` - Interactively setup clients\n- `thv client status` - Show installation status\n- `thv client register ` - Register a specific client\n- `thv client remove ` - Remove a client\n```\n\nEvidence: cmd/thv/app/client.go:36-41" - }, - { - "path": "docs/arch/02-core-concepts.md", - "line": 136, - "body": "The key fields list is incomplete. RunConfig has 39 serializable fields, but only 8 are listed here.\n\nNotable missing fields include: `name`, `cmdArgs`, `secrets`, `oidcConfig`, `authzConfig`, `auditConfig`, `telemetryConfig`, `group`, `toolsFilter`, `toolsOverride`, `isolateNetwork`, `proxyMode`, and many others.\n\nShould we either:\n1. Categorize fields by purpose (Identity, Security, Middleware, etc.), or\n2. Add a reference to the complete list in `05-runconfig-and-permissions.md`?\n\nEvidence: pkg/runner/config.go:32-157" - }, - { - "path": "docs/arch/02-core-concepts.md", - "line": 627, - "body": "The request flow diagram is incomplete. It shows only 4 middleware types but there are 8 middleware types defined in the codebase.\n\nMissing middleware: Token Exchange, Tool Filter, Tool Call Filter, and Telemetry.\n\nComplete flow should include:\n`Auth → [Token Exchange] → [Tool Filter] → [Tool Call Filter] → Parser → [Telemetry] → [Authorization] → [Audit] → Container`\n\n(Brackets indicate conditional middleware that are only present if configured)\n\nEvidence: pkg/runner/middleware.go:16-27" - } - ] -} -``` - -**When to use:** -- Mix of simple and complex issues -- Some items have clear fixes, others need discussion -- Want to provide suggestions where possible but leave complex items open - ---- - -## Example 4: Multi-line Suggestion - -**Use case**: Fixing multiple lines or a larger code block - -**JSON:** -```json -{ - "body": "Correcting middleware list with complete and accurate information", - "event": "COMMENT", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 110, - "body": "The middleware list should include all 8 types with the correct name for Authorization:\n\n```suggestion\n**Eight middleware types:**\n\n1. **Authentication** (`auth`) - JWT token validation\n2. **Token Exchange** (`tokenexchange`) - OAuth token exchange\n3. **MCP Parser** (`mcp-parser`) - JSON-RPC parsing\n4. **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses\n5. **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests\n6. **Telemetry** (`telemetry`) - OpenTelemetry instrumentation\n7. **Authorization** (`authorization`) - Cedar policy evaluation\n8. **Audit** (`audit`) - Request logging\n```\n\nEvidence: pkg/runner/middleware.go:16-27, pkg/authz/middleware.go:211" - } - ] -} -``` - -**When to use:** -- Correcting lists or tables -- Updating code blocks -- Fixing multiple related lines together -- Ensuring consistent formatting across lines - ---- - -## Example 5: Request Changes (Blocking Review) - -**Use case**: Critical issues that must be fixed before merge - -**JSON:** -```json -{ - "body": "Critical inaccuracies found in documentation that must be corrected before merge", - "event": "REQUEST_CHANGES", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 238, - "body": "**CRITICAL**: This file path does not exist and will break documentation links.\n\n```suggestion\n- Registry manager: `pkg/registry/provider.go`\n```\n\nEvidence: Verified via codebase exploration" - }, - { - "path": "docs/arch/02-core-concepts.md", - "line": 329, - "body": "**CRITICAL**: These commands don't exist and users will get errors if they try to use them.\n\n```suggestion\n- `thv client list-registered` - List all registered clients\n- `thv client setup` - Interactively setup clients\n- `thv client status` - Show installation status\n```\n\nEvidence: cmd/thv/app/client.go:36-41" - } - ] -} -``` - -**When to use:** -- Critical bugs or security issues -- Documentation that will mislead users -- Breaking changes without proper migration -- Must be fixed before merge - ---- - -## Example 6: Approval with Minor Suggestions - -**Use case**: Approving PR but offering optional improvements - -**JSON:** -```json -{ - "body": "LGTM! Just a few minor suggestions for improvement.", - "event": "APPROVE", - "comments": [ - { - "path": "docs/arch/02-core-concepts.md", - "line": 597, - "body": "Minor: This file path could be more accurate.\n\n```suggestion\n- Health checker: `pkg/healthcheck/healthcheck.go`\n```\n\n(Not blocking - can be fixed in a follow-up if preferred)\n\nEvidence: Verified via codebase exploration" - } - ] -} -``` - -**When to use:** -- PR is generally good, minor improvements available -- Non-blocking suggestions for quality improvements -- Optional refactoring or cleanup suggestions -- Style or consistency improvements - ---- - -## Tips for Each Scenario - -### For Simple Reviews (No Suggestions) -- Focus on clear problem descriptions -- Ask questions when context is needed -- Provide references to relevant code -- Suggest next steps or alternatives - -### For Reviews with Suggestions -- Always read the current content first -- Match the existing formatting exactly -- Test the suggestion if possible -- Keep suggestions focused and minimal - -### For Mixed Reviews -- Put suggestions first (quick wins) -- Group related comments together -- Use clear markdown formatting -- Distinguish between blocking and non-blocking issues - -### For Blocking Reviews -- Use `REQUEST_CHANGES` event -- Mark critical items clearly (e.g., **CRITICAL**) -- Provide suggestions where possible for faster resolution -- Explain impact of not fixing the issue - -### For Approvals -- Use `APPROVE` event -- Mark suggestions as optional/non-blocking -- Acknowledge good work in the summary -- Keep suggestions truly minor/optional diff --git a/.claude/skills/pr-review/EXAMPLES-REPLY.md b/.claude/skills/pr-review/EXAMPLES-REPLY.md deleted file mode 100644 index d5af8bb4d6..0000000000 --- a/.claude/skills/pr-review/EXAMPLES-REPLY.md +++ /dev/null @@ -1,400 +0,0 @@ -# PR Review Reply Examples - -Common scenarios with actual commands for replying to and resolving GitHub PR review comments. - -## Example 1: Simple "Fixed in Commit" Reply - -**Scenario:** Copilot suggested fixing nolint comment spacing. You fixed it in commit c4bb55d. - -### Step 1: Get the comment ID - -```bash -gh api repos/stacklok/toolhive-registry-server/pulls/20/comments | jq '.[] | {id, path, line, body: .body[0:100], author: .user.login}' -``` - -**Output:** -```json -{ - "id": 2445150488, - "path": "pkg/versions/version.go", - "line": 24, - "body": "Corrected spacing in nolint comment...", - "author": "copilot-pull-request-reviewer" -} -``` - -### Step 2: Reply to the comment - -```bash -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445150488/replies \ - -f body="Fixed in c4bb55d" -``` - -### Step 3: Get the thread ID - -```bash -gh api graphql -f query=' -query { - repository(owner: "stacklok", name: "toolhive-registry-server") { - pullRequest(number: 20) { - reviewThreads(first: 20) { - nodes { - id - isResolved - comments(first: 5) { - nodes { - id - body - author { login } - } - } - } - } - } - } -}' | jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.comments.nodes[0].id == 2445150488) | {threadId: .id, isResolved}' -``` - -**Output:** -```json -{ - "threadId": "PRRT_kwDOP_5nS85emMpx", - "isResolved": false -} -``` - -### Step 4: Resolve the thread - -```bash -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "PRRT_kwDOP_5nS85emMpx"}) { - thread { - id - isResolved - } - } -}' -``` - -**Output:** -```json -{ - "data": { - "resolveReviewThread": { - "thread": { - "id": "PRRT_kwDOP_5nS85emMpx", - "isResolved": true - } - } - } -} -``` - ---- - -## Example 2: Batch Processing Multiple Fixed Comments - -**Scenario:** Multiple comments fixed in the same commit. Process them all at once. - -### Step 1: Get all unresolved comments - -```bash -gh api graphql -f query=' -query { - repository(owner: "stacklok", name: "toolhive-registry-server") { - pullRequest(number: 20) { - reviewThreads(first: 20) { - nodes { - id - isResolved - comments(first: 10) { - nodes { - id - path - line - body - author { login } - } - } - } - } - } - } -}' | jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)' -``` - -### Step 2: Present to user for approval - -``` -Found 2 unresolved threads fixed in commit c4bb55d: - -1. pkg/versions/version.go:24 - "Fix nolint spacing" -2. cmd/thv-registry-api/app/commands.go:53 - "Handle GetString error" - -Reply "Fixed in c4bb55d" to both and resolve? (y/n) -``` - -### Step 3: Reply to each comment (if user approves) - -```bash -# Reply to first comment -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445150488/replies \ - -f body="Fixed in c4bb55d" - -# Reply to second comment -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445150511/replies \ - -f body="Fixed in c4bb55d" -``` - -### Step 4: Resolve both threads - -```bash -# Resolve first thread -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "PRRT_kwDOP_5nS85emMpx"}) { - thread { id isResolved } - } -}' - -# Resolve second thread -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "PRRT_kwDOP_5nS85emMqG"}) { - thread { id isResolved } - } -}' -``` - ---- - -## Example 3: Answering a Question (Don't Auto-Resolve) - -**Scenario:** Reviewer asks why you chose a specific approach. Answer but wait for acknowledgment. - -### Step 1: Draft response for user approval - -``` -Reviewer asked: "Why use buffered channel here?" - -Draft response: -"Good question! The buffered channel prevents blocking when the producer -is faster than the consumer. In our case, the metrics collector can -generate events faster than the writer can persist them, so the buffer -(size 100) acts as a temporary queue to smooth out the bursts. - -Alternative would be unbuffered channel, but that would slow down the -collector. I've added a comment in the code to explain this trade-off." - -Send this response? (y/n/edit) -``` - -### Step 2: Send reply (after user approval) - -```bash -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445160000/replies \ - -f body="Good question! The buffered channel prevents blocking when the producer is faster than the consumer. In our case, the metrics collector can generate events faster than the writer can persist them, so the buffer (size 100) acts as a temporary queue to smooth out the bursts. - -Alternative would be unbuffered channel, but that would slow down the collector. I've added a comment in the code to explain this trade-off." -``` - -### Step 3: Don't resolve yet - -**Important:** Leave the thread unresolved until the reviewer acknowledges or approves your answer. - ---- - -## Example 4: Disagree Respectfully - -**Scenario:** Reviewer suggests a change you don't think is needed. Explain your reasoning. - -### Step 1: Draft respectful disagreement - -``` -Reviewer suggested: "Extract this into a separate function" - -Draft response: -"I considered this, but decided against it for a few reasons: - -1. The logic is only used once in this specific context -2. Extracting would require passing 4 parameters -3. The current form keeps the error handling localized - -However, if you feel strongly about it, I'm happy to refactor! Let me -know your thoughts. - -Related discussion: https://github.com/org/repo/discussions/123" - -Send this response? (y/n/edit) -``` - -### Step 2: Send and wait for discussion - -Don't resolve - this is now a discussion thread. Resolve only after reaching agreement. - ---- - -## Example 5: Already Fixed in Earlier Commit - -**Scenario:** Reviewer comments on something already fixed before the review was submitted. - -### Response: - -```bash -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445170000/replies \ - -f body="Good catch! This was actually already fixed in an earlier commit (ab956b8) before this review. The updated code now handles this case correctly. - -See: https://github.com/stacklok/toolhive-registry-server/commit/ab956b8#diff-abc123" -``` - -Then resolve immediately since it's already addressed. - ---- - -## Example 6: Need More Context - -**Scenario:** Review comment isn't clear. Ask for clarification. - -### Response: - -```bash -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445180000/replies \ - -f body="Thanks for the feedback! Could you clarify what you mean by 'handle the edge case'? - -Are you referring to: -- When the input is nil? -- When the slice is empty? -- When the index is out of bounds? - -Once I understand which case you're concerned about, I'll make sure it's properly handled." -``` - -Leave unresolved until clarified and fixed. - ---- - -## Example 7: Acknowledge Non-Blocking Suggestion - -**Scenario:** Reviewer made an optional suggestion you won't implement right now. - -### Response: - -```bash -gh api -X POST repos/stacklok/toolhive-registry-server/pulls/20/comments/2445190000/replies \ - -f body="Great suggestion! I agree this would be a nice improvement. - -For this PR, I'd like to keep the scope focused on the immediate fix, but I've created issue #456 to track this enhancement for a future PR. - -Thanks for the idea!" -``` - -Resolve after user approves (since you've addressed it by creating an issue). - ---- - -## Command Reference - -### Get all PR comments with details -```bash -gh api repos/{owner}/{repo}/pulls/{pr}/comments | \ - jq '.[] | {id, path, line, author: .user.login, body: .body[0:100]}' -``` - -### Reply to a specific comment -```bash -gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \ - -f body="Your reply message" -``` - -### Get all review threads (to find thread IDs) -```bash -gh api graphql -f query=' -query { - repository(owner: "{owner}", name: "{repo}") { - pullRequest(number: {pr}) { - reviewThreads(first: 20) { - nodes { - id - isResolved - comments(first: 10) { - nodes { - id - body - author { login } - } - } - } - } - } - } -}' -``` - -### Find thread ID for a specific comment -```bash -gh api graphql -f query='...' | \ - jq '.data.repository.pullRequest.reviewThreads.nodes[] | - select(.comments.nodes[0].id == COMMENT_ID) | - {threadId: .id, isResolved}' -``` - -### Resolve a thread -```bash -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "{thread_id}"}) { - thread { - id - isResolved - } - } -}' -``` - -### Unresolve a thread (if needed) -```bash -gh api graphql -f query=' -mutation { - unresolveReviewThread(input: {threadId: "{thread_id}"}) { - thread { - id - isResolved - } - } -}' -``` - ---- - -## Tips for Each Scenario - -### For "Fixed in Commit" Responses -- Include the short SHA (first 7 chars) -- Optionally link to the commit or diff -- Resolve immediately after replying -- Batch process multiple if same commit - -### For Questions -- Draft answer first, get user approval -- Be thorough but concise -- Include links to relevant docs/code -- Don't auto-resolve - wait for acknowledgment - -### For Disagreements -- Be respectful and explain reasoning -- Offer alternatives or compromise -- Link to relevant discussions or standards -- Never resolve - let discussion conclude naturally - -### For Clarifications -- Ask specific questions -- Offer multiple interpretations -- Be open to learning -- Resolve only after understanding and fixing - -### For Optional Suggestions -- Acknowledge the value -- Explain if deferring (create issue) -- Thank the reviewer -- Can resolve if properly acknowledged diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md deleted file mode 100644 index b25a56e425..0000000000 --- a/.claude/skills/pr-review/SKILL.md +++ /dev/null @@ -1,335 +0,0 @@ ---- -name: pr-review -description: Submit inline review comments to GitHub PRs and reply to/resolve review threads using the GitHub CLI and GraphQL API. ---- - -# PR Review - -Submit inline review comments to GitHub Pull Requests and reply to/resolve review threads using the GitHub CLI. - -## Prerequisites - -- GitHub CLI (`gh`) must be installed and authenticated -- User must have write access to the repository -- PR must exist and be open - ---- - -## Part 1: Submitting Inline Review Comments - -### Workflow - -1. **Collect findings**: The user will provide you with: - - Repository owner and name (or detect from current directory) - - PR number - - A list of findings, each containing: - - File path (relative to repo root) - - Line number - - Comment body/description - - (Optional) Suggested fix if it's a simple change - -2. **Read current content**: If providing suggestions, use the Read tool to see the exact current content - -3. **Create review JSON**: Build a JSON structure at `/tmp/pr-review-comments.json`: - ```json - { - "body": "Overall review summary", - "event": "COMMENT", - "comments": [ - { - "path": "path/to/file.ext", - "line": 123, - "body": "Comment text with optional suggestion" - } - ] - } - ``` - -4. **Submit review**: Use GitHub CLI: - ```bash - gh api -X POST repos/{owner}/{repo}/pulls/{pr_number}/reviews --input /tmp/pr-review-comments.json - ``` - -5. **Return URL**: Extract and return the review URL from the response - -### JSON Structure - -#### Top-level fields - -- `body` (required): Overall review summary -- `event` (required): `"COMMENT"`, `"APPROVE"`, or `"REQUEST_CHANGES"` -- `comments` (required): Array of comment objects - -#### Comment object fields - -- `path` (required): File path relative to repository root -- `line` (required): Line number (positive integer) -- `body` (required): Comment text (supports markdown) - -### Inline Code Suggestions - -GitHub supports inline code suggestions that users can commit directly from the PR UI. - -#### When to Use Suggestions - -**Good candidates:** -- Fixing typos or incorrect file paths -- Correcting simple syntax errors -- Updating version numbers or constants -- Renaming variables or functions -- Fixing formatting or indentation -- Adding missing content - -**Not suitable:** -- Complex logic changes requiring multiple files -- Changes that need testing or validation -- Architectural changes requiring discussion -- Changes requiring user decision/context - -#### Suggestion Syntax - -**Single-line:** -````markdown -Description of the issue. - -```suggestion -corrected line of code -``` - -Evidence: reference -```` - -**Multi-line:** -````markdown -Description of the issue. - -```suggestion -first corrected line -second corrected line -third corrected line -``` - -Evidence: reference -```` - -### Submitting Best Practices - -- Be specific with line numbers and file paths -- Provide evidence (link to code/documentation) -- Be constructive - suggest fixes, not just problems -- Use markdown formatting for clarity -- Include context explaining why it's an issue - -#### When Including Suggestions -1. Read the current line(s) using Read tool first -2. Provide exact replacement text -3. Match existing formatting and style -4. Verify syntax is correct -5. One suggestion block per comment - -#### Review Strategy -1. Group related findings into a single review -2. Put simple fixes with suggestions first -3. Use appropriate event type -4. Write clear summary in `body` - -### Output Format - -Report after submission: -- Review ID and URL -- Number of comments submitted -- Number with suggestions -- PR title and number - ---- - -## Part 2: Replying to and Resolving Review Comments - -### Workflow - -#### 1. Gather Review Comments - -Fetch all review comments from the PR and present them organized by: -- Status: unresolved vs resolved -- Type: suggestions, questions, nitpicks, critical issues -- Author: group by reviewer - -**For each comment show:** -- Author and timestamp -- File and line number -- Comment body -- Any existing replies -- Resolution status - -#### 2. Analyze and Recommend - -For each unresolved comment, provide a recommendation: - -**If code needs fixing:** -- "Recommendation: Fix the issue, then reply with commit SHA and resolve" - -**If it's a question:** -- "Recommendation: Answer the question, wait for acknowledgment before resolving" - -**If it's a suggestion to consider:** -- "Recommendation: Discuss trade-offs, decide with user whether to implement" - -**If already addressed:** -- "Recommendation: Reply with commit reference and resolve immediately" - -#### 3. Get User Decisions - -**Present summary:** -``` -Found 5 unresolved review comments: - -1. [Critical] pkg/versions/version.go:24 - @Copilot - "Fix nolint spacing" - Status: Fixed in commit c4bb55d - Recommendation: Reply "Fixed in c4bb55d" and resolve - -2. [Question] pkg/server/handler.go:45 - @reviewer - "Why use buffered channel here?" - Status: Needs answer - Recommendation: Draft response for your review - -How would you like to proceed? -- Reply and resolve all fixed items (1) -- Draft responses for questions (2) -- Process individually -- Custom approach -``` - -#### 4. Execute User's Choice - -Based on user decisions: -- Draft reply messages for approval -- Submit replies after user confirms -- Resolve threads only when user approves - -#### 5. Report Results - -After processing, show: -- What was done (replied/resolved) -- What remains (still needs attention) -- Any errors or issues -- Next steps if any - -### Interactive Decision Points - -#### Before Replying -**Ask:** "Here's my draft reply: '{message}'. Send this?" -- User can edit, approve, or skip - -#### Before Resolving -**Ask:** "Mark this thread as resolved?" -- Only if issue is truly addressed -- User may want to wait for reviewer acknowledgment - -#### For Bulk Operations -**Ask:** "I found 5 comments fixed in commit abc123. Reply 'Fixed in abc123' to all and resolve?" -- Show list of affected comments -- Let user review before executing - -### Reply Best Practices - -- **Be specific**: Reference commit SHAs when applicable -- **Be helpful**: Explain reasoning, not just "fixed" -- **Be respectful**: Thank reviewers for feedback -- **Use markdown**: Format code, lists, links - -### When to Resolve -**Resolve when:** -- Issue is fixed and committed -- Question answered and acknowledged -- Discussion concluded with agreement -- User confirms it's complete - -**Don't auto-resolve:** -- Without user confirmation -- When still discussing -- When waiting for reviewer response -- When unsure about the fix - ---- - -## Command Reference - -### Submit a review -```bash -gh api -X POST repos/{owner}/{repo}/pulls/{pr}/reviews --input /tmp/pr-review-comments.json -``` - -### Get all PR comments with details -```bash -gh api repos/{owner}/{repo}/pulls/{pr}/comments | \ - jq '.[] | {id, path, line, author: .user.login, body: .body[0:100]}' -``` - -### Reply to a specific comment -```bash -gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \ - -f body="Your reply message" -``` - -### Get all review threads (to find thread IDs) -```bash -gh api graphql -f query=' -query { - repository(owner: "{owner}", name: "{repo}") { - pullRequest(number: {pr}) { - reviewThreads(first: 20) { - nodes { - id - isResolved - comments(first: 10) { - nodes { - id - body - author { login } - } - } - } - } - } - } -}' -``` - -### Resolve a thread -```bash -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "{thread_id}"}) { - thread { - id - isResolved - } - } -}' -``` - -### Unresolve a thread -```bash -gh api graphql -f query=' -mutation { - unresolveReviewThread(input: {threadId: "{thread_id}"}) { - thread { - id - isResolved - } - } -}' -``` - -## Error Handling - -- **401 Unauthorized**: Run `gh auth login` -- **404 Not Found**: Verify PR number and repo access -- **422 Unprocessable Entity**: Check JSON format -- **Invalid line number**: Ensure line exists at PR's commit - -## See Also - -- [Inline Review Examples](EXAMPLES-INLINE.md) - Examples of submitting review comments -- [Reply Examples](EXAMPLES-REPLY.md) - Examples of replying to and resolving review comments diff --git a/.claude/skills/toolhive-release/references/WORKFLOW-REFERENCE.md b/.claude/skills/toolhive-release/references/WORKFLOW-REFERENCE.md index f5ff577d74..2821835ed1 100644 --- a/.claude/skills/toolhive-release/references/WORKFLOW-REFERENCE.md +++ b/.claude/skills/toolhive-release/references/WORKFLOW-REFERENCE.md @@ -68,11 +68,13 @@ Detailed documentation of the ToolHive release workflow chain. - Version in commit message matches VERSION file 3. Check if tag already exists (skip if so) 4. Create annotated git tag `vX.Y.Z` -5. Push tag using `RELEASE_TOKEN` (PAT required to trigger downstream workflows) +5. Push tag using a GitHub App installation token (required to trigger downstream workflows; `GITHUB_TOKEN`-authored events do not) 6. Create GitHub Release with auto-generated notes **Requirements**: -- `RELEASE_TOKEN` secret (PAT with repo access) +- GitHub App installed on the repo with `contents: write` permission +- `RELEASE_APP_CLIENT_ID` repository **variable** (the app's Client ID) +- `RELEASE_APP_PRIVATE_KEY` repository **secret** (the app's private key in PEM) ## Workflow 3: releaser.yml @@ -162,7 +164,8 @@ If a previous Create Release PR run failed after creating the branch but before ### Tag creation fails - Tag may already exist: `git tag | grep vX.Y.Z` -- RELEASE_TOKEN may be expired or lack permissions +- Release GitHub App may be uninstalled, or the `RELEASE_APP_CLIENT_ID` variable / `RELEASE_APP_PRIVATE_KEY` secret may be missing or stale +- App may lack `contents: write` permission on the repo ### Releaser workflow fails diff --git a/.codespellrc b/.codespellrc index 29a423f9c6..b1d5725306 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] -ignore-words-list = NotIn,notin,AfterAll,ND,aks,deriver,te,clientA,AtMost,atmost +ignore-words-list = NotIn,notin,AfterAll,ND,aks,deriver,te,clientA,AtMost,atmost,convertIn skip = *.svg,*.mod,*.sum diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c360ce0061..098c10e520 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,29 +9,29 @@ CLAUDE.md @JAORMX @jhrozek @rdimitrov @jerm-dro .claude/rules/ @JAORMX @jhrozek @rdimitrov @jerm-dro # CLI (thv) -cmd/thv/ @JAORMX @yrobla @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek -cmd/help/ @JAORMX @yrobla @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek -docs/cli/ @JAORMX @yrobla @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek -test/e2e/ @JAORMX @yrobla @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek +cmd/thv/ @JAORMX @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek @reyortiz3 @aponcedeleonch +cmd/help/ @JAORMX @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek @reyortiz3 @aponcedeleonch +docs/cli/ @JAORMX @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek @reyortiz3 @aponcedeleonch +test/e2e/ @JAORMX @ChrisJBurns @amirejaz @lujunsan @rdimitrov @jhrozek @reyortiz3 @aponcedeleonch # HTTP API (ToolHive server) -pkg/api/ @JAORMX @amirejaz -docs/server/ @JAORMX @amirejaz +pkg/api/ @JAORMX @amirejaz @rdimitrov @reyortiz3 @aponcedeleonch +docs/server/ @JAORMX @amirejaz @rdimitrov @reyortiz3 @aponcedeleonch # Kubernetes (operator + proxyrunner + charts) -cmd/thv-operator/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -cmd/thv-proxyrunner/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -deploy/charts/operator/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -deploy/charts/operator-crds/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -config/webhook/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -test/e2e/chainsaw/operator/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -test/e2e/thv-operator/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek -docs/operator/ @ChrisJBurns @yrobla @JAORMX @jerm-dro @jhrozek +cmd/thv-operator/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +cmd/thv-proxyrunner/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +deploy/charts/operator/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +deploy/charts/operator-crds/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +config/webhook/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +test/e2e/chainsaw/operator/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +test/e2e/thv-operator/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt +docs/operator/ @ChrisJBurns @JAORMX @jerm-dro @jhrozek @tgrunnagle @rdimitrov @reyortiz3 @blkt # vMCP (Virtual MCP) -cmd/vmcp/ @JAORMX @yrobla @jhrozek @jerm-dro @amirejaz -pkg/vmcp/ @JAORMX @yrobla @jhrozek @jerm-dro @amirejaz -test/integration/vmcp/ @JAORMX @yrobla @jhrozek @jerm-dro @amirejaz +cmd/vmcp/ @JAORMX @jhrozek @jerm-dro @amirejaz @ChrisJBurns @tgrunnagle +pkg/vmcp/ @JAORMX @jhrozek @jerm-dro @amirejaz @ChrisJBurns @tgrunnagle +test/integration/vmcp/ @JAORMX @jhrozek @jerm-dro @amirejaz @ChrisJBurns @tgrunnagle # Core Runtime & Lifecycle pkg/workloads/ @JAORMX @amirejaz @lujunsan @@ -44,32 +44,32 @@ pkg/groups/ @JAORMX @amirejaz @lujunsan pkg/client/ @JAORMX @amirejaz @lujunsan # Infrastructure Abstractions -pkg/container/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla -pkg/transport/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla -pkg/mcp/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla -pkg/networking/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla -pkg/labels/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla -pkg/process/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @yrobla +pkg/container/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov +pkg/transport/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov +pkg/mcp/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov +pkg/networking/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov +pkg/labels/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov +pkg/process/ @JAORMX @jhrozek @blkt @amirejaz @ChrisJBurns @rdimitrov # Registry & Distribution -pkg/registry/ @JAORMX @rdimitrov -.github/workflows/update-registry.yml @JAORMX @rdimitrov +pkg/registry/ @JAORMX @rdimitrov @reyortiz3 +.github/workflows/update-registry.yml @JAORMX @rdimitrov @reyortiz3 # Security & Policy -pkg/auth/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/authz/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/oauth/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/authserver/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/secrets/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/permissions/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/container/verifier/ @jhrozek @JAORMX @ChrisJBurns @yrobla -pkg/audit/ @jhrozek @JAORMX @ChrisJBurns @yrobla +pkg/auth/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/authz/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/oauth/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/authserver/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/secrets/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/permissions/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/container/verifier/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov +pkg/audit/ @jhrozek @JAORMX @ChrisJBurns @tgrunnagle @rdimitrov # Observability -pkg/telemetry/ @ChrisJBurns @JAORMX @yrobla @jerm-dro -pkg/usagemetrics/ @ChrisJBurns @JAORMX @yrobla @jerm-dro -pkg/logger/ @ChrisJBurns @JAORMX @yrobla @jerm-dro -pkg/recovery/ @ChrisJBurns @JAORMX @yrobla @jerm-dro +pkg/telemetry/ @ChrisJBurns @JAORMX @jerm-dro +pkg/usagemetrics/ @ChrisJBurns @JAORMX @jerm-dro +pkg/logger/ @ChrisJBurns @JAORMX @jerm-dro +pkg/recovery/ @ChrisJBurns @JAORMX @jerm-dro # Architecture docs -docs/arch/ @JAORMX @amirejaz @yrobla @rdimitrov @ChrisJBurns @jhrozek \ No newline at end of file +docs/arch/ @JAORMX @amirejaz @rdimitrov @ChrisJBurns @jhrozek @tgrunnagle \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8fe497db20..8a69e2dc01 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -42,6 +42,25 @@ describe exactly what you tested below the checkbox. - [ ] Linting (`task lint-fix`) - [ ] Manual testing (describe below) +## 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 Docker[Docker/Podman
Colima
Rancher Desktop] + CLI --> Docker[Docker/Podman
Colima
Rancher Desktop
OrbStack] UI --> Docker Operator --> K8s[Kubernetes] @@ -31,7 +31,7 @@ graph LR | Feature | Local CLI | Local UI | Kubernetes | |---------|-----------|----------|------------| | **Binary** | `thv` | `thv` (API server) | `thv-operator` + `thv-proxyrunner` | -| **Container Runtime** | Docker/Podman/Colima/Rancher | Docker/Podman/Colima/Rancher | Kubernetes | +| **Container Runtime** | Docker/Podman/Colima/Rancher/OrbStack | Docker/Podman/Colima/Rancher/OrbStack | Kubernetes | | **Process Management** | Detached processes | API-managed | Operator-managed | | **State Storage** | Local filesystem | Local filesystem | etcd (K8s API) | | **Scaling** | Single machine | Single machine | Cluster-wide | @@ -45,7 +45,7 @@ graph LR graph TB User[User] -->|CLI Commands| THV[thv binary] THV -->|spawn detached| Proxy[Proxy Process] - Proxy -->|Docker API| Runtime[Container Runtime
Docker/Podman/Colima] + Proxy -->|Docker API| Runtime[Container Runtime
Docker/Podman/Colima
Rancher Desktop/OrbStack] Runtime -->|creates| Container[MCP Server Container] Proxy -->|stdin/stdout or HTTP| Container Client[MCP Client] -->|HTTP/SSE/Streamable| Proxy @@ -65,7 +65,7 @@ graph TB - Instantiates workloads API (`pkg/workloads/manager.go`) 3. **Workload Manager**: - - Detects available container runtime (Podman → Colima → Docker) + - Detects available container runtime (Podman → Docker → Colima) - Creates container via Runtime API - Spawns detached proxy process @@ -76,13 +76,14 @@ graph TB - Exposes local HTTP endpoint for MCP clients 5. **State Management**: - - RunConfig saved to `~/.toolhive/state/` (or XDG equivalent) - - PID file for process management - - Status file for workload state tracking + - RunConfig saved under `$XDG_STATE_HOME/toolhive/runconfigs/` + - PID file in `$XDG_DATA_HOME/toolhive/pids/` for process management + - Status file in `$XDG_DATA_HOME/toolhive/statuses/` for workload state tracking + - See the platform path table below for full XDG layout ### Container Runtime Selection -**Implementation**: `pkg/container/factory.go` +**Implementation**: `pkg/container/docker/sdk/factory.go` (with per-runtime socket discovery in `pkg/container/docker/sdk/client_unix.go`) The CLI automatically detects container runtimes in this order: @@ -93,17 +94,18 @@ The CLI automatically detects container runtimes in this order: - `~/.local/share/containers/podman/machine/podman.sock` (Podman Machine on macOS) - `$TMPDIR/podman/*-api.sock` (Podman Machine API on macOS) -2. **Colima** - Checks for Colima socket at: - - `$TOOLHIVE_COLIMA_SOCKET` (if set) - - `~/.colima/default/docker.sock` - -3. **Docker** (including Docker Desktop, Rancher Desktop, and OrbStack) - Checks for Docker socket at: +2. **Docker** (including Docker Desktop, Rancher Desktop, and OrbStack) - Checks for Docker socket at: - `$TOOLHIVE_DOCKER_SOCKET` (if set) - `/var/run/docker.sock` - `~/.docker/run/docker.sock` (Docker Desktop on macOS) + - `~/.docker/desktop/docker.sock` (Docker Desktop on Linux) - `~/.rd/docker.sock` (Rancher Desktop on macOS) - `~/.orbstack/run/docker.sock` (OrbStack on macOS) +3. **Colima** - Checks for Colima socket at: + - `$TOOLHIVE_COLIMA_SOCKET` (if set) + - `~/.colima/default/docker.sock` + ### Detached Process Model When running in detached mode (`thv run` without `--foreground`): @@ -128,9 +130,8 @@ sequenceDiagram **Key Implementation**: - `pkg/workloads/manager.go` - `RunWorkloadDetached` method -- Uses `exec.Command` with `SysProcAttr` to detach -- Sets `TOOLHIVE_DETACHED=true` environment variable -- Redirects stdout/stderr to log file: `~/.toolhive/logs/.log` +- Uses `exec.Command` with `SysProcAttr` (`Setsid` on Unix, equivalent flag on Windows) to detach from the parent session +- Redirects stdout/stderr to log file under `$XDG_DATA_HOME/toolhive/logs/.log` ### File Locations @@ -185,7 +186,7 @@ graph TB 5. **Runtime Selection**: - Picks runtime driver based on environment - - Docker, Podman, or Rancher Desktop + - Docker, Podman, Colima, OrbStack, or Rancher Desktop (same set as CLI mode) - Uses driver API to spawn containers ### API Endpoints @@ -230,7 +231,7 @@ Available flags: |------|-------------|-------------| | `--sentry-dsn` | `SENTRY_DSN` | Sentry Data Source Name (required to enable) | | `--sentry-environment` | `SENTRY_ENVIRONMENT` | Environment name (e.g. `production`, `development`) | -| `--sentry-traces-sample-rate` | `SENTRY_TRACES_SAMPLE_RATE` | Trace sampling rate, 0.0–1.0 (default: `1.0`) | +| `--sentry-traces-sample-rate` | — | Trace sampling rate, 0.0–1.0 (default: `1.0`) | When no DSN is configured, all Sentry operations are no-ops with zero overhead. @@ -280,7 +281,7 @@ graph TB 3. **Operator creates Deployment**: - Runs `thv-proxyrunner` container - - Mounts RunConfig as ConfigMap or secret + - Mounts RunConfig as ConfigMap - Applies middleware configuration 4. **Proxy runner creates StatefulSet**: diff --git a/docs/arch/02-core-concepts.md b/docs/arch/02-core-concepts.md index 84e565ee3f..b8b72a32af 100644 --- a/docs/arch/02-core-concepts.md +++ b/docs/arch/02-core-concepts.md @@ -38,6 +38,8 @@ A **workload** is the fundamental deployment unit in ToolHive. It represents eve - `error` - Workload encountered an error - `unhealthy` - Workload is running but unhealthy - `unauthenticated` - Remote workload cannot authenticate (expired tokens) +- `unknown` - Workload status cannot be determined +- `policy_stopped` - Workload was stopped by policy enforcement **Implementation:** - Interface: `pkg/workloads/manager.go` @@ -50,7 +52,7 @@ A **workload** is the fundamental deployment unit in ToolHive. It represents eve A **transport** defines how MCP clients communicate with MCP servers. It encapsulates the protocol and proxy implementation. -**Three types:** +**Transport types:** 1. **stdio**: Standard input/output communication - Container speaks stdin/stdout @@ -67,6 +69,10 @@ A **transport** defines how MCP clients communicate with MCP servers. It encapsu - Transparent HTTP proxy (same as SSE) - Session management via headers +4. **inspector**: Debug-only transport + - Special transport used for the MCP Inspector tooling + - Not intended for production workloads + **Implementation:** - Interface: `pkg/transport/types/transport.go` - Types: `pkg/transport/types/transport.go` @@ -112,18 +118,24 @@ A **proxy** is the component that sits between MCP clients and MCP servers, forw - **Authentication** (`auth`) - JWT token validation - **Token Exchange** (`tokenexchange`) - OAuth token exchange +- **Upstream Swap** (`upstreamswap`) - Swap ToolHive JWTs for upstream IdP tokens (embedded auth server) +- **AWS STS** (`awssts`) - Exchange tokens for AWS STS credentials +- **OBO** (`obo`) - On-Behalf-Of token flow - **MCP Parser** (`mcp-parser`) - JSON-RPC parsing - **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses - **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests +- **Rate Limit** (`ratelimit`) - Per-client request rate limiting - **Usage Metrics** (`usagemetrics`) - Anonymous usage metrics for ToolHive development (opt-out: `thv config usage-metrics disable`) - **Telemetry** (`telemetry`) - OpenTelemetry instrumentation - **Authorization** (`authorization`) - Cedar policy evaluation - **Audit** (`audit`) - Request logging +- **Recovery** (`recovery`) - Panic recovery for the proxy chain +- **Header Forward** (`header-forward`) - Forward selected request headers to upstream +- **Validating Webhook** (`validating-webhook`) - Call external validating webhooks +- **Mutating Webhook** (`mutating-webhook`) - Call external mutating webhooks **Execution order (request flow):** -Middleware applied in reverse configuration order. Requests flow through: Audit* → Authorization* → Telemetry* → Usage Metrics* → Parser → Token Exchange* → Auth → Tool Call Filter* → Tool Filter* → MCP Server - -(*optional middleware, only present if configured) +Middleware is applied in reverse configuration order, and the chain composed for a given workload depends on which features are enabled. See [`docs/middleware.md`](../middleware.md) for the complete chain, ordering rules, and per-middleware semantics. **Implementation:** - Interface: `pkg/transport/types/transport.go` @@ -187,9 +199,7 @@ A **permission profile** defines security boundaries for MCP servers: - `network` - Full network access **Implementation:** -- Definition: `pkg/permissions/profile.go` -- Network: `pkg/permissions/profile.go` -- Mount declarations: `pkg/permissions/profile.go` +- Definition, network permissions, and mount declarations: `github.com/stacklok/toolhive-core/permissions` (imported as `permissions` in `pkg/runner/`) **Related concepts:** RunConfig, Workload, Security @@ -323,7 +333,7 @@ A **registry** is a catalog of MCP server definitions with metadata, configurati - `groups` - Predefined groups of servers **Implementation:** -- Registry types: `pkg/registry/types.go` +- Registry types: `github.com/stacklok/toolhive-core/registry/types` - Provider abstraction: `pkg/registry/provider.go`, `pkg/registry/factory.go` - Local provider: `pkg/registry/provider_local.go` - Remote provider: `pkg/registry/provider_remote.go` @@ -389,11 +399,15 @@ A **runtime** is an abstraction over container orchestration systems. It provide - `IsWorkloadRunning` - Check if running **Runtime detection:** -Order: Podman → Colima → Docker → Kubernetes (via env) +Runtimes are registered with a numeric `Priority` (lower wins) in the runtime registry (`pkg/container/runtime/registry.go`). Today the registered runtimes are: + +- **Docker runtime** (`Priority: 100`, registered in `pkg/container/docker/register.go`) — covers Docker, Podman, Colima, Rancher Desktop, and OrbStack via per-runtime socket discovery (Podman → Docker → Colima). +- **Kubernetes runtime** (`Priority: 200`, registered in `pkg/container/kubernetes/register.go`) — activated when running in-cluster (via `KUBERNETES_SERVICE_HOST`) or when `TOOLHIVE_RUNTIME=kubernetes` is set. **Implementation:** - Interface: `pkg/container/runtime/types.go` -- Factory: `pkg/container/factory.go` +- Registry: `pkg/container/runtime/registry.go` +- Docker SDK factory and socket discovery: `pkg/container/docker/sdk/factory.go`, `pkg/container/docker/sdk/client_unix.go` - Detection: `pkg/container/runtime/types.go` **Related concepts:** Deployer, Workload, Container @@ -449,7 +463,7 @@ A **skill** is an Agent Skill -- a markdown-based instruction set (SKILL.md) tha 5. **Uninstall** - Remove files and metadata **Implementation:** -- Service: `pkg/skills/skillsvc/skillsvc.go` +- Service interface: `pkg/skills/service.go`; implementation in `pkg/skills/skillsvc/` (entry point `pkg/skills/skillsvc/service.go`) - Types: `pkg/skills/types.go` - Storage: `pkg/storage/sqlite/skill_store.go` - CLI: `cmd/thv/app/skill*.go` @@ -480,7 +494,7 @@ A **skill** is an Agent Skill -- a markdown-based instruction set (SKILL.md) tha 2. Start proxy 3. Configure authentication (if needed) 4. Apply middleware -4. Update state +5. Update state **Commands:** - `thv run ` - Deploy and start @@ -717,7 +731,7 @@ See `pkg/audit/mcp_events.go` for complete list of event types. - Shutdown if unhealthy **Implementation:** -- Monitor: `pkg/container/docker/monitor.go` +- Monitor: `pkg/container/runtime/monitor.go` - Health checker: `pkg/healthcheck/healthcheck.go` **Related concepts:** Workload, Transport, Proxy @@ -759,7 +773,7 @@ graph LR style Chain fill:#fff9c4 ``` -Requests pass through up to 9 middleware components (Auth, Token Exchange, Tool Filter, Tool Call Filter, Parser, Usage Metrics, Telemetry, Authorization, Audit). See `docs/middleware.md` for complete middleware architecture and execution order. +Requests pass through a configurable chain of middleware components. See [`docs/middleware.md`](../middleware.md) for the complete chain and execution order. ### Data Hierarchy diff --git a/docs/arch/03-transport-architecture.md b/docs/arch/03-transport-architecture.md index 6fcb14012e..7f881531c1 100644 --- a/docs/arch/03-transport-architecture.md +++ b/docs/arch/03-transport-architecture.md @@ -226,9 +226,9 @@ graph LR ``` **Implementation:** -- `pkg/transport/types/transport.go` - MiddlewareFunction type +- `pkg/transport/types/transport.go` - `MiddlewareFunction` and `NamedMiddleware` types - Middleware applied in reverse order (last registered = outermost) -- Each transport type accepts `[]MiddlewareFunction` in constructor +- Each transport type accepts `[]NamedMiddleware` in constructor (each wraps a `MiddlewareFunction` with its name for logging) ## Remote MCP Server Proxying @@ -446,25 +446,23 @@ ToolHive uses two port concepts: ### MCP Environment Variables -**Implementation**: `pkg/transport/http.go` +**Implementation**: `pkg/environment/environment.go` sets `MCP_TRANSPORT`, `MCP_PORT`, and `FASTMCP_PORT` for the CLI/local path. `pkg/runtime/setup.go` sets all four variables (`MCP_TRANSPORT`, `MCP_PORT`, `FASTMCP_PORT`, and `MCP_HOST`) when deploying workloads through the runtime (used by both local and Kubernetes/proxy-runner paths). The `TargetHost` default of `127.0.0.1` (`transport.LocalhostIPv4`) is established by `WithTargetHost` in `pkg/runner/config_builder.go` (around line 204), not by the env-emitting code — both code paths simply read `RunConfig.TargetHost`. Environment variables set automatically for container configuration: - `MCP_TRANSPORT`: Transport type (stdio, sse, streamable-http) - `MCP_PORT`: Target port (for SSE/Streamable HTTP) -- `MCP_HOST`: Target host - always `127.0.0.1` (both local and Kubernetes) +- `MCP_HOST`: Target host - defaults to `127.0.0.1` (`transport.LocalhostIPv4`), with the default applied by `WithTargetHost` in `pkg/runner/config_builder.go` when `RunConfig.TargetHost` is empty - `FASTMCP_PORT`: Alias for `MCP_PORT` (legacy support) **Architecture distinction:** -- **Target host** (`MCP_HOST` env var): Where container listens - always `127.0.0.1` -- **Proxy host**: Where proxy binds - `127.0.0.1` in local mode, `0.0.0.0` in Kubernetes for cluster access +- **Target host** (`MCP_HOST` env var): Where the container's MCP server listens - defaults to `127.0.0.1` +- **Proxy host**: Where the proxy binds - `127.0.0.1` in local mode, `0.0.0.0` in Kubernetes for cluster access **Merge strategy**: - User-provided values take precedence - ToolHive sets deployment-appropriate defaults -**Reference**: PR #1890 - Runtime Authoring Guide - ## Container Attach (Stdio Transport) For stdio transport, ToolHive attaches to container stdin/stdout: @@ -484,7 +482,7 @@ stdin, stdout, err := t.deployer.AttachToWorkload(ctx, t.containerName) 5. **Framing** - Newline-delimited JSON-RPC messages **Monitoring:** -- Container monitor detects exit: `pkg/container/docker/monitor.go` +- Container monitor detects exit: `pkg/container/runtime/monitor.go` - Proxy automatically stopped on container exit - Workload status updated @@ -625,7 +623,7 @@ spec: trustProxyHeaders: true ``` -**Implementation**: `pkg/transport/proxy/transparent/transparent_proxy.go` - `rewriteEndpointURL()`, `getSSERewriteConfig()` +**Implementation**: `pkg/transport/proxy/transparent/sse_response_processor.go` - `rewriteEndpointURL()`, `getSSERewriteConfig()` ## Transport Factory diff --git a/docs/arch/04-secrets-management.md b/docs/arch/04-secrets-management.md index 74b68e8d48..4b665bf5bc 100644 --- a/docs/arch/04-secrets-management.md +++ b/docs/arch/04-secrets-management.md @@ -26,7 +26,9 @@ graph LR ## Provider Types -**Implementation**: `pkg/secrets/types.go` +**Implementation**: +- `pkg/secrets/factory.go` (`ProviderType` enum: `EncryptedType`, `OnePasswordType`, `EnvironmentType`) +- `pkg/secrets/types.go` defines the `Provider` interface (the contract every provider implements) and the `EnvVarPrefix` constant (`"TOOLHIVE_SECRET_"`) used by the environment provider ### 1. Encrypted @@ -68,7 +70,7 @@ MCPServer resources reference Kubernetes Secrets via `SecretRef`. Secrets are in **Implementation**: - CRD types: `cmd/thv-operator/api/v1beta1/mcpserver_types.go` -- Pod builder: `cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder.go` +- Pod builder: `cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go` ### External Authentication Secrets @@ -79,7 +81,7 @@ OAuth/OIDC client secrets are stored in Kubernetes Secrets and referenced using - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/tokenexchange.go` 2. **OIDC Authentication (MCPOIDCConfig)**: OIDC client secrets for token introspection - - **CRD field**: `InlineOIDCSharedConfig.ClientSecretRef` in `cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go` + - **CRD field**: `spec.inline.clientSecretRef` on the `MCPOIDCConfig` resource (Go: `MCPOIDCConfig.Spec.Inline.ClientSecretRef`, where `Inline` is of type `*InlineOIDCSharedConfig`), defined in `cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go` - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/oidc.go` - **Runtime loading**: `pkg/auth/token.go` (via `TOOLHIVE_OIDC_CLIENT_SECRET` environment variable) @@ -134,7 +136,7 @@ thv run my-server --secret "api-key,target=API_KEY" - Log exposure: ✅ - Malicious container: ❌ (has env access) -**Implementation**: `pkg/secrets/aes/aes.go`, `pkg/secrets/keyring/` +**Implementation**: `pkg/secrets/aes/aes.go` (AES-256-GCM), `pkg/secrets/keyring/` (OS keyring storage), `pkg/secrets/factory.go` (SHA-256 key derivation via `sha256.Sum256(secretsPassword)`) ## Integration Points diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 3127909f04..11a85f2e7e 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -70,7 +70,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`. } ``` -**Implementation**: `pkg/runner/config.go-49` +**Implementation**: `pkg/runner/config.go` #### Runtime Configuration @@ -109,12 +109,12 @@ thv run uvx://mcp-server \ **Configuration priority** (highest to lowest): 1. Per-workload override in `RunConfig.RuntimeConfig` -2. User config file (`~/.toolhive/config.yaml` `runtimeConfigs` map) +2. User config file (`$XDG_CONFIG_HOME/toolhive/config.yaml` `runtimeConfigs` map) 3. Built-in defaults **Note**: For Go workloads, only the builder image is configurable. The runtime stage always uses `alpine:3.23` for simplicity and security. -**Implementation**: `pkg/runner/config.go-198`, `pkg/container/templates/runtime_config.go` +**Implementation**: `pkg/runner/config.go`, `pkg/container/templates/runtime_config.go` #### Transport Configuration @@ -147,7 +147,7 @@ thv run uvx://mcp-server \ - `target_host`: Container host (default: `127.0.0.1`) - `proxy_mode`: For stdio: `sse` or `streamable-http` -**Implementation**: `pkg/runner/config.go-76`, `139` +**Implementation**: `pkg/runner/config.go` #### Environment Variables @@ -178,7 +178,7 @@ thv run uvx://mcp-server \ } ``` -**Implementation**: `pkg/runner/config.go-88`, `290-303` +**Implementation**: `pkg/runner/config.go` #### Volumes @@ -196,7 +196,7 @@ thv run uvx://mcp-server \ **Relative paths**: Resolved relative to current directory -**Implementation**: `pkg/runner/config.go-95` +**Implementation**: `pkg/runner/config.go` #### Secrets @@ -216,11 +216,10 @@ thv run uvx://mcp-server \ - `encrypted`: Encrypted local storage - `1password`: 1Password SDK integration - `environment`: Environment variable provider -- `none`: No-op provider (for testing) **Note**: There is no automatic default provider. Users must run `thv secret setup` to configure a provider before using secrets functionality. -**Implementation**: `pkg/runner/config.go`, `307-341` +**Implementation**: `pkg/runner/config.go` ### Middleware Configuration @@ -238,7 +237,7 @@ thv run uvx://mcp-server \ } }, { - "type": "authz", + "type": "authorization", "parameters": { "policies": "permit(...);" } @@ -247,17 +246,26 @@ thv run uvx://mcp-server \ } ``` -**Middleware types:** +**Middleware types** (registered in `GetSupportedMiddlewareFactories` in `pkg/runner/middleware.go`): - `auth` - JWT authentication -- `tokenexchange` - OAuth token exchange +- `tokenexchange` - OAuth 2.0 token exchange (RFC 8693) +- `upstreamswap` - Swap ToolHive JWTs for upstream IdP tokens (embedded auth server) +- `awssts` - AWS STS credential exchange +- `obo` - On-Behalf-Of token flow +- `mcp-parser` - Parse JSON-RPC (always present) - `tool-filter` - Filter tool lists - `tool-call-filter` - Filter tool calls -- `mcp-parser` - Parse JSON-RPC (always present) +- `ratelimit` - Request rate limiting +- `usagemetrics` - Usage telemetry - `telemetry` - OpenTelemetry -- `authz` - Cedar authorization +- `authorization` - Cedar authorization - `audit` - Request logging +- `recovery` - Panic recovery +- `header-forward` - Forward request headers to upstream (constant: `HeaderForwardMiddlewareName`) +- `validating-webhook` - Validating admission webhook +- `mutating-webhook` - Mutating admission webhook -**Implementation**: `pkg/runner/config.go-161`, `pkg/transport/types/transport.go-39` +**Implementation**: `pkg/runner/config.go`, `pkg/transport/types/transport.go` ### Tool Filtering @@ -283,7 +291,7 @@ thv run uvx://mcp-server \ } ``` -**Implementation**: `pkg/runner/config.go-154`, `464-472` +**Implementation**: `pkg/runner/config.go` ## RunConfig Lifecycle @@ -319,7 +327,7 @@ config, err := runner.ReadJSON(reader) - Unknown fields ignored (forward compatibility) - Required fields validated -**Implementation**: `pkg/runner/config.go-206` +**Implementation**: `pkg/runner/config.go` ### State Storage @@ -364,7 +372,7 @@ Permission profiles define security boundaries for MCP servers using a defense-i 2. **Network isolation** - Control inbound/outbound connections 3. **Privilege isolation** - Avoid privileged mode -**Implementation**: `pkg/permissions/profile.go` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` ### Profile Structure @@ -378,6 +386,8 @@ type Profile struct { } ``` +`NetworkPermissions` also carries a `Mode` field (`"host"`, `"bridge"`, `"none"`) that selects the container runtime network mode; when empty, the runtime default is used. + ### Filesystem Permissions #### Mount Declarations @@ -408,7 +418,7 @@ Three formats supported: - Architectural reason: Containers run Linux internally, requiring Linux-style paths - Example: `C:\Users\name\data:/data` (Windows host → Linux container path) -**Implementation**: `pkg/permissions/profile.go` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` #### Read vs Write @@ -437,7 +447,7 @@ Three formats supported: - Command injection patterns rejected - Windows paths handled specially -**Implementation**: `pkg/permissions/profile.go-182` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` ### Network Permissions @@ -488,7 +498,7 @@ Three formats supported: } ``` -**Implementation**: `pkg/permissions/profile.go-66` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` #### Inbound Connections @@ -505,7 +515,7 @@ Three formats supported: **Note**: Inbound restrictions currently have limited implementation. -**Implementation**: `pkg/permissions/profile.go-72` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` #### Network Isolation @@ -547,7 +557,7 @@ When `isolate_network: true` in RunConfig: } ``` -**Implementation**: `pkg/permissions/profile.go-44` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` ### Built-in Profiles @@ -573,7 +583,7 @@ When `isolate_network: true` in RunConfig: **Use for**: Maximum security, no external access needed -**Implementation**: `pkg/permissions/profile.go` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` #### `network` Profile @@ -595,7 +605,7 @@ When `isolate_network: true` in RunConfig: **Use for**: API calls, web scraping, external services -**Implementation**: `pkg/permissions/profile.go` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` ### Custom Profiles @@ -624,17 +634,17 @@ Custom permission profiles can be defined in JSON files for reusable security po **Profile resolution**: Profiles can be referenced by name (built-in), file path (custom), or from registry metadata (server-specific defaults). -**Implementation**: `pkg/permissions/profile.go` +**Implementation**: `github.com/stacklok/toolhive-core/permissions` ### Profile Selection **Priority order:** 1. Direct profile object: `WithPermissionProfile(profile)` (programmatic use) -2. Command-line flag: `--permission-profile ` (supports "none", "network", "stdio", or file path) +2. Command-line flag: `--permission-profile ` (supports "none", "network", or file path) 3. Registry default: From server metadata 4. Global default: `network` -**Implementation**: `pkg/permissions/`, registry metadata +**Implementation**: `github.com/stacklok/toolhive-core/permissions`, registry metadata ## Security Best Practices @@ -683,7 +693,7 @@ Custom permission profiles can be defined in JSON files for reusable security po } ``` -**Implementation**: `pkg/networking/`, `pkg/permissions/profile.go` +**Implementation**: `pkg/networking/`, `github.com/stacklok/toolhive-core/permissions` ### Secrets Management @@ -699,7 +709,6 @@ Custom permission profiles can be defined in JSON files for reusable security po - **encrypted**: Password-protected local storage - **1password**: 1Password SDK integration for enterprise vaults - **environment**: CI/CD environment variables -- **none**: Testing/development no-op provider **Implementation**: `pkg/secrets/`, `pkg/runner/config.go` diff --git a/docs/arch/06-registry-system.md b/docs/arch/06-registry-system.md index 68129ffc34..491f5331bd 100644 --- a/docs/arch/06-registry-system.md +++ b/docs/arch/06-registry-system.md @@ -71,14 +71,16 @@ thv run server-name ``` **Implementation:** -- Embedded: `pkg/registry/data/registry.json` +- Built-in catalog: loaded from the [`github.com/stacklok/toolhive-catalog`](https://github.com/stacklok/toolhive-catalog) package (`pkg/catalog/toolhive`), via `catalog.Upstream()` in `pkg/registry/provider_local.go` - Manager: `pkg/registry/provider.go`, `pkg/registry/provider_local.go`, `pkg/registry/provider_remote.go` ## Registry Format +> **Note**: The JSON examples in this section show the parsed in-memory shape (`types.Registry` in `toolhive-core`), which is what consumers operate on after the upstream payload has been converted. The actual on-disk / wire format is `UpstreamRegistry` (with `$schema`, `version`, `meta.last_updated`, `data.servers`, `data.skills`). See [Upstream MCP Registry Format](#upstream-mcp-registry-format) below for the wire format and how legacy fields map onto it. + ### Top-Level Structure -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ```json { @@ -98,7 +100,7 @@ thv run server-name ### Server Entry (Container-based) -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ```json { @@ -147,7 +149,7 @@ thv run server-name ### Remote Server Entry -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ```json { @@ -185,7 +187,7 @@ thv run server-name ### Group Entry -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ```json { @@ -276,7 +278,7 @@ User overrides take precedence over registry defaults. - Stores secrets securely - Adds to RunConfig -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ## Custom Registries @@ -284,6 +286,8 @@ Organizations can provide their own registries. ### File-Based Registry +> **Note**: The snippet below shows the legacy/in-memory shape for readability. On disk, the file must be in the upstream MCP registry format with servers under `data.servers` (see [Upstream MCP Registry Format](#upstream-mcp-registry-format)). Use `thv registry convert --in --in-place` to migrate legacy files. + **Create registry JSON:** ```json { @@ -360,7 +364,7 @@ thv config unset-registry The API endpoint must implement: - `GET /v0.1/servers` - List all servers with pagination -- `GET /v0.1/servers/:name` - Get specific server by reverse-DNS name +- `GET /v0.1/servers/:name/versions/latest` - Get the latest version of a specific server by reverse-DNS name - `GET /v0.1/servers?search=` - Search servers - `GET /openapi.yaml` - OpenAPI specification (version 1.0.0) @@ -505,7 +509,7 @@ For complete registry server documentation, see: - [Registry Server Guides](https://docs.stacklok.com/toolhive/guides-registry/) - Configuration, authentication, deployment - [Registry API Reference](https://docs.stacklok.com/toolhive/reference/registry-api) - API endpoint documentation -- [ToolHive Registry Schema](https://docs.stacklok.com/toolhive/reference/registry-schema-toolhive) - Registry format reference +- [Upstream Registry Schema](https://docs.stacklok.com/toolhive/reference/registry-schema-upstream) - Registry format reference ## MCPRegistry CRD (Kubernetes) @@ -538,7 +542,6 @@ spec: configYAML: | sources: - name: company-repo - format: toolhive git: repository: https://github.com/company/mcp-registry branch: main @@ -569,7 +572,6 @@ and Kubernetes. configYAML: | sources: - name: my-source - format: toolhive git: repository: https://github.com/example/registry branch: main @@ -604,7 +606,6 @@ spec: configYAML: | sources: - name: private-repo - format: toolhive git: repository: https://github.com/org/private-registry branch: main @@ -653,7 +654,6 @@ spec: configYAML: | sources: - name: production - format: toolhive file: path: /config/registry/production/registry.json syncPolicy: @@ -697,7 +697,6 @@ Sync intervals are configured per-source inside `configYAML`: configYAML: | sources: - name: my-source - format: toolhive git: repository: https://github.com/example/registry branch: main @@ -721,11 +720,11 @@ The operator always creates a registry API deployment for each MCPRegistry: **Access:** ```bash # Within cluster -curl http://company-registry-api.default.svc.cluster.local:8080/api/v1/registry +curl http://company-registry-api.default.svc.cluster.local:8080/v0.1/servers # Via port-forward kubectl port-forward svc/company-registry-api 8080:8080 -curl http://localhost:8080/api/v1/registry +curl http://localhost:8080/v0.1/servers ``` **Implementation**: `cmd/thv-operator/pkg/registryapi/` @@ -784,7 +783,7 @@ data from its configured sources (Git, API, Kubernetes, etc.) at runtime. - `repository_url` - Source code URL - `tags` - Categorization labels -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ### RemoteServerMetadata (Remote Servers) @@ -803,7 +802,7 @@ data from its configured sources (Git, API, Kubernetes, etc.) at runtime. - `repository_url` - Documentation URL - `tags` - Categorization labels -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ### Group @@ -828,11 +827,17 @@ data from its configured sources (Git, API, Kubernetes, etc.) at runtime. - Organizational structure **Run all servers in group:** + +> **Note**: `thv group run` is deprecated -- registry-based group deployment is no longer supported. The modern path is to create a local group with `thv group create `, then run each server with `thv run --group `. + ```bash -thv group run data-pipeline # assuming 'data-pipeline' is defined in your registry +# Modern path: create a local group, then add servers to it +thv group create data-pipeline +thv run data-reader --group data-pipeline +thv run data-processor --group data-pipeline ``` -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` ## Provenance and Security @@ -870,12 +875,12 @@ thv run weather-server --image-verification enabled ## Upstream MCP Registry Format -ToolHive supports the upstream [MCP registry format](https://github.com/modelcontextprotocol/registry) alongside the legacy ToolHive format. +ToolHive consumes registries in the upstream [MCP registry format](https://github.com/modelcontextprotocol/registry). The legacy ToolHive-native format is no longer accepted; existing files can be migrated with `thv registry convert --in --in-place`. **Key features:** -1. **Dual format support**: Both ToolHive-native and upstream MCP formats +1. **Standardized schema**: Upstream MCP server format from the modelcontextprotocol/registry project 2. **Publisher-provided extensions**: ToolHive-specific metadata via `_meta["io.modelcontextprotocol.registry/publisher-provided"]` -3. **Backward compatibility**: Legacy format continues to work +3. **Lossless migration**: Every legacy ToolHive field maps to a publisher-provided extension on the corresponding upstream server entry ### Publisher-Provided Extensions @@ -912,11 +917,11 @@ ToolHive uses the `io.modelcontextprotocol.registry/publisher-provided` extensio ``` For the complete schema definition, see: -- **Schema file**: `pkg/registry/data/publisher-provided.schema.json` +- **Schemas**: published in [`stacklok/toolhive-core`](https://github.com/stacklok/toolhive-core) under `registry/types/data/` - **Documentation**: `docs/registry/schema.md` - **Validation**: `pkg/registry/schema_validation.go` -**Implementation**: `pkg/registry/` (supports both formats) +**Implementation**: `pkg/registry/` ## Registry Operations @@ -947,9 +952,14 @@ kubectl get mcpregistry company-registry -o yaml ``` **Trigger manual sync:** -```bash -kubectl annotate mcpregistry company-registry toolhive.stacklok.dev/sync-trigger=true -``` + +Sync is handled internally by the registry server based on each source's +`syncPolicy` in `configYAML`. The operator itself does not expose a +trigger annotation in current code — `cmd/thv-operator/REGISTRY.md` +documents a `toolhive.stacklok.dev/manual-sync` annotation, but no +corresponding constant or handler exists in +`cmd/thv-operator/controllers/mcpregistry_controller.go`. To force a +re-sync, modify `configYAML` (or restart the registry API pod). **Implementation**: `cmd/thv-operator/controllers/mcpregistry_controller.go` @@ -966,8 +976,7 @@ kubectl annotate mcpregistry company-registry toolhive.stacklok.dev/sync-trigger ### External Documentation - [ToolHive User Documentation](https://docs.stacklok.com/toolhive/) - User-facing guides - [Registry Server Documentation](https://docs.stacklok.com/toolhive/guides-registry/) - Enterprise registry server -- [ToolHive Registry Schema](https://docs.stacklok.com/toolhive/reference/registry-schema-toolhive) - Registry format reference -- [Upstream Registry Schema](https://docs.stacklok.com/toolhive/reference/registry-schema-upstream) - MCP standard format +- [Upstream Registry Schema](https://docs.stacklok.com/toolhive/reference/registry-schema-upstream) - MCP standard format used by ToolHive - [Registry API Reference](https://docs.stacklok.com/toolhive/reference/registry-api) - API specification ### Related Repositories diff --git a/docs/arch/07-groups.md b/docs/arch/07-groups.md index 5c2e9c768e..9ac6bc2e1b 100644 --- a/docs/arch/07-groups.md +++ b/docs/arch/07-groups.md @@ -69,7 +69,7 @@ Registry groups are predefined collections of servers that can be deployed toget - Group deployment creates a runtime group with all member servers - Each server maintains its individual identity and configuration -**Implementation**: `pkg/registry/types.go` +**Implementation**: `github.com/stacklok/toolhive-core/registry/types/registry_types.go` **Use case**: Deploy complete stacks (e.g., a full data processing pipeline) with a single command, ensuring all required components are available together. diff --git a/docs/arch/08-workloads-lifecycle.md b/docs/arch/08-workloads-lifecycle.md index 22bd6ddd33..0bef4ce759 100644 --- a/docs/arch/08-workloads-lifecycle.md +++ b/docs/arch/08-workloads-lifecycle.md @@ -39,6 +39,7 @@ stateDiagram-v2 **States**: `pkg/container/runtime/types.go` - `starting`, `running`, `stopping`, `stopped` - `removing`, `error`, `unhealthy`, `unauthenticated` +- `policy_stopped`, `unknown` ## Core Operations @@ -95,6 +96,32 @@ thv rm my-server **Implementation**: `pkg/workloads/manager.go` +### Upgrade + +```bash +thv upgrade check [my-server] # offline metadata comparison, never pulls +thv upgrade apply my-server --yes # verify + pull, then recreate +``` + +Upgrades apply only to **registry-sourced** workloads — those run by a registry entry name, which records `RunConfig.RegistryServerName`. A workload run from a raw image reference has no registry server name and is reported as `not-registry-sourced`. + +**Check** is an offline comparison. The checker (`pkg/workloads/upgrade.Checker`) looks up the workload's `RegistryServerName` in the configured registry and compares the running image tag against the candidate the registry advertises (semver, conservative: a `latest` tag, a different repository, or non-comparable tags yield `unknown`). When the registry advertises a strictly newer tag the status is `upgrade-available`, and the result also surfaces **env-var drift** (variables the candidate now declares that the workload does not yet supply) and **posture drift** (transport, permission profile — network isolation is a local-only choice the registry cannot express, so it is not reported as drift). Checks never pull images. + +**Apply** is the single security-critical path (`pkg/workloads/upgrade.Applier`). It re-derives the check against the registry on every apply (closing the time-of-check/time-of-use window — a `CheckResult` from an earlier `check` is never trusted), then, in order: + +1. Resolves and **verifies** the candidate image's provenance (by registry server name). +2. Builds a merged `RunConfig` that **preserves the entire user configuration** — env vars, secrets, OIDC/authz/audit/telemetry, tool filters, middleware, transport/posture — and changes only the image, any merged env/secrets supplied via `--env`/`--secret`, and the registry source URLs. +3. Runs the policy gate and performs the verified **pull**. +4. Only then asks the manager to recreate the workload via `UpdateWorkload` (stop → delete → start with the new config). + +Steps 1–3 all complete before any destruction, so a failure while preparing the candidate leaves the running workload untouched. **There is no automatic rollback**: once recreation begins, the previous image/config is not restored — recovery is a forward operation. Posture drift (transport, permission profile) is **surfaced as a warning, not converged**: the upgraded workload keeps its full existing posture, including transport, network isolation, and permission profile. + +> Runtime boundary: the "verified pull before destruction" guarantee holds precisely for local container runtimes (the scope here). On Kubernetes the verification and policy gate still precede recreation, but the byte-level pull is delegated to the kubelet and happens after recreation. + +The same `Applier` backs both the CLI (`thv upgrade apply`) and the API (`POST /api/v1beta/workloads/{name}/upgrade`, with `GET .../{name}/upgrade-check` and `GET .../upgrade-check` for single and bulk checks), so the verify-then-pull ordering lives in exactly one place. `thv list --check-upgrades` annotates the workload list with each workload's check result. + +**Implementation**: `pkg/workloads/upgrade/`, `cmd/thv/app/upgrade.go` + ### List Listing combines container workloads from the runtime with remote workloads from persisted state. The manager can filter workloads by label or group, and can optionally include stopped workloads. @@ -105,12 +132,10 @@ Listing combines container workloads from the runtime with remote workloads from Some operations (stop, delete) support processing multiple workloads in a single invocation, handling each workload sequentially or in parallel as appropriate. -**Pattern**: Operations return `errgroup.Group` +**Pattern**: Operations return a `CompletionFunc` (call to block until completion); internally uses `golang.org/x/sync/errgroup` **Timeout**: 5 minutes per operation -**Implementation**: Uses `golang.org/x/sync/errgroup` - ## Container vs Remote ### Container Workloads diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 82eff27f4a..38ce8001fc 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -67,6 +67,12 @@ MCPServer is the fundamental building block. All other CRDs either **organize**, │ CONFIGURATION (attaches to any) │ │ ToolConfig MCPExternalAuthConfig │ │ MCPOIDCConfig MCPTelemetryConfig │ + │ MCPWebhookConfig │ + └──────────────────────────────────────────────────┘ + + ┌──────────────────────────────────────────────────┐ + │ AUXILIARY │ + │ EmbeddingServer │ └──────────────────────────────────────────────────┘ ``` @@ -76,7 +82,8 @@ MCPServer is the fundamental building block. All other CRDs either **organize**, | **Organization** | MCPGroup | Group related servers together | | **Aggregation** | VirtualMCPServer, VirtualMCPCompositeToolDefinition | Combine multiple servers into one endpoint | | **Discovery** | MCPRegistry | Help clients find available servers | -| **Configuration** | ToolConfig, MCPExternalAuthConfig, MCPOIDCConfig, MCPTelemetryConfig | Shared config that attaches to any layer | +| **Configuration** | MCPToolConfig, MCPExternalAuthConfig, MCPOIDCConfig, MCPTelemetryConfig, MCPWebhookConfig | Shared config that attaches to any layer | +| **Auxiliary** | EmbeddingServer | Supporting infrastructure (e.g., embedding inference for vMCP) | #### Workload CRDs (Deploy Running Pods) @@ -86,6 +93,7 @@ MCPServer is the fundamental building block. All other CRDs either **organize**, | **MCPRemoteProxy** | Deployment | Proxy to external/remote MCP servers | | **VirtualMCPServer** | Deployment | Aggregates multiple backends into one endpoint | | **MCPRegistry** | Deployment | Registry API server for MCP discovery | +| **EmbeddingServer** | StatefulSet | HuggingFace text-embeddings-inference server (e.g., for vMCP semantic search) | #### Logical/Configuration CRDs (No Pods) @@ -93,10 +101,11 @@ MCPServer is the fundamental building block. All other CRDs either **organize**, |-----|---------| | **MCPServerEntry** | Zero-infrastructure declaration of a remote MCP endpoint | | **MCPGroup** | Logical grouping of workloads (status tracking only) | -| **ToolConfig** | Tool filtering and renaming configuration | +| **MCPToolConfig** | Tool filtering and renaming configuration | | **MCPExternalAuthConfig** | Token exchange / header injection configuration | | **MCPOIDCConfig** | Shared OIDC provider settings referenced by workload CRDs | | **MCPTelemetryConfig** | Shared OpenTelemetry/Prometheus settings referenced by workload CRDs | +| **MCPWebhookConfig** | Validating/mutating webhook middleware definitions referenced by MCPServer | | **VirtualMCPCompositeToolDefinition** | Workflow definitions (webhook validation only) | ### CRD Relationships @@ -121,14 +130,15 @@ graph TB subgraph "Configuration Only" CTD[VirtualMCPCompositeToolDefinition
Webhook validation] ExtAuth[MCPExternalAuthConfig
No resources] - ToolCfg[ToolConfig
No resources] + ToolCfg[MCPToolConfig
No resources] OIDCCfg[MCPOIDCConfig
No resources] TelCfg[MCPTelemetryConfig
No resources] + WebhookCfg[MCPWebhookConfig
No resources] end VMCP -->|groupRef| Group - VMCP -->|compositeToolRefs| CTD - VMCP -.->|oidcConfigRef| OIDCCfg + VMCP -->|config.compositeToolRefs| CTD + VMCP -.->|incomingAuth.oidcConfigRef| OIDCCfg VMCP -.->|telemetryConfigRef| TelCfg Server -->|groupRef| Group @@ -137,6 +147,7 @@ graph TB Server -.->|toolConfigRef| ToolCfg Server -.->|oidcConfigRef| OIDCCfg Server -.->|telemetryConfigRef| TelCfg + Server -.->|webhookConfigRef| WebhookCfg Proxy -->|groupRef| Group Proxy -.->|externalAuthConfigRef| ExtAuth @@ -176,11 +187,18 @@ For examples, see: ### MCPRegistry -Manages MCP server registries in Kubernetes, supporting both Git-based and ConfigMap-based registry sources with automatic or manual synchronization. +Deploys a registry API server that serves MCP server metadata to clients. The registry server itself handles fetching, parsing, and refreshing registry data from upstream sources (Git, files, etc.) — the operator is responsible only for running the server and feeding it configuration. **Implementation**: `cmd/thv-operator/api/v1beta1/mcpregistry_types.go` -MCPRegistry resources can sync registry data from external sources and optionally deploy a registry API service for serving the registry data to other components. +**Key fields:** +- `configYAML` (required) — the complete `config.yaml` for the registry server. The operator stores this verbatim in a ConfigMap mounted at `/config/config.yaml` and does NOT parse, validate, or transform it. +- `volumes` / `volumeMounts` — additional volumes the registry server needs (e.g., a Secret containing Git credentials referenced by file path from `configYAML`). +- `pgpassSecretRef` — dedicated field for PostgreSQL `.pgpass` credentials (handled via an init container because libpq requires mode 0600). +- `podTemplateSpec` — pod-level customizations (resources, affinity, tolerations). +- `imagePullSecrets` — applied to both the Deployment and the operator-managed ServiceAccount. + +**Security note**: `configYAML` is stored in a ConfigMap, so credentials must NOT be inlined there. Reference them by file path and mount the corresponding Secret via `volumes` / `volumeMounts`. **Controller**: `cmd/thv-operator/controllers/mcpregistry_controller.go` @@ -233,7 +251,7 @@ MCPOIDCConfig eliminates OIDC configuration duplication — define an identity p **Status fields** include a `Ready` condition, `configHash` for change detection, and `referencingWorkloads` tracking which resources reference this config. Deletion is blocked while references exist (finalizer pattern). -**Referenced by**: MCPServer, MCPRemoteProxy, VirtualMCPServer (via `oidcConfigRef`) +**Referenced by**: MCPServer and MCPRemoteProxy (via `spec.oidcConfigRef`); VirtualMCPServer (via `spec.incomingAuth.oidcConfigRef`) **Controller**: `cmd/thv-operator/controllers/mcpoidcconfig_controller.go` @@ -260,6 +278,35 @@ MCPTelemetryConfig centralises telemetry infrastructure settings (collector endp For examples, see [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](../../examples/operator/mcp-servers/mcpserver_fetch_otel.yaml). +### MCPWebhookConfig + +Defines reusable validating and mutating webhook middleware configurations that can be referenced by MCPServer resources. + +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpwebhookconfig_types.go` + +MCPWebhookConfig declares one or more `validating` and/or `mutating` webhooks (URL, timeout, failure policy, optional TLS, optional HMAC signing). At least one webhook must be defined (CEL-enforced). The MCPServer references it via `spec.webhookConfigRef`, and the proxy-runner injects the corresponding middleware into the request chain. + +**Key features:** +- Per-webhook `failurePolicy` (`fail` or `ignore`) for handling webhook errors +- Optional `tlsConfig` with CA bundle, mTLS client cert, or `insecureSkipVerify` +- Optional `hmacSecretRef` adds the `X-Toolhive-Signature` header to outgoing payloads + +**Status fields** include a `Ready` condition, `configHash` for change detection, and `referencingWorkloads`. + +**Referenced by**: MCPServer (via `webhookConfigRef`) + +**Controller**: `cmd/thv-operator/controllers/mcpwebhookconfig_controller.go` + +### EmbeddingServer + +Deploys a HuggingFace text-embeddings-inference container as a StatefulSet, used by VirtualMCPServer for features such as semantic tool search. + +**Implementation**: `cmd/thv-operator/api/v1beta1/embeddingserver_types.go` + +EmbeddingServer encapsulates the model (defaulting to `BAAI/bge-small-en-v1.5`), image, port, optional HuggingFace token, and an optional persistent model cache. The operator reconciles each EmbeddingServer into a StatefulSet plus Service. + +**Controller**: `cmd/thv-operator/controllers/embeddingserver_controller.go` + ### MCPRemoteProxy Defines a proxy for remote MCP servers with authentication, authorization, audit logging, and tool filtering. @@ -324,7 +371,7 @@ MCPGroup resources allow grouping related MCP servers. Servers reference their g **Status fields** include phase (Ready, Pending, Failed), list of server names, and server count. -**Referenced by MCPServer** using `spec.groupRef.name`. +**Referenced by**: MCPServer, MCPRemoteProxy, MCPServerEntry, and VirtualMCPServer using `spec.groupRef.name`. **Controller**: `cmd/thv-operator/controllers/mcpgroup_controller.go` @@ -405,7 +452,7 @@ Defines reusable composite tool workflows that can be shared across multiple Vir Composite tools orchestrate calls to multiple backend tools in sequence or parallel, enabling complex workflows without client awareness of the underlying backends. Workflow steps form a DAG (Directed Acyclic Graph) with support for conditional execution and error handling. -**Referenced by**: VirtualMCPServer (via `spec.compositeToolRefs`) +**Referenced by**: VirtualMCPServer (via `spec.config.compositeToolRefs`) **Status fields** track validation status and which VirtualMCPServers reference the definition. @@ -415,6 +462,10 @@ For complete examples of all CRDs, see the [`examples/operator/mcp-servers/`](.. ## Operator Components +### Entry Point + +The operator binary's `main` package delegates to `cmd/thv-operator/app/app.go`. `Run()` performs flag parsing, manager construction, and leader-election setup, then calls `setupControllersAndWebhooks`, which fans out to `setupServerControllers` (MCPServer, MCPRemoteProxy, MCPServerEntry, MCPGroup, and shared config CRDs), `setupRegistryController` (MCPRegistry), and `setupAggregationControllers` (VirtualMCPServer, VirtualMCPCompositeToolDefinition, EmbeddingServer). Webhooks are registered alongside their controllers. + ### Controller **Reconciliation loop:** @@ -489,17 +540,16 @@ graph LR 5. Apply middleware chain 6. Forward traffic to StatefulSet pods -**Command:** -```bash -thv-proxyrunner run -``` +**Container invocation:** + +The operator sets `Container.Args` (not `Command`) on the proxy-runner Deployment. The image's default entrypoint runs the `thv-proxyrunner` binary; the operator passes `run` as the first argument, optionally followed by `--k8s-pod-patch=` when the user supplied a `podTemplateSpec`, then the MCP image reference. See `deploymentForMCPServer` in `cmd/thv-operator/controllers/mcpserver_controller.go`. **Environment:** - `KUBERNETES_SERVICE_HOST` - Detects K8s environment - RunConfig path from mount - In-cluster Kubernetes client -**Implementation**: `cmd/thv-proxyrunner/app/commands.go` +**Implementation**: `cmd/thv-proxyrunner/app/commands.go` (cobra wiring), `cmd/thv-proxyrunner/app/run.go` (`run` command logic) ## Design Principles @@ -543,7 +593,7 @@ spec: **Why**: Simple Phase + Ready condition + ReadyReplicas + URL, enables `kubectl wait --for=condition=Ready` -**Implementation**: `cmd/thv-operator/controllers/mcpregistry_controller.go` +**Implementation**: `cmd/thv-operator/pkg/controllerutil/status.go` (`MutateAndPatchStatus` merge-patch helper used by all CRD controllers) ## MCPRegistry Controller @@ -552,84 +602,50 @@ spec: ```mermaid graph TB MCPReg[MCPRegistry CRD] --> Controller[Controller] - Controller --> Source[Source Handler] - - Source -->|git| Git[Git Clone] - Source -->|configmap| CM[Read ConfigMap] + Controller --> Manager[Registry API Manager] - Git --> Storage[Storage Manager] - CM --> Storage + Manager --> CM[ConfigMap
raw configYAML] + Manager --> SA[ServiceAccount + Role/RoleBinding] + Manager --> Deploy[Deployment
thv-registry-api] + Manager --> SVC[Service] - Storage --> ConfigMap[ConfigMap Storage] - Controller --> API[Registry API Service] + Deploy -.mounts.-> CM - API --> Deploy[Deployment] - API --> SVC[Service] + Source[Registry source
Git / file / DB] -->|fetched at runtime| Deploy style Controller fill:#5c6bc0 - style Storage fill:#e3f2fd - style API fill:#ba68c8 + style Manager fill:#e3f2fd + style Deploy fill:#ba68c8 ``` -### Source Handlers +The operator is intentionally thin: it materialises a Deployment, Service, ServiceAccount, RBAC, and a config ConfigMap. All registry-source logic (Git cloning, file watching, database access, refresh scheduling) lives in the registry-api server and is driven entirely by the `configYAML` the user supplies. -**Git source**: `cmd/thv-operator/pkg/sources/git.go` -- Clones repository -- Reads registry.json -- Calculates hash for change detection +### Registry API Manager -**ConfigMap source**: `cmd/thv-operator/pkg/sources/configmap.go` -- Reads from existing ConfigMap -- Watches for updates +The MCPRegistry controller delegates workload management to a Manager that creates the ConfigMap, ServiceAccount, RBAC, Deployment, and Service for the registry-api server. -**Storage Manager**: `cmd/thv-operator/pkg/sources/storage_manager.go` -- Creates ConfigMap with key `registry.json` containing full registry data -- Sync operations are handled by the registry server itself +**Implementation entry point**: `cmd/thv-operator/pkg/registryapi/manager.go` -**Interface**: `cmd/thv-operator/pkg/sources/types.go` +Supporting files in `cmd/thv-operator/pkg/registryapi/`: +- `deployment.go` — builds the registry-api Deployment +- `service.go` — builds the Service +- `rbac.go` — builds the ServiceAccount, Role, and RoleBinding +- `podtemplatespec.go` — merges user-supplied `podTemplateSpec` into the generated PodSpec +- `config/raw_config.go` — wraps the raw `configYAML` in a ConfigMap (no parsing) -### Storage Manager +### Config Delivery -**Purpose**: Persist registry data in cluster +The operator passes `spec.configYAML` verbatim into a ConfigMap (`-registry-server-config`) and mounts it at `/config/config.yaml`. A content-checksum annotation on the ConfigMap triggers Deployment rollouts when the YAML changes. -**Implementation**: `cmd/thv-operator/pkg/sources/storage_manager.go` - -**Storage**: ConfigMap with owner reference - -**Format:** ```yaml data: - registry.json: | - { full registry data } -``` - -Sync operations are handled by the registry server, not the operator. - -### Sync Policy - -**Automatic sync:** -```yaml -spec: - syncPolicy: - interval: 1h + config.yaml: | + { raw user-supplied configYAML } ``` -Operator syncs every hour. The presence of `syncPolicy` with an `interval` enables automatic synchronization. - -**Manual sync:** - -Omit the `syncPolicy` field entirely. - -Trigger: Add or update annotation `toolhive.stacklok.dev/sync-trigger=` where the value can be any non-empty string. The operator triggers sync when this value changes, allowing multiple manual syncs by using different values (e.g., timestamps, counters). - -### Registry API Service - -When enabled, operator creates: -- Deployment running `thv-registry-api` -- Service exposing API -- ConfigMap mount with registry data +### Refresh Behavior -**Implementation**: `cmd/thv-operator/pkg/registryapi/service.go` +Refresh policy, source location, and credentials are all defined inside `configYAML` and interpreted by the registry-api server. The operator does not poll, schedule, or trigger registry syncs. ## Configuration References diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index 2cb899721d..891dffc3ed 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -237,12 +237,16 @@ Middleware is applied by wrapping handlers, so execution order is outer-to-inner | Order | Middleware | Required | Purpose | |-------|------------|----------|---------| | 1 | Recovery | Always | Catches panics, returns HTTP 500 | -| 2 | Authentication | Optional | Validates incoming JWT tokens (OIDC/Anonymous) | -| 3 | Authorization | Optional | Evaluates Cedar policies (composed with auth) | -| 4 | Audit | Optional | Logs request events for compliance | -| 5 | Discovery | Always | Aggregates backend capabilities per session | -| 6 | Backend Enrichment | Optional | Adds backend name to audit context | -| 7 | Telemetry | Optional | OpenTelemetry instrumentation | +| 2 | WriteTimeout | Always | Clears the server `WriteTimeout` for qualifying SSE connections | +| 3 | Header Validation | Always | Rejects GETs without `Accept: text/event-stream` before they reach the MCP handler | +| 4 | Authentication (+ MCP parsing) | Optional | Validates incoming credentials (OIDC/local/anonymous); MCP parsing is composed inside so downstream layers see `ParsedMCPRequest` | +| 5 | Audit | Optional | Logs request events for compliance | +| 6 | Discovery | Always | Aggregates backend capabilities per session | +| 7 | Annotation Enrichment | Optional | Injects tool annotations into context for annotation-aware authz (only when Authorization is configured) | +| 8 | Authorization | Optional | Evaluates Cedar policies after discovery and annotation enrichment | +| 9 | Backend Enrichment | Optional | Adds backend name to audit context (only when Audit is configured) | +| 10 | MCP Parsing | Always | Second application is a no-op when auth already parsed; ensures telemetry can label metrics with `mcp_method` when auth is nil | +| 11 | Telemetry | Optional | OpenTelemetry instrumentation | ### Discovery Middleware @@ -272,14 +276,17 @@ This enriches audit events with the backend name for better observability. ### Authentication Composition -When Authorization is configured, Authentication middleware is composed with MCP Parsing and Authorization: +`pkg/vmcp/auth/factory/incoming.NewIncomingAuthMiddleware()` returns two separate middlewares: + +- `authMw`: Authentication composed with MCP Parsing (parsing runs immediately after auth so downstream layers see `ParsedMCPRequest`). +- `authzMw`: Authorization, returned independently so the server can place it after discovery and annotation enrichment in the chain. + +The server wires them around discovery/annotation-enrichment so the effective execution order is: ``` -Authentication → MCP Parsing → Authorization → Next Handler +Authentication → MCP Parsing → Audit → Discovery → Annotation Enrichment → Authorization → Next Handler ``` -This composition is created by `pkg/vmcp/auth/factory/incoming.NewIncomingAuthMiddleware()`. - **Implementation**: `pkg/vmcp/server/server.go`, `pkg/vmcp/discovery/middleware.go`, `pkg/vmcp/auth/factory/` ## Health Monitoring @@ -290,14 +297,29 @@ vMCP monitors backend health with configurable intervals. Health status (healthy ## Deployment -vMCP can be deployed two ways: +vMCP can be deployed in three ways: - **Kubernetes** - Via the VirtualMCPServer CRD managed by the operator -- **CLI** - Standalone via the `vmcp` binary for development or non-Kubernetes environments +- **Local CLI (`thv vmcp`)** - Recommended path for local and non-Kubernetes use; built into the main `thv` binary +- **Standalone `vmcp` binary** - Preserved for backwards compatibility and advanced CLI use **Implementation**: - Kubernetes: `cmd/thv-operator/controllers/virtualmcpserver_controller.go` -- CLI: `cmd/vmcp/` +- Local CLI: `cmd/thv/app/vmcp.go`, `pkg/vmcp/cli/` +- Standalone binary: `cmd/vmcp/` + +## Local CLI Mode + +`thv vmcp` is the recommended way to run a vMCP server outside of Kubernetes. It provides the same aggregation, tool routing, and optimizer capabilities as the Kubernetes-managed VirtualMCPServer, but runs as a local foreground process driven by Cobra CLI flags. + +Key features: + +- **Zero-config quick mode**: `thv vmcp serve --group ` generates an in-memory config from a running ToolHive group — no YAML file required. +- **Config-file workflow**: `thv vmcp init` → `thv vmcp validate` → `thv vmcp serve --config` for reproducible deployments. +- **Optimizer tiers**: optional FTS5 keyword search (Tier 1) and managed TEI semantic search (Tier 2) reduce tool count for MCP clients. +- **Loopback-only binding**: quick mode enforces a loopback-only host via `ServeConfig.validateQuickModeHost` — `localhost`, `127.0.0.1`, `::1`, or any other loopback IP is accepted; non-loopback addresses are rejected. + +See [Local vMCP CLI Mode](vmcp-local.md) for the full architecture, optimizer tier table, and TEI container lifecycle documentation. ## Status Reporting @@ -311,12 +333,12 @@ Status reporting enables vMCP runtime to report operational status directly inst ### Key Concepts -- `StatusReporter` interface (`pkg/vmcp/status/reporter.go`): `ReportStatus(ctx, *vmcp.Status)` and `Start(ctx)` returning shutdown func. +- `Reporter` interface (`pkg/vmcp/status/reporter.go`); set via `Config.StatusReporter` field: `ReportStatus(ctx, *vmcp.Status)` and `Start(ctx)` returning shutdown func. - Status model (`pkg/vmcp/types.go`): - Phase: Pending, Ready, Degraded, Failed - Conditions: `metav1.Condition` (ready, backends discovered, auth configured) using shared constants - DiscoveredBackends: backend URL/auth type/health with timestamps -- CLI reporter: Logging-only reporter (no persistence) always logs status updates. +- CLI reporter: Logging-only reporter (no persistence) logs status updates at Debug level (visible when `--debug` is set). - Lifecycle hook: server starts the reporter, collects shutdown funcs, and stops them during graceful shutdown. ### Integration in vMCP Runtime @@ -339,3 +361,6 @@ Status reporting enables vMCP runtime to report operational status directly inst - [Operator Architecture](09-operator-architecture.md) - CRD details - [Transport Architecture](03-transport-architecture.md) - Transport types used by backends - [Middleware Architecture](../middleware.md) - Shared middleware system (Authentication, Audit, Telemetry, etc.) +- [Local vMCP CLI Mode](vmcp-local.md) - `thv vmcp` CLI surface, optimizer tiers, and TEI lifecycle +- [vMCP Library Embedding](vmcp-library.md) - Embedding `pkg/vmcp/` in downstream Go projects +- [vMCP Scalability Limits and Constraints](13-vmcp-scalability.md) - Per-pod session cap, TTL mechanics, Redis sizing, and pod restart behaviour diff --git a/docs/arch/11-auth-server-storage.md b/docs/arch/11-auth-server-storage.md index 224e8c190d..b1e49afdf7 100644 --- a/docs/arch/11-auth-server-storage.md +++ b/docs/arch/11-auth-server-storage.md @@ -7,7 +7,7 @@ The embedded authorization server uses a pluggable storage backend to persist OA The auth server stores OAuth 2.0 protocol state including access tokens, refresh tokens, authorization codes, PKCE challenges, client registrations, user accounts, and upstream IDP tokens. Two storage backends are available: 1. **Memory** (default): In-process storage with mutex-based concurrency. Suitable for single-instance deployments. -2. **Redis Sentinel**: Shared storage using Redis with Sentinel for high availability. Required for horizontal scaling across multiple auth server replicas. +2. **Redis**: Shared storage backed by Redis. Supports standalone mode (single endpoint, suitable for managed services like GCP Memorystore Basic and AWS ElastiCache without cluster mode), Cluster mode (Redis Cluster discovery endpoint, e.g. GCP Memorystore Cluster and AWS ElastiCache with cluster mode enabled), and Sentinel mode (high-availability with automatic failover). Required for horizontal scaling across multiple auth server replicas. ```mermaid graph TB @@ -20,7 +20,7 @@ graph TB subgraph "Storage Backend" direction TB Memory[In-Memory Storage
Single instance only] - Redis[Redis Sentinel
Shared state] + Redis[Redis
Standalone, Cluster, or Sentinel
Shared state] end AS1 -.->|single instance| Memory @@ -28,27 +28,21 @@ graph TB AS2 -->|distributed| Redis AS3 -->|distributed| Redis - subgraph "Redis Sentinel Cluster" - S1[Sentinel 1] - S2[Sentinel 2] - S3[Sentinel 3] - RM[Redis Master] - RR1[Redis Replica] - RR2[Redis Replica] + subgraph "Redis Deployment Options" + Standalone[Standalone
Managed services] + Cluster[Redis Cluster
Managed services] + Sentinel[Sentinel Cluster
Self-managed HA] end - Redis --> S1 - Redis --> S2 - Redis --> S3 - S1 -.->|monitors| RM - S2 -.->|monitors| RM - S3 -.->|monitors| RM - RM -->|replicates| RR1 - RM -->|replicates| RR2 + Redis --> Standalone + Redis --> Cluster + Redis --> Sentinel style Memory fill:#fff3e0 style Redis fill:#e1f5fe - style RM fill:#ffb74d + style Standalone fill:#e8f5e9 + style Cluster fill:#e8f5e9 + style Sentinel fill:#e8f5e9 ``` ## Storage Interface @@ -67,12 +61,60 @@ The storage layer implements multiple interfaces from the [fosite](https://githu - `UpstreamTokenStorage` — Upstream IDP token caching with user binding - `PendingAuthorizationStorage` — In-flight authorization tracking - `UserStorage` — Internal user accounts and provider identity linking +- `DCRCredentialStore` — DCR client secret persistence; intentionally NOT embedded in `Storage` (each backend implements it separately and call sites reach it via an explicit `stor.(DCRCredentialStore)` type assertion) **Implementation:** - Interface definitions: `pkg/authserver/storage/types.go` - Memory backend: `pkg/authserver/storage/memory.go` - Redis backend: `pkg/authserver/storage/redis.go` +## Identity resolution for pure OAuth2 providers + +For pure OAuth 2.0 upstream providers (`OAuth2Config`), OIDC is unavailable and there is no ID token. `BaseOAuth2Provider.ExchangeCodeForIdentity` resolves user identity through a three-way priority chain. Each path has distinct implications for `UserStorage`, `UpstreamTokenStorage`, and the Redis secondary index. + +### IdentityFromToken (priority 1) + +An operator opt-in path that extracts identity claims directly from the token endpoint response body, skipping the userinfo HTTP call entirely. + +**When the path triggers.** `IdentityFromToken` is configured on the upstream provider (`p.config.IdentityFromToken != nil`). The `tokenResponseRewriter` intercepts the token endpoint response and runs extraction against the raw pre-rewrite body; the result is available to `ExchangeCodeForIdentity` without an additional round-trip. + +**Subject format.** Real, stable subject string extracted from the token response body via a gjson dot-notation path (e.g. `username`, `authed_user.id`). For token responses that embed a JWT, the `@upstreamjwt` modifier decodes the payload for further drilling (e.g. `access_token|@upstreamjwt|sub`). The `@upstreamjwt` modifier performs no signature verification — it is intended only for JWTs received directly from the upstream token endpoint over a TLS-authenticated channel. The returned `*Identity` carries `Synthetic = false`. Path semantics and trust-model notes are documented on the runtime config struct `IdentityFromTokenConfig` in `pkg/authserver/upstream/identity_from_token.go`. The corresponding CRD type (`cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig`) is defined in a sibling PR; operator-to-runner translation of this config lands separately. + +**`UserResolver` interaction.** Because `Identity.Synthetic` is false, `callback.go` takes the normal path: `UserResolver.ResolveUser` runs, a row is created (or looked up) in `UserStorage`, a provider-identities entry is written, and `UpdateLastAuthenticated` is called. `UpstreamTokens.UserID` carries the resolved internal user UUID, not the raw operator-supplied subject string. + +**Reverse-index implication (Redis backend).** Stable user IDs mean `KeyTypeUserUpstream` works as designed — one set per user accumulates session IDs across re-authentications. No set churn. + +**Operator visibility.** The `IdentitySynthesized` condition does not fire for upstreams using `IdentityFromToken`. `SyntheticIdentityUpstreams()` (the controller-side predicate that drives the condition) skips upstreams where either `userInfo` or `identityFromToken` is configured — only the dual-unconfigured case is reported as synthesis-mode. + +**Implementation.** +- `pkg/authserver/upstream/oauth2.go` — `ExchangeCodeForIdentity` priority 1 branch +- `pkg/authserver/upstream/identity_from_token.go` — `IdentityFromTokenConfig`, `extractIdentityFromTokenResponse`, `@upstreamjwt` modifier +- `pkg/authserver/upstream/token_exchange.go` — `tokenResponseRewriter.RoundTrip` extracts identity from the raw pre-rewrite body + +### UserInfo endpoint (priority 2) + +Existing behavior. When `IdentityFromToken` is unconfigured and `userInfo` is set, `fetchUserInfo` is called with the upstream access token. Subject, name, and email come from the userinfo response. `UserResolver.ResolveUser` runs normally, `Identity.Synthetic` is false. + +### Synthesis-mode subjects (priority 3) + +Reached when both `IdentityFromToken` is unconfigured AND `userInfo` is absent. The embedded auth server synthesizes a non-PII subject by hashing the upstream access token. The mode changes what `UserStorage` and `UpstreamTokenStorage` see and is observable to operators inspecting stored state. + +**When the path triggers.** Pure OAuth 2.0 upstream provider (`OAuth2Config`) where both `IdentityFromToken` and `userInfo` are unconfigured. Reached at `BaseOAuth2Provider.ExchangeCodeForIdentity` as the final fallback. OIDC providers and OAuth2 providers with either `IdentityFromToken` or `userInfo` configured are not affected. + +**Subject format.** `tk-` followed by 32 lowercase hex characters (the first 16 bytes of `SHA-256(accessToken)`), e.g. `tk-89abcdef0123456789abcdef01234567`. The output is opaque: assuming the upstream issues opaque (non-JWT) bearer tokens, the digest reveals nothing about the input that an attacker holding a candidate token could not already confirm by re-hashing. The returned `*Identity` carries `Synthetic = true`; the `upstream.IsSynthesizedSubject(string)` predicate lets bare-string consumers recognize the prefix. + +**`UserResolver` bypass.** The bypass is gated on `Identity.Synthetic` in `callback.go` — synthesis is the only path that sets this field. Synthetic identities skip `UserResolver.ResolveUser` entirely — no row is created in `UserStorage`, no entry is written to provider-identities, and `UpdateLastAuthenticated` is not called. The synthesized subject rotates per access token, so persisting it would create a fresh `users` row on every re-authentication. `UpstreamTokens.UserID` therefore carries the `tk-…` value directly rather than a stable internal UUID. + +**Reverse-index implication (Redis backend).** The `KeyTypeUserUpstream` secondary-index set under `thv:auth:{ns:name}:user:upstream:{userID}` is designed around stable user IDs — one set per user, holding all of that user's session IDs. Under synthesis the userID rotates with every re-authentication, so each session lands in its own one-element set. Reads continue to work, but set churn is much higher than under OIDC. The existing TODO in `pkg/authserver/storage/redis.go` (on `warnOnCleanupErr`) to scan and clean up stale secondary-index entries applies, and synthesis-mode workloads make a periodic scan more important. + +**Operator visibility.** When at least one configured OAuth2 upstream has both `userInfo` and `identityFromToken` unconfigured, the controller surfaces the `IdentitySynthesized` condition on the `MCPExternalAuthConfig` and `VirtualMCPServer` status (Reason `IdentitySynthesizedActive`, naming the affected upstreams). The condition flips to `False` (Reason `IdentitySynthesizedInactive`) once every upstream has either `userInfo` or `identityFromToken` configured. + +**Implementation.** +- `pkg/authserver/upstream/oauth2.go` — `synthesizeIdentity`, `synthesizeSubjectFromAccessToken`, `IsSynthesizedSubject` +- `pkg/authserver/upstream/types.go` — `Identity.Synthetic` +- `pkg/authserver/server/handlers/callback.go` — `UserResolver` bypass on `Identity.Synthetic` +- `cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go` and `cmd/thv-operator/controllers/virtualmcpserver_controller.go` — `IdentitySynthesized` advisory condition + ## Memory Backend The in-memory backend uses Go maps protected by `sync.RWMutex` for thread safety. A background goroutine runs periodic cleanup of expired entries. @@ -85,16 +127,17 @@ The in-memory backend uses Go maps protected by `sync.RWMutex` for thread safety **Implementation:** `pkg/authserver/storage/memory.go` -## Redis Sentinel Backend +## Redis Backend -The Redis backend stores all OAuth 2.0 state as JSON-serialized values in Redis, using the Sentinel protocol for automatic master discovery and failover. +The Redis backend stores all OAuth 2.0 state as JSON-serialized values in Redis. ### Connection Architecture -The client connects to Redis through Sentinel using `redis.NewFailoverClient()` from the `go-redis` library. Sentinel handles: -- Master discovery: Finding the current master node -- Automatic failover: Detecting master failure and promoting a replica -- Configuration notification: Updating clients when the master changes +Three connection modes are supported. Client construction (standalone, cluster, or sentinel), connection-mode validation, TLS plumbing, and connectivity verification are delegated to the shared `github.com/stacklok/toolhive-core/redis` package; `RedisStorage` holds a `redis.UniversalClient` so the same call sites work across all three topologies. + +- **Standalone**: A single endpoint for managed Redis services. The caller is responsible for endpoint availability (the managed service handles HA internally). +- **Cluster**: A Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache with cluster mode enabled). Enabled by setting `clusterMode: true` alongside `addr` in the CRD. +- **Sentinel**: Connects via Sentinel for self-managed high-availability deployments. Sentinel handles master discovery, automatic failover, and configuration updates. ### Multi-Tenancy @@ -104,7 +147,7 @@ Each auth server instance has a unique key prefix derived from its Kubernetes na thv:auth:{namespace:name}: ``` -The `{namespace:name}` portion is a Redis hash tag. Although ToolHive only supports Sentinel deployments, the hash tag format ensures keys remain co-located in the same hash slot if the deployment were ever migrated to Redis Cluster. In Sentinel mode, hash tags have no functional effect but impose no overhead. +The `{namespace:name}` portion is a Redis hash tag. In standalone and Sentinel modes, hash tags have no functional effect but impose no overhead. The format ensures keys remain co-located in the same hash slot if the deployment were ever migrated to Redis Cluster. **Implementation:** `pkg/authserver/storage/redis_keys.go` @@ -162,15 +205,23 @@ MCPExternalAuthConfig └── spec.embeddedAuthServer.storage ├── type: "memory" | "redis" └── redis - ├── address (direct Redis-compatible mode) - ├── sentinelConfig (HA mode) - │ ├── masterName - │ ├── sentinelAddrs[] (or sentinelService) - │ └── db + ├── addr (standalone or cluster) ─── mutually exclusive ─── sentinelConfig + │ ├── masterName + │ ├── sentinelAddrs[] (or sentinelService) + │ └── db + ├── clusterMode (optional; requires addr — Redis Cluster protocol) ├── aclUserConfig - │ ├── usernameSecretRef + │ ├── usernameSecretRef (optional; omit for password-only AUTH) │ └── passwordSecretRef - └── timeouts (dial, read, write) + ├── tls (optional, master/cluster connection) + │ ├── caCertSecretRef + │ └── insecureSkipVerify + ├── sentinelTls (optional, sentinel connection only) + │ ├── caCertSecretRef + │ └── insecureSkipVerify + ├── dialTimeout + ├── readTimeout + └── writeTimeout ``` **Implementation:** `cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go` @@ -183,13 +234,43 @@ When passing configuration across process boundaries (operator → proxy-runner) ## Security Considerations -- **ACL authentication only**: Redis ACL users (Redis 6+) provide fine-grained access control. Legacy `requirepass` authentication is not supported. +- **ACL or legacy authentication**: Redis ACL users (Redis 6+) provide fine-grained access control. When a username is omitted, go-redis sends legacy password-only `AUTH`, which is required for managed Redis tiers that do not expose an ACL subsystem (e.g. GCP Memorystore Basic/Standard HA, Azure Cache for Redis). - **Key prefix isolation**: Each auth server is restricted to its own key prefix via Redis ACL rules (`~thv:auth:*`). - **Credential handling**: In Kubernetes, credentials are stored in Secrets and injected as environment variables. They are never written to disk or logged. -- **No TLS currently**: TLS/mTLS for Redis connections is not yet supported and is planned as a future enhancement. +- **TLS support**: TLS is supported for both master and Sentinel connections via `tls` and `sentinelTls` in the CRD. For managed services with private CAs (e.g. GCP Memorystore), provide the CA certificate via `caCertSecretRef`. ## Related Documentation - [Redis Storage Configuration Guide](../redis-storage.md) — User-facing setup guide - [Operator Architecture](09-operator-architecture.md) — CRD and controller design - [Core Concepts](02-core-concepts.md) — Platform terminology + +## CIMD Storage Decorator + +When `authServer.cimd.enabled: true` is set, the embedded authorization server wraps its storage backend in a `CIMDStorageDecorator` before passing it to fosite. This decorator enables MCP clients to present HTTPS URLs as `client_id` values without first calling `/oauth/register`. + +### What it does + +`CIMDStorageDecorator` embeds the full `storage.Storage` interface and overrides only `GetClient`. When fosite calls `GetClient("https://vscode.dev/oauth/client-metadata.json")` during an authorization request: + +1. The decorator detects the HTTPS URL using `oauthproto.IsClientIDMetadataDocumentURL` +2. It fetches the Client ID Metadata Document from that URL via `pkg/oauthproto/cimd.FetchClientMetadataDocument` (with SSRF protection, 10 KB cap, 5-second timeout) +3. It builds a `fosite.Client` from the document fields, caches it with a configurable TTL, and returns it to fosite +4. Concurrent fetches for the same URL are deduplicated via `singleflight` + +All other `Storage` methods (`RegisterClient`, token storage, upstream token storage, etc.) delegate to the underlying backend unchanged. DCR clients (opaque string IDs) continue to work exactly as before. + +### Unwrap pattern + +`CIMDStorageDecorator` implements `Unwrap() Storage` to expose the concrete backend through the decorator layer. Two call sites in `pkg/authserver/server_impl.go` depend on this: + +- **`DCRCredentialStore` assertion** (`newServer`): The `DCRCredentialStore` interface is narrower than `Storage` and not embedded in it. The assertion `unwrapStorage(stor).(storage.DCRCredentialStore)` reaches the concrete backend through the decorator. +- **`RedisStorage` migration** (`runLegacyMigration`): A type assertion to `*storage.RedisStorage` is needed to run a one-shot data migration. Same `unwrapStorage` call. + +Both call sites use the `unwrapStorage(stor)` helper rather than asserting directly on `stor`. + +### Air-gapped environments + +When the embedded authorization server is deployed in an environment that cannot reach `https://toolhive.dev/oauth/client-metadata.json` or any public CIMD metadata URL, set `authServer.cimd.enabled: false`. Clients will fall back to DCR (`/oauth/register`) which uses only the local storage backend and requires no outbound connectivity. + +**Implementation:** `pkg/authserver/storage/cimd_decorator.go` diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 4db139cc06..e0021eb346 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -112,7 +112,7 @@ Instructions for how the AI assistant should perform code reviews... | `toolhive.requires` | No | OCI references for skill dependencies | | `license` | No | SPDX license identifier | | `compatibility` | No | Client compatibility string (max 500 chars) | -| `metadata` | No | Arbitrary key-value pairs | +| `metadata` | No | Arbitrary string-keyed, string-valued metadata | **Implementation:** `pkg/skills/types.go` (SkillFrontmatter), `pkg/skills/parser.go`, `pkg/skills/validator.go` @@ -175,7 +175,7 @@ thv skill build ./my-skill/ --tag v1.0.0 3. Package all files into a tar.gz OCI layer 4. Store in the local OCI store with the specified tag -**Implementation:** `pkg/skills/skillsvc/skillsvc.go` (Build), `toolhive-core/oci/skills` (SkillPackager) +**Implementation:** `pkg/skills/skillsvc/build.go` (Build), `toolhive-core/oci/skills` (SkillPackager) ### 3. Publishing @@ -185,7 +185,7 @@ Push a locally-built artifact to a remote OCI registry: thv skill push ghcr.io/org/my-skill:v1.0.0 ``` -**Implementation:** `pkg/skills/skillsvc/skillsvc.go` (Push), `toolhive-core/oci/skills` (RegistryClient) +**Implementation:** `pkg/skills/skillsvc/build.go` (Push), `toolhive-core/oci/skills` (RegistryClient) ### 4. Installation @@ -244,9 +244,9 @@ flowchart TD 3. **Supply chain validation**: For OCI installs, the skill name in the artifact must match the repository name in the reference. -4. **Client targeting**: When no `--clients` flag is provided, all skill-supporting clients are targeted by default. Specify `--clients claude-code` to target a particular client. +4. **Client targeting**: When no `--clients` flag is provided, all skill-supporting clients detected on the host are targeted by default. Specify `--clients claude-code` to target a particular client. -**Implementation:** `pkg/skills/skillsvc/skillsvc.go` (Install) +**Implementation:** `pkg/skills/skillsvc/install.go` (Install) ### 5. Uninstallation @@ -256,7 +256,7 @@ thv skill uninstall code-review Removes the skill files from the filesystem, deletes the database record, and removes the skill from all groups. -**Implementation:** `pkg/skills/skillsvc/skillsvc.go` (Uninstall), `pkg/groups/skills.go` (RemoveSkillFromAllGroups) +**Implementation:** `pkg/skills/skillsvc/uninstall.go` (Uninstall), `pkg/groups/skills.go` (RemoveSkillFromAllGroups) ## Git-Based Skill Resolution @@ -272,7 +272,7 @@ git://github.com/org/repo@main#skills/my-skill # Branch + subdirectory **Resolution process:** 1. Parse the git reference (host, repo, ref, path) 2. Resolve authentication (`GITHUB_TOKEN` for github.com, `GITLAB_TOKEN` for gitlab.com — both host-scoped to prevent credential exfiltration; `GIT_TOKEN` as an unscoped fallback sent to any host) -3. Clone the repository (2-minute timeout, shallow clone) +3. Clone the repository (2-minute timeout; shallow clone when a branch or tag is specified) 4. Extract the skill directory files 5. Validate and install as normal @@ -282,7 +282,7 @@ git://github.com/org/repo@main#skills/my-skill # Branch + subdirectory ## Storage -Skill installation records are persisted in SQLite across four tables. The `entries` table is a shared parent for all entry types (skills share it with future entry kinds); `installed_skills` holds skill-specific columns and references `entries` via a foreign key; `oci_tags` caches OCI reference-to-digest mappings for upgrade detection and deduplication: +Skill installation records are persisted in SQLite across four tables. The `entries` table is a shared parent for all entry types (skills share it with future entry kinds); `installed_skills` holds skill-specific columns and references `entries` via a foreign key; `oci_tags` is reserved for caching OCI reference-to-digest mappings but is not currently populated: ``` entries table @@ -317,7 +317,7 @@ skill_dependencies table ├── dep_digest (TEXT) └── PRIMARY KEY(installed_skill_id, dep_reference) -oci_tags table +oci_tags table (reserved; not currently populated) ├── reference (TEXT, PRIMARY KEY — OCI reference string) └── digest (TEXT NOT NULL — content digest) ``` @@ -341,6 +341,7 @@ oci_tags table | `POST` | `/push` | Push built skill to registry | | `GET` | `/builds` | List local builds | | `DELETE` | `/builds/{tag}` | Delete a local build | +| `GET` | `/content` | Get a skill's SKILL.md body and file listing for a reference | **Implementation:** `pkg/api/v1/skills.go` @@ -408,7 +409,7 @@ The skills system applies defense-in-depth across multiple layers: - Resolves symlinks in parent components before applying depth checks ### Supply Chain -- OCI artifact skill name must match repository name in the reference +- OCI artifact skill name must match the last path component of the OCI repository - Git authentication is host-scoped (GitHub token only sent to github.com) - SSRF prevention: rejects localhost and private IPs in git references @@ -441,7 +442,7 @@ ToolHive owns the installation lifecycle, scoping model, CLI/API interfaces, and |---|---| | Type definitions | `pkg/skills/types.go` | | Service interface | `pkg/skills/service.go` | -| Service implementation | `pkg/skills/skillsvc/skillsvc.go` | +| Service implementation | `pkg/skills/skillsvc/` | | Options / DTOs | `pkg/skills/options.go` | | Validation | `pkg/skills/validator.go` | | Parsing | `pkg/skills/parser.go` | diff --git a/docs/arch/13-vmcp-scalability.md b/docs/arch/13-vmcp-scalability.md new file mode 100644 index 0000000000..8acc86dc19 --- /dev/null +++ b/docs/arch/13-vmcp-scalability.md @@ -0,0 +1,212 @@ +# vMCP Scalability Limits and Constraints + +> **Audience**: operators scaling vMCP beyond a single replica. For the +> architectural overview, see +> [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md). + +This document describes the known capacity limits, configuration-driven +constraints, and operational considerations for Virtual MCP Server (vMCP) +deployments. Review this before scaling beyond a single replica. + +## Per-pod session cache + +Each vMCP pod maintains a **node-local LRU cache** capped at **1,000 concurrent +`MultiSession` entries** (source: +`pkg/vmcp/server/sessionmanager/factory.go:defaultCacheCapacity`). + +When the cache is full, the least-recently-used session is evicted via the +`onEvict` callback, which calls `sess.Close()` to tear down its backend +connections. Any request in flight at that moment fails. Subsequent requests +for the same session ID trigger a cache miss: the session manager calls +`factory.RestoreSession()`, which reconstructs the `MultiSession` from stored +metadata and re-establishes backend connections transparently. The client does +not need to reconnect unless the metadata itself has also expired. + +The cap exists to prevent unbounded memory growth: omitting `CacheCapacity` +from a `FactoryConfig` silently defaults to 1,000 rather than unbounded growth. +`CacheCapacity` is currently an internal field and is not exposed via the +VirtualMCPServer CRD. + +**Implication:** A single vMCP pod can serve at most ~1,000 simultaneous MCP +sessions. To handle more, add replicas and configure Redis session storage so +that session metadata is persisted and any pod can reconstruct the live session +(including its routing table) via `RestoreSession()` on demand. + +## Session TTL + +### vMCP server TTL (30 minutes) + +The vMCP server defaults to a **30-minute session TTL** +(`pkg/vmcp/server/server.go:defaultSessionTTL`). The TTL controls the lifetime +of **session metadata** in the storage layer, not the in-process `MultiSession` +runtime objects: + +- **Local storage (single replica):** session metadata is removed from + `LocalSessionDataStorage` after the TTL elapses with no access. The + corresponding in-process `MultiSession` (with its live backend connections) + remains in the node-local LRU cache until it is evicted by cache pressure or + explicit termination. +- **Redis storage (multi-replica):** see [Redis sliding-window TTL](#redis-sliding-window-ttl) below. + +When metadata expires, any subsequent request that references that session ID +will fail to restore the session (`RestoreSession()` finds no stored metadata) +and the client must reinitialize. Backend connections held by the cached +`MultiSession` are only released when the LRU cache evicts the entry or the +session is explicitly terminated. + +The TTL is configurable via `server.Config.SessionTTL` but is not currently +exposed through the operator CRD. + +### MCPServer proxy TTL (2 hours) + +The MCPServer proxy runner uses a separate, longer TTL of **2 hours** +(`pkg/transport/session/proxy_session.go:DefaultSessionTTL`). This applies to +the underlying SSE/streamable transport sessions, not the vMCP-level session +aggregation. + +### Redis sliding-window TTL + +When Redis session storage is enabled, every `Load` call issues a `GETEX` that +resets the key's TTL atomically (see `pkg/transport/session/storage_redis.go`, +`NewRedisStorage` and the `Load` function docstring). This means: + +- Active sessions are preserved indefinitely as long as they receive at least + one request per TTL window. +- Idle sessions expire automatically after the full TTL elapses with no access. +- There is no absolute maximum session lifetime enforced by Redis storage. + +### Session garbage collection + +| Trigger | Mechanism | +| ------- | --------- | +| Explicit termination (client disconnect, auth failure) | `DEL` issued immediately to Redis | +| Inactivity beyond TTL | Redis TTL expiry (automatic, no application-side action needed) | +| Pod-local cache eviction (LRU) | `onEvict` callback closes backend connections only; the Redis metadata key is **not** deleted and expires via TTL | + +### Identity-binding storage and Redis access control + +Each vMCP session carries an identity binding stored in session metadata under the +key `vmcp.identity.binding`. The canonical format is defined in +`pkg/vmcp/session/binding/binding.go`: a NUL-separated `iss + "\x00" + sub` for +authenticated sessions, and the literal string `"unauthenticated"` for sessions +without an auth identity. + +The binding is stored as **plaintext** in the session store (Redis/Valkey). It is +not a credential — it identifies but does not authenticate a principal — but it is +personally-identifying information (a combination of issuer URL and user subject). + +Operators are responsible for access-controlling the Redis/Valkey instance +equivalently to any other identity store. Concretely: enable Redis ACLs (Redis 6+) +or `requirepass`, restrict network reach with a Kubernetes `NetworkPolicy`, and +avoid sharing the cluster with untrusted workloads. + +The session store prior to issue #5306 held an HMAC of the bearer token rather than +the raw `(iss, sub)` pair. That scheme reduced the value of a Redis dump at the cost +of breaking on every legitimate OAuth token refresh. The current scheme accepts +plaintext PII at rest as the price of correctness; operators who require additional +protection against a Redis compromise must layer Redis-side access controls as +described above. + +## File descriptor limits + +Each open backend connection consumes one file descriptor on the vMCP pod. A +pod aggregating many MCP backends at high session concurrency can exhaust the +OS-level `nofile` limit before hitting the 1,000-session cache cap. + +The default Linux per-process `nofile` soft limit is typically 1,024. When this +limit is reached, new `connect()` calls fail with `EMFILE` ("too many open +files"), which surfaces as backend connection errors. + +**Estimate:** `concurrent_sessions × backends_per_session` file descriptors. +For example, 200 sessions each connecting to 3 backends requires ~600 fds, +plus fds for incoming client connections and internal pipes. + +The issue has been identified but the exact threshold depends on pod +configuration and backend topology. Raise the limit in the container spec or at +the node level via the container runtime before deploying at scale. + +## Redis sizing + +Session data is written on every new session (`Store`) and read on every +request (`Load` + `GETEX`). Redis is on the hot path. + +| Parameter | Default | Notes | +| --------- | ------- | ----- | +| Dial timeout | 5 s (`DefaultDialTimeout`) | `github.com/stacklok/toolhive-core/redis/config.go` | +| Read timeout | 3 s (`DefaultReadTimeout`) | | +| Write timeout | 3 s (`DefaultWriteTimeout`) | | +| Key prefix | configurable | Must end with `:` to avoid collisions | + +**Memory:** Session payloads include the routing table and tool metadata. Rough +estimate: 10–50 KB per session depending on backend count and tool count. +Maximum concurrent session count across the fleet is `replicas × 1,000`. + +**Connection pools:** Each vMCP pod creates one go-redis client with its own +connection pool. Client construction is delegated to +`github.com/stacklok/toolhive-core/redis` and no explicit `PoolSize` is +configured (the `tcredis.Config` struct does not expose one), so go-redis +applies its default of `10 × GOMAXPROCS` connections per pool. Total Redis +connections therefore scale as `replicas × (10 × GOMAXPROCS)`. Size the Redis +`maxclients` setting accordingly. + +**Eviction policy:** Use `allkeys-lru` so Redis can shed stale sessions under +memory pressure rather than returning errors on new writes. + +**Persistence:** Redis persistence is not required for session storage. If the +Redis pod restarts, all active sessions are lost and MCP clients must reconnect. +For production deployments where session continuity is critical, use a +`StatefulSet` with a PVC and enable RDB/AOF persistence. + +## Stateful backends and pod restarts + +vMCP is a stateless proxy: it holds routing tables and tool aggregation state, +but the backend MCP servers own their own state (browser sessions, database +cursors, open files). + +When a vMCP pod restarts or is evicted: + +1. **Redis session storage is configured:** the routing table survives in Redis. + Clients can reconnect and resume the MCP session. However, any backend-side + state (Playwright browser context, open transaction, filesystem handle) is + **not recovered** — the backend connection was torn down without a graceful + MCP shutdown sequence. + +2. **Local storage only:** both the routing table and the backend connections + are lost. Clients must reinitialize completely. + +In both cases, **in-flight tool calls are lost without a response** when a pod +dies. Callers should implement retry logic with idempotency guards for any tool +invocations that modify external state. + +### Session affinity and multi-replica deployments + +Stateful backends require that all requests within a session reach the same +backend pod. The `VirtualMCPServer` CRD exposes `sessionAffinity: ClientIP` +(default), which instructs kube-proxy to sticky-route connections by source IP. + +This is unreliable when clients sit behind NAT, a corporate proxy, or a cloud +load balancer — all traffic appears to originate from the same IP, routing every +session to a single pod. For production stateful workloads, prefer vertical +scaling over horizontal scaling. See `docs/arch/10-virtual-mcp-architecture.md` +for session affinity design details. + +## Hardcoded limits summary + +| Limit | Value | Source | Tunable? | +| ----- | ----- | ------ | -------- | +| Per-pod session cache | 1,000 sessions | `sessionmanager/factory.go:defaultCacheCapacity` | No (internal field) | +| vMCP session TTL | 30 minutes | `vmcp/server/server.go:defaultSessionTTL` | Via `server.Config.SessionTTL` (not CRD-exposed) | +| MCPServer proxy session TTL | 2 hours | `transport/session/proxy_session.go:DefaultSessionTTL` | No | +| Redis dial timeout | 5 s | `toolhive-core/redis/config.go:DefaultDialTimeout` | Via `tcredis.Config.DialTimeout` | +| Redis read timeout | 3 s | `toolhive-core/redis/config.go:DefaultReadTimeout` | Via `tcredis.Config.ReadTimeout` | +| Redis write timeout | 3 s | `toolhive-core/redis/config.go:DefaultWriteTimeout` | Via `tcredis.Config.WriteTimeout` | +| forEach max iterations | 1,000 | `vmcp/config/composite_validation.go:MaxForEachIterations` | Via `WorkflowStepConfig.MaxIterations` (capped at 1,000) | + +## Related + +- `pkg/vmcp/server/sessionmanager/factory.go` — LRU cache and `FactoryConfig` +- `pkg/vmcp/server/server.go` — `defaultSessionTTL`, `Config.SessionTTL` +- `pkg/transport/session/storage_redis.go` — sliding-window TTL via `GETEX` +- `github.com/stacklok/toolhive-core/redis/config.go` — Redis client config and timeout defaults +- `docs/arch/10-virtual-mcp-architecture.md` — overall vMCP architecture +- `docs/arch/11-auth-server-storage.md` — Redis Sentinel for auth server sessions diff --git a/docs/arch/README.md b/docs/arch/README.md index 116bc3d20e..2ae4a69657 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -25,65 +25,65 @@ Welcome to the ToolHive architecture documentation. This directory contains comp ### Detailed Component Documentation -4. **[Core Concepts](02-core-concepts.md)** +1. **[Core Concepts](02-core-concepts.md)** - Nouns: Workloads, Transports, Proxy, Middleware, RunConfig, Permissions, Groups, Registry, Sessions - Verbs: Deploy, Proxy, Attach, Parse, Filter, Authorize, Audit, Export, Import, Monitor - Terminology quick reference -5. **[Secrets Management](04-secrets-management.md)** +2. **[Secrets Management](04-secrets-management.md)** - Provider types (encrypted, 1password, environment) - OS keyring integration - Fallback chain - Security model -6. **[RunConfig and Permission Profiles](05-runconfig-and-permissions.md)** +3. **[RunConfig and Permission Profiles](05-runconfig-and-permissions.md)** - RunConfig schema and versioning - Permission profiles (read, write, network) - Built-in profiles and custom profiles - Mount declarations and resource URIs - Security best practices -7. **[Registry System](06-registry-system.md)** +4. **[Registry System](06-registry-system.md)** - Built-in curated registry - Custom registries (file and remote) - Registry API server architecture - MCPRegistry CRD - Image and remote server metadata -8. **[Groups](07-groups.md)** +5. **[Groups](07-groups.md)** - Group concept and use cases - Registry groups - Client configuration -9. **[Workloads Lifecycle Management](08-workloads-lifecycle.md)** +6. **[Workloads Lifecycle Management](08-workloads-lifecycle.md)** - Workloads API interface - Lifecycle: deploy, stop, restart, delete, update - State management - Container vs remote workloads - Async operations -10. **[Kubernetes Operator Architecture](09-operator-architecture.md)** - - CRD design (MCPServer, MCPRegistry, MCPToolConfig, MCPExternalAuthConfig, VirtualMCPServer) - - Two-binary architecture (operator + proxy-runner) - - Deployment pattern - - Status management - - Design principles - -11. **[Virtual MCP Server Architecture](10-virtual-mcp-architecture.md)** - - MCP Gateway for aggregating multiple backends - - Backend discovery and capability aggregation - - Conflict resolution strategies - - Two-boundary authentication model - - Composite tool workflows - -12. **[Auth Server Storage Architecture](11-auth-server-storage.md)** - - Storage interface design (fosite + ToolHive extensions) - - Memory and Redis Sentinel backends - - Multi-tenancy via key prefixes - - Atomic operations with Lua scripts - - Configuration and security model - -13. **[Skills System](12-skills-system.md)** +7. **[Kubernetes Operator Architecture](09-operator-architecture.md)** + - CRD design (MCPServer, MCPRegistry, MCPToolConfig, MCPExternalAuthConfig, VirtualMCPServer) + - Two-binary architecture (operator + proxy-runner) + - Deployment pattern + - Status management + - Design principles + +8. **[Virtual MCP Server Architecture](10-virtual-mcp-architecture.md)** + - MCP Gateway for aggregating multiple backends + - Backend discovery and capability aggregation + - Conflict resolution strategies + - Two-boundary authentication model + - Composite tool workflows + +9. **[Auth Server Storage Architecture](11-auth-server-storage.md)** + - Storage interface design (fosite + ToolHive extensions) + - Memory and Redis Sentinel backends + - Multi-tenancy via key prefixes + - Atomic operations with Lua scripts + - Configuration and security model + +10. **[Skills System](12-skills-system.md)** - Agent Skills lifecycle (discover, build, publish, install) - SKILL.md format and validation - OCI-based distribution and git resolution @@ -91,6 +91,28 @@ Welcome to the ToolHive architecture documentation. This directory contains comp - Security model (archive safety, SSRF prevention, supply chain) - Skills vs MCP servers design rationale +11. **[vMCP Scalability Limits and Constraints](13-vmcp-scalability.md)** + - Per-pod session cache cap (1,000 sessions, LRU eviction) + - Session TTL and Redis sliding-window behavior + - File descriptor constraints and estimation + - Redis sizing, eviction policy, and persistence guidance + - Stateful backend data loss on pod restart + +12. **[Local vMCP CLI Mode](vmcp-local.md)** + - `thv vmcp` CLI surface (`serve`, `validate`, `init`) + - Zero-config quick mode and config-file workflow + - Optimizer tier table (Tier 0–3: none, FTS5, TEI semantic, external service) + - TEI container lifecycle (naming, idempotent reuse, health polling, graceful shutdown) + - ARM64/Apple Silicon Rosetta 2 emulation note + - Migration guide from StacklokLabs/mcp-optimizer + +13. **[vMCP Library Embedding](vmcp-library.md)** + - Library embedding pattern and `brood-box` reference implementation + - `pkg/vmcp/` stability table (Stable, Experimental, Internal per sub-package) + - Stability declaration convention and how to use the table as a reviewer + - Compatibility guarantees and semver-aligned deprecation policy + - Guidance for downstream embedders on pinning and upgrading + ### Existing Documentation For middleware architecture, see: **[docs/middleware.md](../middleware.md)** diff --git a/docs/arch/vmcp-library.md b/docs/arch/vmcp-library.md new file mode 100644 index 0000000000..2dc7142d70 --- /dev/null +++ b/docs/arch/vmcp-library.md @@ -0,0 +1,115 @@ +# vMCP Library Embedding + +## Overview + +The `pkg/vmcp/` packages provide a stable Go library for embedding vMCP functionality into downstream projects. The library is designed for import — not just for internal use — and `github.com/stacklok/brood-box` is the reference production embedder. + +## Why a Stability Table + +Downstream consumers like `brood-box` need predictability across ToolHive releases. Without explicit stability guarantees, any refactor in `pkg/vmcp/` could silently break embedders. The stability table below formalises the contract: **Stable** packages have semver-aligned compatibility guarantees; **Experimental** packages may change before stabilising; **Internal** packages are not for external use. + +## Library Embedding Pattern + +### Importing `pkg/vmcp/` + +```go +import ( + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/server" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + "github.com/stacklok/toolhive/pkg/vmcp/router" +) +``` + +The `pkg/vmcp/` root package (`github.com/stacklok/toolhive/pkg/vmcp`) contains only shared domain types (`types.go`, `errors.go`) and is always safe to import. + +### Reference Implementation: brood-box + +[`github.com/stacklok/brood-box`](https://github.com/stacklok/brood-box) embeds `pkg/vmcp/` under `internal/infra/mcp/`. It demonstrates the recommended pattern: + +1. Load a `vmcpconfig.Config` from YAML or programmatically. +2. Instantiate a `discovery.Manager`, `vmcp.BackendRegistry`, router, and backend client. +3. Build a `server.Server` via `server.New(ctx, cfg, router, backendClient, discoveryMgr, backendRegistry, workflowDefs)`. +4. Call `server.Start(ctx)` and `server.Stop(ctx)` for lifecycle management. + +This is the same path used by `pkg/vmcp/cli/serve.go` in the `thv vmcp serve` command; the library has no CLI-specific coupling. + +## `pkg/vmcp/` Stability Table + +The table below maps every sub-package to its stability level per RFC THV-0059. Verify against the merged RFC if there is a discrepancy. + +| Package | Stability | Notes | +|---------|-----------|-------| +| `pkg/vmcp` (root) | Stable | Shared domain types (`BackendTarget`, `Tool`, etc.) and errors; public API | +| `pkg/vmcp/config` | Stable | Config structs and YAML loader; `Config`, `BackendConfig`, `OptimizerConfig` | +| `pkg/vmcp/aggregator` | Stable | Backend discovery and capability merge; `Aggregator` interface | +| `pkg/vmcp/router` | Stable | Request routing and tool name translation; `Router` interface | +| `pkg/vmcp/server` | Stable | Server constructor and lifecycle; `New`, `Start`, `Stop` | +| `pkg/vmcp/session` | Stable | Session factory and per-session routing table | +| `pkg/vmcp/auth` | Stable | Incoming/outgoing auth interfaces; `IncomingAuthenticator`, `OutgoingAuthRegistry` | +| `pkg/vmcp/client` | Stable | Backend HTTP client; used for all backend MCP calls | +| `pkg/vmcp/health` | Stable | Health monitor; `HealthMonitor` interface and implementations | +| `pkg/vmcp/status` | Stable | `StatusReporter` interface; CLI and K8s reporter implementations | +| `pkg/vmcp/optimizer` | Experimental | Optimizer interface and TEI integration; tier API may evolve | +| `pkg/vmcp/cli` | Experimental | New in Phase 4; `Serve`, `Init`, `Validate` entry points may change before stabilisation | +| `pkg/vmcp/composer` | Experimental | Composite tool DAG executor; workflow API not yet stable | +| `pkg/vmcp/cache` | Internal | Token cache; not intended for external use | +| `pkg/vmcp/conversion` | Internal | CRD-to-config conversion; K8s-specific, not for local embedding | +| `pkg/vmcp/discovery` | Internal | Discovery middleware; use via aggregator, not directly | +| `pkg/vmcp/headerforward` | Internal | Per-backend HTTP header-forwarding round-tripper; the root `headerforward` package only; `pkg/vmcp/headerforward/wirefmt` is a separate wire-format helper used by aggregator/workloads/cli/operator | +| `pkg/vmcp/k8s` | Internal | Kubernetes-specific discovery; not for local embedding | +| `pkg/vmcp/workloads` | Internal | Backend workload helpers for K8s mode; not for local embedding | +| `pkg/vmcp/schema` | Internal | MCP schema parsing; subject to change | + +## Stability Declaration Convention + +The `pkg/vmcp/` sub-packages do not currently carry in-source stability annotations. The stability levels in the table above are derived from RFC THV-0059 and are documented here as the authoritative reference for downstream consumers. Reviewers should consult this table (and the RFC) when evaluating whether a proposed change to a `pkg/vmcp/` package constitutes a breaking change. + +## Compatibility Guarantees for Stable Packages + +For packages marked **Stable**: + +- **No breaking API changes** between patch and minor releases. +- **No import-path renames** without a compatibility shim and deprecation notice. +- **Deprecation policy**: a package or function is deprecated with a `// Deprecated:` comment for at least one minor release before removal. +- **Semver alignment**: breaking changes (if ever necessary) are reserved for major version bumps. + +For packages marked **Experimental**: + +- The API may change in any minor release. +- Changes will be noted in the release changelog. +- Callers should pin to a specific minor version until the package stabilises. + +For packages marked **Internal**: + +- No compatibility guarantees of any kind. +- These packages may be reorganised, merged, or removed at any time. + +## Guidance for Downstream Embedders + +### Pinning + +Pin to a specific ToolHive minor version in your `go.mod`: + +``` +require github.com/stacklok/toolhive v0.Y.Z +``` + +Watch the [ToolHive changelog](https://github.com/stacklok/toolhive/releases) for Experimental package changes before upgrading. + +### Upgrading + +1. Check the release notes for any changes to packages you import. +2. Run `go mod tidy` after updating the version. +3. Ensure your tests cover the vMCP integration paths so breaking changes are caught early. + +### What ToolHive Does Not Provide for Embedders + +- Goroutine leak protection in Experimental/Internal packages — test your shutdown paths. +- Guarantees about the behaviour of K8s-internal packages (`k8s`, `workloads`, `conversion`) outside a Kubernetes environment. + +## Related Documentation + +- [Local vMCP CLI Mode](vmcp-local.md) — `thv vmcp` CLI surface and optimizer lifecycle +- [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) — Kubernetes-side vMCP (CRD, operator) +- [Groups](07-groups.md) — ToolHive groups used as vMCP backend source diff --git a/docs/arch/vmcp-local.md b/docs/arch/vmcp-local.md new file mode 100644 index 0000000000..ca5568511b --- /dev/null +++ b/docs/arch/vmcp-local.md @@ -0,0 +1,201 @@ +# Local vMCP CLI Mode + +## Overview + +The `thv vmcp` subcommand lets users run a Virtual MCP Server (vMCP) locally without Kubernetes. It aggregates multiple MCP server backends from a ToolHive group into a single unified endpoint that any MCP client can connect to. + +```mermaid +graph TB + Client[MCP Client] -->|HTTP/SSE/Streamable-HTTP| vMCP[thv vmcp serve
pkg/vmcp/cli/serve.go] + vMCP -->|discover| Groups[ToolHive Groups
pkg/groups/] + vMCP -->|aggregate| B1[Backend MCP Server 1] + vMCP -->|aggregate| B2[Backend MCP Server 2] + vMCP -->|aggregate| BN[Backend MCP Server N] + vMCP -.->|optional| Optimizer[Optimizer
pkg/vmcp/optimizer/] + Optimizer -.->|Tier 2| TEI[TEI Container
thv-embedding-*] + + style vMCP fill:#90caf9 + style Optimizer fill:#81c784 + style TEI fill:#ffb74d + style Groups fill:#90caf9 +``` + +## Why This Exists + +The original vMCP deployment model required a Kubernetes cluster and a `VirtualMCPServer` CRD managed by the operator. This is well-suited for production multi-tenant environments but creates friction for local development and non-Kubernetes users. + +`thv vmcp` provides the same aggregation, tool routing, and optimizer capabilities without requiring a cluster. It runs as a foreground process driven by Cobra CLI flags, with a zero-config quick mode for the common case of aggregating a local ToolHive group. + +This path replaces the earlier Python [`StacklokLabs/mcp-optimizer`](https://github.com/StacklokLabs/mcp-optimizer) project (see [Migration from mcp-optimizer](#migration-from-stackloklabsmcp-optimizer)). + +## How It Works + +The `thv vmcp` command has three subcommands: + +| Subcommand | Purpose | +|------------|---------| +| `thv vmcp init` | Generate a starter YAML config from a running group | +| `thv vmcp validate` | Validate a YAML config for syntax and semantic errors | +| `thv vmcp serve` | Start the aggregated vMCP server | + +### Request Path + +```mermaid +sequenceDiagram + participant Client as MCP Client + participant Cobra as Cobra CLI
cmd/thv/app/vmcp.go + participant Serve as pkg/vmcp/cli/serve.go + participant Server as vMCP Server
pkg/vmcp/server/ + participant Agg as Aggregator
pkg/vmcp/aggregator/ + participant Backend as Backend MCP Server + + Client->>Cobra: thv vmcp serve [flags] + Cobra->>Serve: vmcpcli.Serve(ServeConfig{...}) + Serve->>Serve: Load or generate config + Serve->>Server: Build server with middleware chain + Server->>Agg: Discover and connect backends + Agg->>Backend: MCP initialize handshake + Backend-->>Agg: capabilities + Agg-->>Server: merged capability table + Server-->>Client: server ready on :4483 + Client->>Server: tools/list + Server->>Agg: route to backend(s) + Agg->>Backend: tools/list + Backend-->>Agg: tool list + Agg-->>Client: merged tool list +``` + +**Implementation**: `cmd/thv/app/vmcp.go`, `pkg/vmcp/cli/serve.go` + +## Key Components + +### Zero-Config Quick Mode + +When `--config` is omitted and `--group` is set, `thv vmcp serve` generates an in-memory YAML configuration from the named ToolHive group. No configuration file is required. + +Security requirement: in quick mode, `--host` is still honoured but `validateQuickModeHost()` rejects any value that is not a loopback address. Accepted values are an empty string (defaults to `127.0.0.1`), `"localhost"`, or any IP for which `net.IP.IsLoopback()` returns true (e.g. `::1`). Any non-loopback address is rejected to prevent an unauthenticated server from being exposed on the network. + +**Implementation**: `pkg/vmcp/cli/serve.go` — `generateQuickModeConfig()` + +### Config-File Mode + +The recommended workflow for reproducible or customized deployments: + +``` +thv vmcp init --group --output vmcp.yaml +# review and edit vmcp.yaml +thv vmcp validate --config vmcp.yaml +thv vmcp serve --config vmcp.yaml +``` + +`thv vmcp init` discovers running workloads in the given group and writes a starter YAML pre-populated with one `backends` entry per accessible workload. + +**Implementation**: `pkg/vmcp/cli/init.go` + +### Optimizer Tiers + +`thv vmcp serve` supports an optional tool optimizer that exposes `find_tool` and `call_tool` instead of passing all backend tools through to the client. This is useful when the aggregated tool count is large. + +| Tier | Flag(s) | Optimizer | External Service | Exposed Tools | +|------|---------|-----------|-----------------|---------------| +| 0 | (none) | None | None | All backend tools passed through | +| 1 | `--optimizer` | FTS5 keyword (SQLite in-process) | None | `find_tool`, `call_tool` only | +| 2 | `--optimizer-embedding` | FTS5 + TEI semantic | Managed TEI container | `find_tool`, `call_tool` only | +| 3 | `optimizer.embeddingService` in config YAML | FTS5 + external embedding service | User-managed | `find_tool`, `call_tool` only | + +Tier 2 (`--optimizer-embedding`) implies `--optimizer`. The TEI container is started automatically and stopped on server shutdown. + +**Implementation**: `pkg/vmcp/optimizer/optimizer.go`, `pkg/vmcp/cli/embedding_manager.go` + +### TEI Container Lifecycle (Tier 2) + +When `--optimizer-embedding` is set, ToolHive manages a HuggingFace Text Embeddings Inference (TEI) container for semantic search. + +```mermaid +sequenceDiagram + participant Serve as serve.go + participant EM as EmbeddingServiceManager
embedding_manager.go + participant RT as Container Runtime + participant TEI as TEI Container + + Serve->>EM: Start(ctx) + EM->>EM: containerNameForModel(model)
→ thv-embedding-<8-char-hash> + EM->>RT: inspect existing container + alt container exists and is running + RT-->>EM: running + EM->>EM: reuse; started=false (no ownership) + else container absent or stopped + EM->>RT: create container + RT->>TEI: start thv-embedding- + EM->>EM: poll /health with exponential backoff
(2s → 4s → 8s … max 30s, until ctx cancelled) + TEI-->>EM: 200 OK (model loaded) + EM->>EM: started=true (owns container) + end + EM-->>Serve: embedding URL + Serve->>Serve: run vMCP server + Serve->>EM: Stop(ctx) on shutdown + alt started==true + EM->>RT: stop container + else started==false + EM->>EM: no-op (container not owned) + end +``` + +**Container naming**: `thv-embedding-` where the hash is the first 8 hex characters of the SHA-256 of the model name. This avoids invalid container-name characters (e.g., slashes in `BAAI/bge-small-en-v1.5`). + +**Ownership tracking**: `EmbeddingServiceManager` sets an internal `started` flag only when it deploys the container itself (`deployContainer`). When it finds an already-running container and calls `reuseContainer`, `started` remains false. + +**Reuse semantics**: if a container with the correct name is already running when `thv vmcp serve` starts (e.g. left running by another process or a previous invocation that did not shut down cleanly), ToolHive reuses it and does not stop it on exit. In the normal case — where `thv vmcp serve` itself deployed the container — it will stop it on shutdown, so the next invocation will redeploy from scratch. + +**Health polling**: exponential backoff starting at 2 s, multiplier 2, cap at 30 s per interval. `pollHealth()` polls until the passed `context.Context` is cancelled — there is no built-in total-time budget. `thv vmcp serve` passes `cmd.Context()` without an additional deadline, so polling continues indefinitely until the user cancels (Ctrl-C) or the context is otherwise closed. + +**Graceful shutdown**: `EmbeddingServiceManager.Stop()` stops the TEI container only if this instance deployed it (`started == true`). It is a no-op when the container was reused from an external process. + +**Implementation**: `pkg/vmcp/cli/embedding_manager.go` + +#### ARM64 / Apple Silicon Note + +The default TEI image (`ghcr.io/huggingface/text-embeddings-inference:cpu-latest`) is published as an `amd64`-only image. On Apple Silicon Macs, Docker/OrbStack runs it via Rosetta 2 x86-64 emulation. This works but is slower than native. A future improvement may select an ARM64-native image automatically; for now, `cpu-latest` is the only supported CPU path. + +## Implementation + +Key files: + +| File | Role | +|------|------| +| `cmd/thv/app/vmcp.go` | Cobra command definitions; flag parsing | +| `pkg/vmcp/cli/serve.go` | `Serve()` entry point; config loading, optimizer wiring, server start | +| `pkg/vmcp/cli/init.go` | `Init()` entry point; workload discovery and YAML template generation | +| `pkg/vmcp/cli/validate.go` | `Validate()` entry point; config file validation | +| `pkg/vmcp/cli/embedding_manager.go` | TEI container lifecycle (Tier 2) | +| `pkg/vmcp/optimizer/optimizer.go` | `GetAndValidateConfig`, `NewOptimizerFactory` | +| `pkg/vmcp/config/config.go` | `Config` struct; `OptimizerConfig.EmbeddingService` for Tier 3 | + +## Migration from StacklokLabs/mcp-optimizer + +The Python [`StacklokLabs/mcp-optimizer`](https://github.com/StacklokLabs/mcp-optimizer) project is **deprecated** in favour of the Go-native `thv vmcp serve --optimizer`. The Go implementation ships in every ToolHive release, requires no separate Python environment, and is fully integrated with ToolHive's container and group management. + +### Feature Parity + +| mcp-optimizer feature | `thv vmcp` equivalent | +|-----------------------|-----------------------| +| Keyword (FTS5) search | `thv vmcp serve --optimizer` | +| Semantic (embedding) search | `thv vmcp serve --optimizer-embedding` | +| Custom embedding model | `--embedding-model ` | +| Custom TEI image | `--embedding-image ` | +| External embedding service | `optimizer.embeddingService` in config YAML (Tier 3) | + +### Migration Steps + +1. Stop the Python `mcp-optimizer` process. +2. Ensure ToolHive is up to date (`thv version`). +3. Run `thv vmcp init --group --output vmcp.yaml` to generate a config from your current group. +4. Start with `thv vmcp serve --group --optimizer` (quick mode) or `thv vmcp serve --config vmcp.yaml --optimizer` (config-file mode). +5. Update any MCP client configuration to point at the new `thv vmcp` endpoint (default `http://127.0.0.1:4483`). + +## Related Documentation + +- [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) — Kubernetes-side vMCP (CRD, operator, backend discovery) +- [vMCP Library Embedding](vmcp-library.md) — Embedding `pkg/vmcp/` in downstream Go projects +- [Groups](07-groups.md) — ToolHive groups used as vMCP backend source +- [Deployment Modes](01-deployment-modes.md) — Local vs Kubernetes deployment comparison diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 9f6b4ac6db..74824dfad8 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -42,6 +42,7 @@ thv [flags] * [thv group](thv_group.md) - Manage logical groupings of MCP servers * [thv inspector](thv_inspector.md) - Launches the MCP Inspector UI and connects it to the specified MCP server * [thv list](thv_list.md) - List running MCP servers +* [thv llm](thv_llm.md) - Manage LLM gateway authentication * [thv logs](thv_logs.md) - Output the logs of an MCP server or manage log files * [thv mcp](thv_mcp.md) - Interact with MCP servers for debugging * [thv proxy](thv_proxy.md) - Create a transparent proxy for an MCP server with authentication support @@ -56,5 +57,8 @@ thv [flags] * [thv start](thv_start.md) - Start (resume) a tooling server * [thv status](thv_status.md) - Show detailed status of an MCP server * [thv stop](thv_stop.md) - Stop one or more MCP servers +* [thv tui](thv_tui.md) - Open the interactive TUI dashboard (experimental) +* [thv upgrade](thv_upgrade.md) - Manage upgrades for MCP server workloads * [thv version](thv_version.md) - Show the version of ToolHive +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index 41c42e0a11..cb5e076cbc 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -28,6 +28,7 @@ Valid clients: - cline: VS Code Cline extension - codex: OpenAI Codex CLI - continue: Continue.dev IDE plugins + - copilot-cli: GitHub Copilot CLI - cursor: Cursor editor - factory: Factory.ai Droid CLI - gemini-cli: Google Gemini CLI @@ -37,7 +38,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor - - roo-code: VS Code Roo Code extension + - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code - vscode-insider: Visual Studio Code Insiders diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index f1d6044c01..57480144a9 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -28,6 +28,7 @@ Valid clients: - cline: VS Code Cline extension - codex: OpenAI Codex CLI - continue: Continue.dev IDE plugins + - copilot-cli: GitHub Copilot CLI - cursor: Cursor editor - factory: Factory.ai Droid CLI - gemini-cli: Google Gemini CLI @@ -37,7 +38,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor - - roo-code: VS Code Roo Code extension + - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code - vscode-insider: Visual Studio Code Insiders diff --git a/docs/cli/thv_list.md b/docs/cli/thv_list.md index c0a3acfc3d..4b76992187 100644 --- a/docs/cli/thv_list.md +++ b/docs/cli/thv_list.md @@ -41,6 +41,7 @@ thv list [flags] ``` -a, --all Show all workloads (default shows just running) + --check-upgrades Check each workload for available upgrades against its source registry (performs a registry lookup) --format string Output format (json, text, mcpservers) (default "text") --group string Filter by group -h, --help help for list diff --git a/docs/cli/thv_llm.md b/docs/cli/thv_llm.md new file mode 100644 index 0000000000..b39471c6f0 --- /dev/null +++ b/docs/cli/thv_llm.md @@ -0,0 +1,57 @@ +--- +title: thv llm +hide_title: true +description: Reference for ToolHive CLI command `thv llm` +last_update: + author: autogenerated +slug: thv_llm +mdx: + format: md +--- + +## thv llm + +Manage LLM gateway authentication + +### Synopsis + +Configure and manage authentication for OIDC-protected LLM gateways. + +The llm command bridges AI coding tools to LLM gateways by handling OIDC +authentication transparently. Two modes are supported: + + Proxy mode — a localhost reverse proxy injects fresh tokens for tools + that only accept static API keys (e.g. Cursor). + Token helper — "thv llm token" prints a fresh JWT suitable for use as + apiKeyHelper or auth.command in OIDC-capable tools + (e.g. Claude Code). + +To configure the gateway connection settings, use: + + thv llm config set --gateway-url https://llm.example.com \ + --issuer https://auth.example.com \ + --client-id my-client-id + +Use "thv llm config show" to view the current configuration. + +### Options + +``` + -h, --help help for llm +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv llm config](thv_llm_config.md) - Manage LLM gateway configuration +* [thv llm proxy](thv_llm_proxy.md) - Manage the LLM gateway localhost proxy +* [thv llm setup](thv_llm_setup.md) - Configure detected AI tools to use the LLM gateway +* [thv llm teardown](thv_llm_teardown.md) - Remove LLM gateway configuration from all (or one) configured tools +* [thv llm token](thv_llm_token.md) - Print a fresh LLM gateway access token to stdout + diff --git a/docs/cli/thv_llm_config.md b/docs/cli/thv_llm_config.md new file mode 100644 index 0000000000..bd8e654372 --- /dev/null +++ b/docs/cli/thv_llm_config.md @@ -0,0 +1,38 @@ +--- +title: thv llm config +hide_title: true +description: Reference for ToolHive CLI command `thv llm config` +last_update: + author: autogenerated +slug: thv_llm_config +mdx: + format: md +--- + +## thv llm config + +Manage LLM gateway configuration + +### Synopsis + +The config command provides subcommands to manage LLM gateway connection settings. + +### Options + +``` + -h, --help help for config +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm](thv_llm.md) - Manage LLM gateway authentication +* [thv llm config reset](thv_llm_config_reset.md) - Clear all LLM gateway configuration and cached tokens +* [thv llm config set](thv_llm_config_set.md) - Set LLM gateway connection settings +* [thv llm config show](thv_llm_config_show.md) - Display current LLM gateway configuration + diff --git a/docs/cli/thv_llm_config_reset.md b/docs/cli/thv_llm_config_reset.md new file mode 100644 index 0000000000..9b5402f9a3 --- /dev/null +++ b/docs/cli/thv_llm_config_reset.md @@ -0,0 +1,40 @@ +--- +title: thv llm config reset +hide_title: true +description: Reference for ToolHive CLI command `thv llm config reset` +last_update: + author: autogenerated +slug: thv_llm_config_reset +mdx: + format: md +--- + +## thv llm config reset + +Clear all LLM gateway configuration and cached tokens + +### Synopsis + +Remove all LLM gateway settings from config.yaml and delete cached OIDC +tokens from the secrets provider. + +``` +thv llm config reset [flags] +``` + +### Options + +``` + -h, --help help for reset +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm config](thv_llm_config.md) - Manage LLM gateway configuration + diff --git a/docs/cli/thv_llm_config_set.md b/docs/cli/thv_llm_config_set.md new file mode 100644 index 0000000000..2d5a89afbc --- /dev/null +++ b/docs/cli/thv_llm_config_set.md @@ -0,0 +1,52 @@ +--- +title: thv llm config set +hide_title: true +description: Reference for ToolHive CLI command `thv llm config set` +last_update: + author: autogenerated +slug: thv_llm_config_set +mdx: + format: md +--- + +## thv llm config set + +Set LLM gateway connection settings + +### Synopsis + +Persist LLM gateway connection settings to config.yaml. + +Example: + thv llm config set \ + --gateway-url https://llm.example.com \ + --issuer https://auth.example.com \ + --client-id my-client-id + +``` +thv llm config set [flags] +``` + +### Options + +``` + --audience string OIDC audience (optional) + --callback-port int OIDC callback port (omit to keep current; default: ephemeral) + --client-id string OIDC client ID + --gateway-url string LLM gateway base URL (must use HTTPS) + -h, --help help for set + --issuer string OIDC issuer URL + --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) + --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm config](thv_llm_config.md) - Manage LLM gateway configuration + diff --git a/docs/cli/thv_llm_config_show.md b/docs/cli/thv_llm_config_show.md new file mode 100644 index 0000000000..f1e48b7c86 --- /dev/null +++ b/docs/cli/thv_llm_config_show.md @@ -0,0 +1,36 @@ +--- +title: thv llm config show +hide_title: true +description: Reference for ToolHive CLI command `thv llm config show` +last_update: + author: autogenerated +slug: thv_llm_config_show +mdx: + format: md +--- + +## thv llm config show + +Display current LLM gateway configuration + +``` +thv llm config show [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for show +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm config](thv_llm_config.md) - Manage LLM gateway configuration + diff --git a/docs/cli/thv_llm_proxy.md b/docs/cli/thv_llm_proxy.md new file mode 100644 index 0000000000..bdb8a3f38b --- /dev/null +++ b/docs/cli/thv_llm_proxy.md @@ -0,0 +1,32 @@ +--- +title: thv llm proxy +hide_title: true +description: Reference for ToolHive CLI command `thv llm proxy` +last_update: + author: autogenerated +slug: thv_llm_proxy +mdx: + format: md +--- + +## thv llm proxy + +Manage the LLM gateway localhost proxy + +### Options + +``` + -h, --help help for proxy +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm](thv_llm.md) - Manage LLM gateway authentication +* [thv llm proxy start](thv_llm_proxy_start.md) - Start the LLM gateway localhost proxy + diff --git a/docs/cli/thv_llm_proxy_start.md b/docs/cli/thv_llm_proxy_start.md new file mode 100644 index 0000000000..77b9e58ee2 --- /dev/null +++ b/docs/cli/thv_llm_proxy_start.md @@ -0,0 +1,46 @@ +--- +title: thv llm proxy start +hide_title: true +description: Reference for ToolHive CLI command `thv llm proxy start` +last_update: + author: autogenerated +slug: thv_llm_proxy_start +mdx: + format: md +--- + +## thv llm proxy start + +Start the LLM gateway localhost proxy + +### Synopsis + +Start a localhost reverse proxy that injects fresh OIDC tokens for AI tools +that only accept static API keys (e.g. Cursor). + +The proxy runs in the foreground and blocks until interrupted (Ctrl+C). +To run it in the background, use your shell or a process manager: + + thv llm proxy start & + +``` +thv llm proxy start [flags] +``` + +### Options + +``` + -h, --help help for start + --tls-skip-verify Skip TLS certificate verification for the upstream gateway (overrides stored config; local dev only) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm proxy](thv_llm_proxy.md) - Manage the LLM gateway localhost proxy + diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md new file mode 100644 index 0000000000..285573b090 --- /dev/null +++ b/docs/cli/thv_llm_setup.md @@ -0,0 +1,65 @@ +--- +title: thv llm setup +hide_title: true +description: Reference for ToolHive CLI command `thv llm setup` +last_update: + author: autogenerated +slug: thv_llm_setup +mdx: + format: md +--- + +## thv llm setup + +Configure detected AI tools to use the LLM gateway + +### Synopsis + +Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, +Xcode) and patch each tool's configuration to route through the LLM gateway. + +Token-helper tools (Claude Code, Gemini CLI) are configured to call +"thv llm token" to obtain a fresh OIDC token on demand. + +Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to +the localhost reverse proxy started by "thv llm proxy start". + +Use --client to configure only a single named tool instead of all detected +tools. An error is returned if the named client is not installed. + +Inline flags (--gateway-url, --issuer, --client-id, etc.) are applied for this +run and persisted to config only after login and tool patching succeed. This +lets you combine "config set" and "setup" into a single command. + +Run "thv llm teardown" to revert all changes. + +``` +thv llm setup [flags] +``` + +### Options + +``` + --anthropic-path-prefix string Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools (e.g. /anthropic). When omitted, the gateway is probed automatically. + --audience string OIDC audience (optional) + --callback-port int OIDC callback port (omit to keep current; default: ephemeral) + --client string Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools. + --client-id string OIDC client ID + --gateway-url string LLM gateway base URL (must use HTTPS) + -h, --help help for setup + --issuer string OIDC issuer URL + --lazy Skip the interactive OIDC login and defer it until the first time a configured tool accesses the gateway. Tool config and persisted settings are written normally. Useful for unattended provisioning (e.g. an MDM profile). + --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) + --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only). For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, disabling TLS for ALL of that tool's outbound connections. For proxy-mode tools only the proxy-to-gateway connection is affected. +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm](thv_llm.md) - Manage LLM gateway authentication + diff --git a/docs/cli/thv_llm_teardown.md b/docs/cli/thv_llm_teardown.md new file mode 100644 index 0000000000..5a91facb85 --- /dev/null +++ b/docs/cli/thv_llm_teardown.md @@ -0,0 +1,45 @@ +--- +title: thv llm teardown +hide_title: true +description: Reference for ToolHive CLI command `thv llm teardown` +last_update: + author: autogenerated +slug: thv_llm_teardown +mdx: + format: md +--- + +## thv llm teardown + +Remove LLM gateway configuration from all (or one) configured tools + +### Synopsis + +Revert the configuration changes made by "thv llm setup" for all configured +tools, or for a single tool when tool-name is provided as a positional argument +or via --client. + +Use --purge-tokens to also remove cached OIDC tokens from the secrets provider. + +``` +thv llm teardown [tool-name] [flags] +``` + +### Options + +``` + --client string Remove configuration for only this AI tool by name (e.g. claude-code, cursor). Omit to revert all configured tools. + -h, --help help for teardown + --purge-tokens Also delete cached OIDC tokens from the secrets provider +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm](thv_llm.md) - Manage LLM gateway authentication + diff --git a/docs/cli/thv_llm_token.md b/docs/cli/thv_llm_token.md new file mode 100644 index 0000000000..a63735e0fd --- /dev/null +++ b/docs/cli/thv_llm_token.md @@ -0,0 +1,44 @@ +--- +title: thv llm token +hide_title: true +description: Reference for ToolHive CLI command `thv llm token` +last_update: + author: autogenerated +slug: thv_llm_token +mdx: + format: md +--- + +## thv llm token + +Print a fresh LLM gateway access token to stdout + +### Synopsis + +Print a fresh OIDC access token to stdout (all other output on stderr). +Intended for use as apiKeyHelper or auth.command in OIDC-capable AI tools. + +A cached or refreshable token is printed without prompting. If none exists +(for example after "thv llm setup --lazy"), the OIDC browser login flow is +launched automatically and the resulting token is printed once login completes. + +``` +thv llm token [flags] +``` + +### Options + +``` + -h, --help help for token +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv llm](thv_llm.md) - Manage LLM gateway authentication + diff --git a/docs/cli/thv_mcp.md b/docs/cli/thv_mcp.md index ca6131c317..386dd26996 100644 --- a/docs/cli/thv_mcp.md +++ b/docs/cli/thv_mcp.md @@ -32,6 +32,7 @@ The mcp command provides subcommands to interact with MCP (Model Context Protoco ### SEE ALSO * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv mcp call](thv_mcp_call.md) - Invoke a tool on an MCP server * [thv mcp list](thv_mcp_list.md) - List MCP server capabilities * [thv mcp serve](thv_mcp_serve.md) - 🧪 EXPERIMENTAL: Start an MCP server to control ToolHive diff --git a/docs/cli/thv_mcp_call.md b/docs/cli/thv_mcp_call.md new file mode 100644 index 0000000000..0973192fd2 --- /dev/null +++ b/docs/cli/thv_mcp_call.md @@ -0,0 +1,54 @@ +--- +title: thv mcp call +hide_title: true +description: Reference for ToolHive CLI command `thv mcp call` +last_update: + author: autogenerated +slug: thv_mcp_call +mdx: + format: md +--- + +## thv mcp call + +Invoke a tool on an MCP server + +### Synopsis + +Invoke a tool on an MCP server. The server is connected, initialized, +the tool is called with the supplied arguments, and the result is printed. + +Arguments are supplied as a JSON object via --args or --args-file. If neither +flag is set, the tool is called with an empty argument object. + +By default, the command exits with a non-zero status when the tool reports an +error (CallToolResult.IsError=true). Use --ignore-tool-error to exit zero in +that case; transport and protocol failures always exit non-zero. + +``` +thv mcp call [flags] +``` + +### Options + +``` + --args string Tool arguments as a JSON object literal + --args-file string Path to a file containing a JSON object of tool arguments (use '-' to read from stdin) + --format string Output format (json, text) (default "text") + -h, --help help for call + --ignore-tool-error Exit zero even when the tool reports an error (default is non-zero) + --server string MCP server URL or name from ToolHive registry (required) + --timeout duration Connection timeout (default 30s) + --transport string Transport type (auto, sse, streamable-http) (default "auto") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv mcp](thv_mcp.md) - Interact with MCP servers for debugging + diff --git a/docs/cli/thv_registry.md b/docs/cli/thv_registry.md index 1b1e780006..f983ba945b 100644 --- a/docs/cli/thv_registry.md +++ b/docs/cli/thv_registry.md @@ -32,6 +32,7 @@ Manage the MCP server registry, including listing and getting information about ### SEE ALSO * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv registry convert](thv_registry_convert.md) - Convert a legacy registry file to the upstream MCP format * [thv registry info](thv_registry_info.md) - Get information about an MCP server * [thv registry list](thv_registry_list.md) - List available MCP servers * [thv registry login](thv_registry_login.md) - Authenticate with the configured registry diff --git a/docs/cli/thv_registry_convert.md b/docs/cli/thv_registry_convert.md new file mode 100644 index 0000000000..7bfc6f8a2e --- /dev/null +++ b/docs/cli/thv_registry_convert.md @@ -0,0 +1,47 @@ +--- +title: thv registry convert +hide_title: true +description: Reference for ToolHive CLI command `thv registry convert` +last_update: + author: autogenerated +slug: thv_registry_convert +mdx: + format: md +--- + +## thv registry convert + +Convert a legacy registry file to the upstream MCP format + +### Synopsis + +Convert a legacy ToolHive registry JSON file to the upstream MCP registry format. + +Reads from --in (or stdin) and writes to --out (or stdout). Use --in-place to +overwrite the input file; a backup is written to .bak unless --no-backup +is set. + +``` +thv registry convert [flags] +``` + +### Options + +``` + -h, --help help for convert + --in string Input file (default: stdin) + --in-place Overwrite the input file (writes a .bak backup unless --no-backup is set) + --no-backup Do not write a .bak backup when using --in-place + --out string Output file (default: stdout) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv registry](thv_registry.md) - Manage MCP server registry + diff --git a/docs/cli/thv_registry_login.md b/docs/cli/thv_registry_login.md index e13c2c950e..ccbfb1baf2 100644 --- a/docs/cli/thv_registry_login.md +++ b/docs/cli/thv_registry_login.md @@ -32,6 +32,7 @@ thv registry login [flags] ### Options ``` + -p, --allow-private-ip Allow --registry to reference a private IP address (default false). Mirrors the flag on 'thv config set-registry'. --audience string OAuth audience parameter for registry authentication (optional) --client-id string OAuth client ID for registry authentication -h, --help help for login diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index d3c38dc4cb..b69d7b6cd1 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -178,6 +178,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --runtime-add-package stringArray Add additional packages to install in the builder and runtime stages (can be repeated) --runtime-image string Override the default base image for protocol schemes (e.g., golang:1.24-alpine, node:20-alpine, python:3.11-slim) --secret stringArray Specify a secret to be fetched from the secrets manager and set as an environment variable (format: NAME,target=TARGET) + --session-ttl duration Session inactivity timeout (e.g., 30m, 2h); zero uses the default (2h) --stateless Declare the server as stateless (POST-only, no SSE). Use for MCP servers implementing streamable-HTTP stateless mode. --target-host string Host to forward traffic to (only applicable to SSE or Streamable HTTP transport) (default "127.0.0.1") --target-port int Port for the container to expose (only applicable to SSE or Streamable HTTP transport) diff --git a/docs/cli/thv_serve.md b/docs/cli/thv_serve.md index c455142639..e7a0c95d87 100644 --- a/docs/cli/thv_serve.md +++ b/docs/cli/thv_serve.md @@ -41,7 +41,7 @@ thv serve [flags] --sentry-dsn string Sentry DSN for error tracking and distributed tracing (falls back to SENTRY_DSN env var) --sentry-environment string Sentry environment name, e.g. production or development (falls back to SENTRY_ENVIRONMENT env var) --sentry-traces-sample-rate float Sentry traces sample rate (0.0-1.0) for performance monitoring (default 1) - --socket string UNIX socket path to bind the server to (overrides host and port if provided) + --socket string UNIX socket path or, on Windows, a named pipe (\\.\pipe\) to bind the server to (overrides host and port if provided) ``` ### Options inherited from parent commands diff --git a/docs/cli/thv_tui.md b/docs/cli/thv_tui.md new file mode 100644 index 0000000000..a258fe1a8d --- /dev/null +++ b/docs/cli/thv_tui.md @@ -0,0 +1,62 @@ +--- +title: thv tui +hide_title: true +description: Reference for ToolHive CLI command `thv tui` +last_update: + author: autogenerated +slug: thv_tui +mdx: + format: md +--- + +## thv tui + +Open the interactive TUI dashboard (experimental) + +### Synopsis + +Launch the interactive terminal dashboard for managing MCP servers. + +The dashboard shows a real-time list of servers with live log streaming, +tool inspection, and registry browsing — all from a single terminal window. + +Key bindings: + ↑/↓/j/k navigate servers or tools + tab cycle panels: Logs → Info → Tools → Proxy Logs → Inspector + s stop selected server + r restart selected server + d d delete selected server (press d twice) + / filter server list, or search logs (on Logs/Proxy Logs panel) + n/N next/previous search match + f toggle log follow mode + ←/→ horizontal scroll in log panels + R open registry browser + enter open tool in inspector (from Tools panel) + space toggle JSON node collapse (in inspector response) + c copy response JSON to clipboard + y copy curl command to clipboard + u copy server URL to clipboard + i show tool description (in inspector) + ? show full help overlay + q/ctrl+c quit + +``` +thv tui [flags] +``` + +### Options + +``` + -h, --help help for tui +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers + diff --git a/docs/cli/thv_upgrade.md b/docs/cli/thv_upgrade.md new file mode 100644 index 0000000000..03998668eb --- /dev/null +++ b/docs/cli/thv_upgrade.md @@ -0,0 +1,41 @@ +--- +title: thv upgrade +hide_title: true +description: Reference for ToolHive CLI command `thv upgrade` +last_update: + author: autogenerated +slug: thv_upgrade +mdx: + format: md +--- + +## thv upgrade + +Manage upgrades for MCP server workloads + +### Synopsis + +Inspect and apply upgrades for registry-sourced MCP server workloads. + +Upgrade checks compare each workload's current image and configuration against +the metadata reported by its source registry. Checks are an offline metadata +comparison and never pull images. + +### Options + +``` + -h, --help help for upgrade +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv upgrade apply](thv_upgrade_apply.md) - Apply an available upgrade to a workload +* [thv upgrade check](thv_upgrade_check.md) - Check workloads for available upgrades + diff --git a/docs/cli/thv_upgrade_apply.md b/docs/cli/thv_upgrade_apply.md new file mode 100644 index 0000000000..da7d73d2e6 --- /dev/null +++ b/docs/cli/thv_upgrade_apply.md @@ -0,0 +1,67 @@ +--- +title: thv upgrade apply +hide_title: true +description: Reference for ToolHive CLI command `thv upgrade apply` +last_update: + author: autogenerated +slug: thv_upgrade_apply +mdx: + format: md +--- + +## thv upgrade apply + +Apply an available upgrade to a workload + +### Synopsis + +Apply the upgrade the registry reports for a registry-sourced workload. + +The candidate image is resolved, verified, and pulled BEFORE the existing +workload is touched. The existing workload is then stopped and replaced with one +running the candidate image; the rest of the workload's configuration (env vars, +secrets, posture, middleware) is preserved. There is no automatic rollback: if +recreation fails the previous workload is not restored, so recovery is a forward +operation. + +New environment variables the candidate declares can be supplied with --env and +--secret. When run interactively, missing required values are prompted for; with +--yes (or in a non-interactive shell) the command runs non-interactively and +fails if a required value is missing. + +Examples: + # Apply the available upgrade, prompting for confirmation + thv upgrade apply my-server + + # Apply non-interactively, supplying a new env var + thv upgrade apply my-server --yes --env NEW_FLAG=true + + # Preview what an upgrade would change without applying it + thv upgrade apply my-server --dry-run + +``` +thv upgrade apply [flags] +``` + +### Options + +``` + --ca-cert string Path to a custom CA certificate file to use when resolving the candidate image + --dry-run Print what the upgrade would change without applying it + -e, --env stringArray Environment variables to set on the upgraded workload (format: KEY=VALUE, repeatable) + -h, --help help for apply + --image-verification string Set image verification mode (warn, enabled, disabled) (default "warn") + --secret stringArray Secrets to set on the upgraded workload (format: NAME,target=TARGET, repeatable) + -y, --yes Skip the confirmation prompt and run non-interactively (fail if required values are missing) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv upgrade](thv_upgrade.md) - Manage upgrades for MCP server workloads + diff --git a/docs/cli/thv_upgrade_check.md b/docs/cli/thv_upgrade_check.md new file mode 100644 index 0000000000..be1f501d4e --- /dev/null +++ b/docs/cli/thv_upgrade_check.md @@ -0,0 +1,55 @@ +--- +title: thv upgrade check +hide_title: true +description: Reference for ToolHive CLI command `thv upgrade check` +last_update: + author: autogenerated +slug: thv_upgrade_check +mdx: + format: md +--- + +## thv upgrade check + +Check workloads for available upgrades + +### Synopsis + +Check whether registry-sourced workloads have a newer image available. + +With no arguments, all workloads are checked (including stopped ones) and a +summary table is printed. When a workload name is given, a detailed report for +that single workload is printed, including any new environment variables the +candidate image declares and any configuration (posture) drift. + +Examples: + # Check all workloads + thv upgrade check + + # Check a single workload with detailed output + thv upgrade check my-server + + # Check all workloads in JSON format + thv upgrade check --format json + +``` +thv upgrade check [workload-name] [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for check +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv upgrade](thv_upgrade.md) - Manage upgrades for MCP server workloads + diff --git a/docs/cli/thv_vmcp.md b/docs/cli/thv_vmcp.md new file mode 100644 index 0000000000..d9d0ece7f4 --- /dev/null +++ b/docs/cli/thv_vmcp.md @@ -0,0 +1,40 @@ +--- +title: thv vmcp +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp` +last_update: + author: autogenerated +slug: thv_vmcp +mdx: + format: md +--- + +## thv vmcp + +Run and manage a Virtual MCP Server locally + +### Synopsis + +The vmcp command provides subcommands to run and validate a Virtual MCP +Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP +servers from a ToolHive group into a single unified endpoint. + +### Options + +``` + -h, --help help for vmcp +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv vmcp init](thv_vmcp_init.md) - Generate a starter vMCP configuration file +* [thv vmcp serve](thv_vmcp_serve.md) - Start the Virtual MCP Server +* [thv vmcp validate](thv_vmcp_validate.md) - Validate a vMCP configuration file + diff --git a/docs/cli/thv_vmcp_init.md b/docs/cli/thv_vmcp_init.md new file mode 100644 index 0000000000..5f5c420e9e --- /dev/null +++ b/docs/cli/thv_vmcp_init.md @@ -0,0 +1,50 @@ +--- +title: thv vmcp init +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp init` +last_update: + author: autogenerated +slug: thv_vmcp_init +mdx: + format: md +--- + +## thv vmcp init + +Generate a starter vMCP configuration file + +### Synopsis + +Discover running workloads in a ToolHive group and generate a starter +vMCP YAML configuration file pre-populated with one backend entry per +accessible workload. + +The generated file can be reviewed and customized, then passed to +'thv vmcp validate --config' to check it and 'thv vmcp serve --config' +to start the aggregated server. + +If neither --output nor --config is provided, the generated YAML is written to stdout. + +``` +thv vmcp init [flags] +``` + +### Options + +``` + -c, --config string Output file path for the generated config; alias for --output + -g, --group string ToolHive group name to discover workloads from (required) + -h, --help help for init + -o, --output string Output file path for the generated config (default: stdout) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + diff --git a/docs/cli/thv_vmcp_serve.md b/docs/cli/thv_vmcp_serve.md new file mode 100644 index 0000000000..48243c7ee4 --- /dev/null +++ b/docs/cli/thv_vmcp_serve.md @@ -0,0 +1,57 @@ +--- +title: thv vmcp serve +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp serve` +last_update: + author: autogenerated +slug: thv_vmcp_serve +mdx: + format: md +--- + +## thv vmcp serve + +Start the Virtual MCP Server + +### Synopsis + +Start the Virtual MCP Server to aggregate and proxy multiple MCP servers. + +The server reads the configuration file specified by --config and starts +listening for MCP client connections, aggregating tools, resources, and +prompts from all configured backend MCP servers. + +When --config is omitted, --group enables zero-config quick mode: a minimal +in-memory configuration is generated from the named ToolHive group, so no +configuration file is needed for the common case of aggregating a local group. + +``` +thv vmcp serve [flags] +``` + +### Options + +``` + -c, --config string Path to vMCP configuration file + --embedding-image string TEI container image (Tier 2) (default "ghcr.io/huggingface/text-embeddings-inference:cpu-latest") + --embedding-model string HuggingFace model name for semantic search (Tier 2) (default "BAAI/bge-small-en-v1.5") + --enable-audit Enable audit logging with default configuration + --group string ToolHive group name (zero-config quick mode when --config is omitted) + -h, --help help for serve + --host string Host address to bind to (default "127.0.0.1") + --optimizer Enable FTS5 keyword optimizer (Tier 1): exposes find_tool and call_tool instead of all backend tools + --optimizer-embedding Enable managed TEI semantic optimizer (Tier 2); implies --optimizer + --port int Port to listen on (default 4483) + --session-ttl duration Session inactivity timeout (e.g., 30m, 2h); zero uses the default (30m) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + diff --git a/docs/cli/thv_vmcp_validate.md b/docs/cli/thv_vmcp_validate.md new file mode 100644 index 0000000000..0f88282be0 --- /dev/null +++ b/docs/cli/thv_vmcp_validate.md @@ -0,0 +1,44 @@ +--- +title: thv vmcp validate +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp validate` +last_update: + author: autogenerated +slug: thv_vmcp_validate +mdx: + format: md +--- + +## thv vmcp validate + +Validate a vMCP configuration file + +### Synopsis + +Validate the vMCP configuration file for syntax and semantic errors. + +This command checks YAML syntax, required field presence, middleware +configuration correctness, and backend configuration validity. Exits 0 +for valid configurations, non-zero with a descriptive error otherwise. + +``` +thv vmcp validate [flags] +``` + +### Options + +``` + -c, --config string Path to vMCP configuration file (required) + -h, --help help for validate +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + diff --git a/docs/middleware.md b/docs/middleware.md index 8fe2b1b687..19bb148cd1 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -357,7 +357,7 @@ thv config usage-metrics enable **Purpose**: Exchanges incoming JWT tokens for external service tokens using OAuth 2.0 Token Exchange (RFC 8693). -**Location**: `pkg/auth/tokenexchange/middleware.go` +**Location**: `pkg/oauthproto/tokenexchange/middleware.go` **Responsibilities**: - Extract claims from authenticated JWT tokens diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 90b415d976..77ff1ebac2 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -4,6 +4,7 @@ - [toolhive.stacklok.dev/audit](#toolhivestacklokdevaudit) - [toolhive.stacklok.dev/authtypes](#toolhivestacklokdevauthtypes) - [toolhive.stacklok.dev/config](#toolhivestacklokdevconfig) +- [toolhive.stacklok.dev/ratelimit](#toolhivestacklokdevratelimit) - [toolhive.stacklok.dev/telemetry](#toolhivestacklokdevtelemetry) - [toolhive.stacklok.dev/v1alpha1](#toolhivestacklokdevv1alpha1) - [toolhive.stacklok.dev/v1beta1](#toolhivestacklokdevv1beta1) @@ -56,6 +57,30 @@ _Appears in:_ ## toolhive.stacklok.dev/authtypes +#### auth.types.AwsStsConfig + + + +AwsStsConfig configures AWS STS authentication with SigV4 request signing. +This strategy exchanges incoming tokens for AWS STS temporary credentials. + + + +_Appears in:_ +- [auth.types.BackendAuthStrategy](#authtypesbackendauthstrategy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `region` _string_ | Region is the AWS region for the STS endpoint and service. | | | +| `service` _string_ | Service is the AWS service name for SigV4 signing. | | | +| `fallbackRoleArn` _string_ | FallbackRoleArn is the IAM role ARN to assume when no role mappings match. | | | +| `roleMappings` _[auth.types.RoleMapping](#authtypesrolemapping) array_ | RoleMappings defines claim-based role selection rules. | | | +| `roleClaim` _string_ | RoleClaim is the JWT claim to use for role mapping evaluation. | | | +| `sessionDuration` _integer_ | SessionDuration is the duration in seconds for the STS session. | | | +| `sessionNameClaim` _string_ | SessionNameClaim is the JWT claim to use for the role session name. | | | +| `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's token to use as the
web identity token for AssumeRoleWithWebIdentity. When set, the token is
looked up from Identity.UpstreamTokens instead of the request's
Authorization header. | | | + + #### auth.types.BackendAuthStrategy @@ -72,10 +97,11 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _string_ | Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject" | | | +| `type` _string_ | Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo" | | | | `headerInjection` _[auth.types.HeaderInjectionConfig](#authtypesheaderinjectionconfig)_ | HeaderInjection contains configuration for header injection auth strategy.
Used when Type = "header_injection". | | | | `tokenExchange` _[auth.types.TokenExchangeConfig](#authtypestokenexchangeconfig)_ | TokenExchange contains configuration for token exchange auth strategy.
Used when Type = "token_exchange". | | | | `upstreamInject` _[auth.types.UpstreamInjectConfig](#authtypesupstreaminjectconfig)_ | UpstreamInject contains configuration for upstream inject auth strategy.
Used when Type = "upstream_inject". | | | +| `awsSts` _[auth.types.AwsStsConfig](#authtypesawsstsconfig)_ | AwsSts contains configuration for AWS STS auth strategy.
Used when Type = "aws_sts". | | | #### auth.types.HeaderInjectionConfig @@ -97,6 +123,26 @@ _Appears in:_ | `headerValueEnv` _string_ | HeaderValueEnv is the environment variable name containing the header value.
The value will be resolved at runtime from this environment variable.
Either HeaderValue or HeaderValueEnv should be set, not both. | | | +#### auth.types.RoleMapping + + + +RoleMapping defines a rule for mapping JWT claims to IAM roles. +Mappings are evaluated in priority order (lower number = higher priority). + + + +_Appears in:_ +- [auth.types.AwsStsConfig](#authtypesawsstsconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `claim` _string_ | Claim is a simple claim value to match against the RoleClaim field. | | | +| `matcher` _string_ | Matcher is a CEL expression for complex matching against JWT claims. | | | +| `roleArn` _string_ | RoleArn is the IAM role ARN to assume when this mapping matches. | | | +| `priority` _integer_ | Priority determines evaluation order (lower values = higher priority).
Mirrors awssts.RoleMapping.Priority, which is *int because the role mapper
uses math.MaxInt for nil-priority semantics in effectivePriority. | | | + + #### auth.types.TokenExchangeConfig @@ -186,7 +232,11 @@ _Appears in:_ | --- | --- | --- | --- | | `type` _string_ | Type is the authz type: "cedar", "none" | | | | `policies` _string array_ | Policies contains Cedar policy definitions (when Type = "cedar"). | | | +| `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities. Required for
enterprise policies that rely on transitive relationships (e.g.
`ClaimGroup → PlatformRole`) — without it the Cedar authorizer is
constructed with an empty entity store and `in` checks against absent
entities silently evaluate to false. Defaults to "[]" when empty. | | Optional: \{\}
| | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP provider whose access
token should be used as the source of JWT claims for Cedar evaluation.
When empty, claims from the ToolHive-issued token are used.
Must match an upstream provider name configured in the embedded auth server
(e.g. "default", "github"). Only relevant when the embedded auth server is active. | | Optional: \{\}
| +| `groupClaimName` _string_ | GroupClaimName is the JWT claim key that contains group membership for
the principal. When set, takes priority over the well-known defaults
("groups", "roles", "cognito:groups"). Use this for IDPs that place
groups under a URI-style claim (e.g. "https://example.com/groups").
When empty, only the well-known claim names are checked. | | Optional: \{\}
| +| `roleClaimName` _string_ | RoleClaimName is the JWT claim key that contains role membership for the
principal. When set, the claim is extracted separately from GroupClaimName
and both are mapped to the configured group entity type. When empty, no
role extraction is performed. | | Optional: \{\}
| +| `groupEntityType` _string_ | GroupEntityType is the Cedar entity type name used for principal parent
UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
empty. Must match the entity type used in EntitiesJSON for transitive
`in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported. | | Optional: \{\}
| #### vmcp.config.CircuitBreakerConfig @@ -280,6 +330,7 @@ _Appears in:_ | `optimizer` _[vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig)_ | Optimizer configures the MCP optimizer for context optimization on large toolsets.
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions. | | Optional: \{\}
| | `sessionStorage` _[vmcp.config.SessionStorageConfig](#vmcpconfigsessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When provider is "redis", the operator injects Redis connection parameters
(address, db, keyPrefix) here. The Redis password is provided separately via
the THV_SESSION_REDIS_PASSWORD environment variable. | | Optional: \{\}
| | `sessionTTL` _[vmcp.config.Duration](#vmcpconfigduration)_ | SessionTTL configures the idle TTL for vMCP sessions.
When omitted, the server default is used. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
Optional: \{\}
| +| `rateLimiting` _[ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig)_ | RateLimiting defines rate limiting configuration for the Virtual MCP server.
Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\}
| #### vmcp.config.ConflictResolutionConfig @@ -695,6 +746,68 @@ _Appears in:_ +## toolhive.stacklok.dev/ratelimit + + +#### ratelimit.types.RateLimitBucket + + + +RateLimitBucket defines a token bucket configuration with a maximum capacity +and a refill period. Used by both shared and per-user rate limits. + + + +_Appears in:_ +- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) +- [ratelimit.types.ToolRateLimitConfig](#ratelimittypestoolratelimitconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `maxTokens` _integer_ | MaxTokens is the maximum number of tokens (bucket capacity).
This is also the burst size: the maximum number of requests that can be served
instantaneously before the bucket is depleted. | | Minimum: 1
Required: \{\}
| +| `refillPeriod` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#duration-v1-meta)_ | RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.
The effective refill rate is maxTokens / refillPeriod tokens per second.
Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). | | Required: \{\}
| + + +#### ratelimit.types.RateLimitConfig + + + +RateLimitConfig defines rate limiting configuration for an MCP server. +At least one of shared, perUser, or tools must be configured. + + + +_Appears in:_ +- [vmcp.config.Config](#vmcpconfigconfig) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\}
| +| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user
at the server level. Requires authentication to be enabled.
Each unique userID creates Redis keys that expire after 2x refillPeriod.
Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\}
| +| `tools` _[ratelimit.types.ToolRateLimitConfig](#ratelimittypestoolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.
Each entry applies additional rate limits to calls targeting a specific tool name.
A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\}
| + + +#### ratelimit.types.ToolRateLimitConfig + + + +ToolRateLimitConfig defines rate limits for a specific tool. +At least one of shared or perUser must be configured. + + + +_Appears in:_ +- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| +| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| +| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| + + + ## toolhive.stacklok.dev/telemetry @@ -759,6 +872,8 @@ _Appears in:_ - [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) - [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) - [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) +- [api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) +- [api.v1alpha1.MCPWebhookConfigList](#apiv1alpha1mcpwebhookconfiglist) - [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) - [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) - [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) @@ -810,6 +925,10 @@ _Appears in:_ + + + + @@ -866,6 +985,7 @@ _Appears in:_ | `roleClaim` _string_ | RoleClaim is the JWT claim to use for role mapping evaluation
Defaults to "groups" to match common OIDC group claims | groups | Optional: \{\}
| | `sessionDuration` _integer_ | SessionDuration is the duration in seconds for the STS session
Must be between 900 (15 minutes) and 43200 (12 hours)
Defaults to 3600 (1 hour) if not specified | 3600 | Maximum: 43200
Minimum: 900
Optional: \{\}
| | `sessionNameClaim` _string_ | SessionNameClaim is the JWT claim to use for role session name
Defaults to "sub" to use the subject claim | sub | Optional: \{\}
| +| `subjectProviderName` _string_ | SubjectProviderName is the name of the upstream provider whose access token
is used as the web identity token for STS AssumeRoleWithWebIdentity.
This field is used exclusively by VirtualMCPServer, where there is no
upstream swap middleware to replace the bearer token before the strategy runs.
When left empty and an embedded authorization server is configured on the
VirtualMCPServer, the controller automatically populates this field with
the first configured upstream provider name. Set it explicitly to override
that default or to select a specific provider when multiple upstreams are
configured.
When no embedded auth server is present, the bearer token from the incoming
request's Authorization header is used instead. | | Optional: \{\}
| #### api.v1beta1.AuditConfig @@ -957,6 +1077,9 @@ _Appears in:_ | `type` _string_ | Type is the type of authorization configuration | configMap | Enum: [configMap inline]
| | `configMap` _[api.v1beta1.ConfigMapAuthzRef](#apiv1beta1configmapauthzref)_ | ConfigMap references a ConfigMap containing authorization configuration
Only used when Type is "configMap" | | Optional: \{\}
| | `inline` _[api.v1beta1.InlineAuthzConfig](#apiv1beta1inlineauthzconfig)_ | Inline contains direct authorization configuration
Only used when Type is "inline" | | Optional: \{\}
| +| `groupClaimName` _string_ | GroupClaimName is the JWT claim key that contains group membership for the
principal. When set, takes priority over the well-known defaults
("groups", "roles", "cognito:groups"). Use this for IDPs that place
groups under a URI-style claim (e.g. "https://example.com/groups"). When
Type is "configMap", a group_claim_name entry in the referenced ConfigMap
is overridden by this field if both are set. | | MaxLength: 253
Optional: \{\}
| +| `roleClaimName` _string_ | RoleClaimName is the JWT claim key that contains role membership for the
principal. When set, the claim is extracted separately from GroupClaimName
and both are mapped to the configured GroupEntityType. When Type is
"configMap", a role_claim_name entry in the referenced ConfigMap is
overridden by this field if both are set. | | MaxLength: 253
Optional: \{\}
| +| `groupEntityType` _string_ | GroupEntityType is the Cedar entity type name used for principal parent
UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
empty. Must match the entity type used in the static entity store for
transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
Namespaced names (`Foo::Bar`) are not yet supported. When Type is
"configMap", a group_entity_type entry in the referenced ConfigMap is
overridden by this field if both are set. | | MaxLength: 63
Pattern: `^[A-Za-z_][A-Za-z0-9_]*$`
Optional: \{\}
| #### api.v1beta1.BackendAuthConfig @@ -1029,6 +1152,61 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the authorization configuration | authz.json | Optional: \{\}
| +#### api.v1beta1.DCRUpstreamConfig + + + +DCRUpstreamConfig configures RFC 7591 Dynamic Client Registration for an +OAuth 2.0 upstream. When present on an OAuth2 upstream, the authserver +performs registration at runtime to obtain client credentials, replacing +the need to pre-provision a ClientID. + +Exactly one of DiscoveryURL or RegistrationEndpoint must be set. DiscoveryURL +points at an RFC 8414 / OIDC Discovery document from which the registration +endpoint is resolved; RegistrationEndpoint is used directly when the upstream +does not publish discovery metadata. + +The XOR rule uses has() alone (not has() + size() > 0) to keep the rule's +estimated CEL cost under the apiserver's per-rule static budget. With +`omitempty` on both fields, an unset field is absent on the wire and has() +returns false; the explicit-empty-string edge case is rejected at reconcile +time by ValidateOAuth2DCRConfig. + + + +_Appears in:_ +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `discoveryUrl` _string_ | DiscoveryURL is the RFC 8414 / OIDC Discovery document URL. The resolver
issues a single GET against this URL (no well-known-path fallback) and
reads registration_endpoint, authorization_endpoint, token_endpoint,
token_endpoint_auth_methods_supported, and scopes_supported from the
response.
Mutually exclusive with RegistrationEndpoint.
HTTPS is required because the registration endpoint resolved from this
document carries the initial access token and the issued client_secret
(RFC 7591 §3, RFC 8414 §3). MaxLength is a defensive size cap (etcd
object budget, regex evaluation cost) and matches the conventional URL
length cap. | | MaxLength: 2048
Pattern: `^https://[^\s?#]+[^/\s?#]$`
Optional: \{\}
| +| `registrationEndpoint` _string_ | RegistrationEndpoint is the RFC 7591 registration endpoint URL used
directly, bypassing discovery. When using this field, the caller is
expected to also supply AuthorizationEndpoint, TokenEndpoint, and an
explicit Scopes list on the parent OAuth2UpstreamConfig.
Mutually exclusive with DiscoveryURL.
HTTPS is required because the registration endpoint carries the initial
access token and the issued client_secret (RFC 7591 §3, RFC 8414 §3).
MaxLength is a defensive size cap (etcd object budget, regex evaluation
cost) and matches the conventional URL length cap. | | MaxLength: 2048
Pattern: `^https://[^\s?#]+[^/\s?#]$`
Optional: \{\}
| +| `initialAccessTokenRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | InitialAccessTokenRef is an optional reference to a Kubernetes Secret
carrying an RFC 7591 §3 initial access token. When set, the resolver
presents the token value as a Bearer credential on the registration
request. Mirrors the ClientSecretRef pattern. | | Optional: \{\}
| +| `softwareId` _string_ | SoftwareID is the RFC 7591 "software_id" registration metadata value,
identifying the client software independent of any particular
registration instance. Typically a UUID or short identifier. | | MaxLength: 255
Optional: \{\}
| +| `softwareStatement` _string_ | SoftwareStatement is the RFC 7591 "software_statement" JWT asserting
metadata about the client software, signed by a party the authorization
server trusts.
Stored inline on the CR. The JWT is signed but not encrypted, so its
contents are visible to anyone with get/list/watch on this resource and
appear in etcd backups in plaintext. Treat the value as non-confidential
(signed attestation, not a secret). Operators that rotate software
statements like bearer credentials should keep them at the authorization
server side and rely on the registration endpoint's initial access
token (see InitialAccessTokenRef) instead of placing them on the CR.
Bounded to 16384 characters as a defensive size cap (etcd object
budget, regex evaluation cost). Real-world signed statements with
embedded x5c certificate chains, JWKS keys, or OIDC-Federation
trust-framework metadata routinely exceed 4 KB. | | MaxLength: 16384
Optional: \{\}
| + + + + +#### api.v1beta1.EmbeddedAuthServerCIMDConfig + + + +EmbeddedAuthServerCIMDConfig configures Client ID Metadata Document (CIMD) support +on the embedded authorization server. When enabled, the AS accepts HTTPS URLs as +client_id values and resolves them via the CIMD protocol, allowing clients such as +VS Code to authenticate without prior Dynamic Client Registration. + + + +_Appears in:_ +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled activates CIMD client lookup. When false (the default), the AS only
accepts client_id values that were registered via DCR. | false | | +| `cacheMaxSize` _integer_ | CacheMaxSize is the maximum number of CIMD documents held in the LRU cache.
Defaults to 256 when Enabled is true and this field is omitted. | | Minimum: 1
Optional: \{\}
| +| `cacheFallbackTtl` _string_ | CacheFallbackTTL is the fixed TTL applied to every cached CIMD document.
Cache-Control header parsing is not yet implemented; all entries use this value.
Format: Go duration string (e.g. "5m", "10m", "1h").
Defaults to 5 minutes when Enabled is true and this field is omitted. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| #### api.v1beta1.EmbeddedAuthServerConfig @@ -1052,7 +1230,10 @@ _Appears in:_ | `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
| | `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
| | `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.
The embedded auth server delegates authentication to these providers.
MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | MinItems: 1
Required: \{\}
| +| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| | `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
| +| `baselineClientScopes` _string array_ | BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be
included in every client registration. The embedded auth server unions these
scopes into the registered set returned by RFC 7591 Dynamic Client
Registration, so a client that narrows the `scope` field at /oauth/register
can still request the baseline scopes at /oauth/authorize. All values must
be present in the upstream-derived scopesSupported set; the auth server
fails to start if any value is missing.
Security: every client registered via /oauth/register will gain the
ability to request these scopes at /oauth/authorize, regardless of what
the client itself requested. Keep the baseline narrow (typically
"openid" and "offline_access"). Adding a privileged scope here — e.g.
"admin:read" — would grant it to every DCR-registered client, including
public clients like Claude Code, Cursor, and VS Code.
When cimd.enabled is true, every dynamically resolved CIMD client will
also gain the ability to request these scopes, including third-party
clients resolved from arbitrary HTTPS URLs. | | MaxItems: 10
items:MinLength: 1
items:Pattern: `^[\x21\x23-\x5B\x5D-\x7E]+$`
Optional: \{\}
| +| `cimd` _[api.v1beta1.EmbeddedAuthServerCIMDConfig](#apiv1beta1embeddedauthservercimdconfig)_ | CIMD configures Client ID Metadata Document support. When omitted, CIMD is disabled. | | Optional: \{\}
| #### api.v1beta1.EmbeddingResourceOverrides @@ -1169,7 +1350,7 @@ _Appears in:_ | `model` _string_ | Model is the HuggingFace embedding model to use (e.g., "sentence-transformers/all-MiniLM-L6-v2") | BAAI/bge-small-en-v1.5 | Optional: \{\}
| | `hfTokenSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token.
If provided, the secret value will be provided to the embedding server for authentication with huggingface. | | Optional: \{\}
| | `image` _string_ | Image is the container image for the embedding inference server.
Images must be from HuggingFace Text Embeddings Inference (https://github.com/huggingface/text-embeddings-inference). | ghcr.io/huggingface/text-embeddings-inference:cpu-latest | Optional: \{\}
| -| `imagePullPolicy` _string_ | ImagePullPolicy defines the pull policy for the container image | IfNotPresent | Enum: [Always Never IfNotPresent]
Optional: \{\}
| +| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#pullpolicy-v1-core)_ | ImagePullPolicy defines the pull policy for the container image | IfNotPresent | Enum: [Always Never IfNotPresent]
Optional: \{\}
| | `port` _integer_ | Port is the port to expose the embedding service on | 8080 | Maximum: 65535
Minimum: 1
| | `args` _string array_ | Args are additional arguments to pass to the embedding inference server | | Optional: \{\}
| | `env` _[api.v1beta1.EnvVar](#apiv1beta1envvar) array_ | Env are environment variables to set in the container | | Optional: \{\}
| @@ -1278,6 +1459,7 @@ _Appears in:_ | `embeddedAuthServer` | ExternalAuthTypeEmbeddedAuthServer is the type for embedded OAuth2/OIDC authorization server
This enables running an embedded auth server that delegates to upstream IDPs
| | `awsSts` | ExternalAuthTypeAWSSts is the type for AWS STS authentication
| | `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IDP access token as the Authorization: Bearer header
| +| `obo` | ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows.
This type requires a build with an OBO handler registered via
controllerutil.RegisterOBOHandler; an upstream-only build surfaces
status.conditions[Valid] = False with Reason: EnterpriseRequired
when an obo-typed MCPExternalAuthConfig is applied.
| #### api.v1beta1.HeaderForwardConfig @@ -1334,6 +1516,38 @@ _Appears in:_ | `valueSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ValueSecretRef references a Kubernetes Secret containing the header value | | Required: \{\}
| +#### api.v1beta1.IdentityFromTokenConfig + + + +IdentityFromTokenConfig extracts user identity (subject, name, email) directly from the +OAuth2 token-endpoint response body using gjson dot-notation paths. When configured on an +OAuth2UpstreamConfig, the embedded auth server skips the userinfo HTTP call entirely and +resolves identity from the token response. + +Paths use gjson dot-notation, where each segment is a JSON object key. For example, +"username" extracts a top-level field, and "authed_user.id" extracts a nested field. + +Trust-model warning: Identity claims extracted via this block are not cryptographically +verified — they are trusted only via the TLS connection to the token endpoint. Prefer +OIDC + ID token validation when verifiable claims are required. + +Subject uniqueness is scoped to the upstream provider entry. To keep identity namespaces +separate across multiple instances of the same provider (e.g., two Snowflake accounts), +use distinct upstream provider entries. + + + +_Appears in:_ +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `subjectPath` _string_ | SubjectPath is the dot-notation path to the subject (user ID) field in the token response.
Warning: claims read from the token response are trusted only via TLS, not
cryptographically verified; prefer OIDC ID tokens when verifiable claims are required.
Example: "authed_user.id" for Slack (top-level token-response field). For providers
whose token response embeds the access token as a JWT (e.g. Snowflake), use the
"@upstreamjwt" modifier to decode the payload, e.g. "access_token\|@upstreamjwt\|sub".
The "@upstreamjwt" modifier performs no signature verification either. | | MaxLength: 256
MinLength: 1
Required: \{\}
| +| `namePath` _string_ | NamePath is the dot-notation path to the display name field in the token response.
If not specified or if the path does not resolve to a string, the display name is omitted.
Omit the field entirely rather than setting it to an empty string. | | MaxLength: 256
MinLength: 1
Optional: \{\}
| +| `emailPath` _string_ | EmailPath is the dot-notation path to the email address field in the token response.
If not specified or if the path does not resolve to a string, the email is omitted.
Omit the field entirely rather than setting it to an empty string. | | MaxLength: 256
MinLength: 1
Optional: \{\}
| + + #### api.v1beta1.IncomingAuthConfig @@ -1356,7 +1570,11 @@ _Appears in:_ -InlineAuthzConfig contains direct authorization configuration +InlineAuthzConfig contains direct authorization configuration. + +Source-agnostic Cedar JWT-claim mapping settings (GroupClaimName, +RoleClaimName, GroupEntityType) live on the parent AuthzConfigRef so they +work the same way for inline and configMap-sourced authz. @@ -1366,8 +1584,8 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `policies` _string array_ | Policies is a list of Cedar policy strings | | MinItems: 1
Required: \{\}
| -| `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities | [] | Optional: \{\}
| -| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP provider whose access
token should be used as the source of JWT claims for Cedar evaluation.
When empty, claims from the ToolHive-issued token are used.
Must match an upstream provider name configured in the embedded auth server
(e.g. "default", "github"). Only relevant when the embedded auth server is active. | | Optional: \{\}
| +| `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities. Required when
transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
entity store; defaults to "[]". | [] | Optional: \{\}
| +| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token's
claims Cedar should evaluate.
Deprecated: on VirtualMCPServer this field has moved to
spec.authServerConfig.primaryUpstreamProvider. The old location is
still read for one release for backward compatibility; the
VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
Warning event whenever it is consumed, and removal is planned for the
release after the deprecation cycle.
On MCPServer and MCPRemoteProxy this field has always been a structural
no-op (those CRDs do not run an embedded auth server). Setting it
continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
condition; the deprecation does not change that behaviour. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| #### api.v1beta1.InlineOIDCSharedConfig @@ -1480,13 +1698,14 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject]
Required: \{\}
| +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | Optional: \{\}
| | `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | Optional: \{\}
| | `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication
Only used when Type is "bearerToken" | | Optional: \{\}
| | `embeddedAuthServer` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server
Only used when Type is "embeddedAuthServer" | | Optional: \{\}
| | `awsSts` _[api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing
Only used when Type is "awsSts" | | Optional: \{\}
| | `upstreamInject` _[api.v1beta1.UpstreamInjectSpec](#apiv1beta1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.
Only used when Type is "upstreamInject". | | Optional: \{\}
| +| `obo` _[api.v1beta1.OBOConfig](#apiv1beta1oboconfig)_ | OBO configures On-Behalf-Of (OBO) authentication.
Only used when Type is "obo". The inner schema is intentionally empty in
this revision; sub-fields land in a follow-up. Setting this field on an
upstream-only build will cause the MCPExternalAuthConfig to transition to
status.conditions[Valid] = False with Reason: EnterpriseRequired. | | Optional: \{\}
| #### api.v1beta1.MCPExternalAuthConfigStatus @@ -1505,6 +1724,7 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPExternalAuthConfig's state | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig.
It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. | | Optional: \{\}
| | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\}
| +| `referenceCount` _integer_ | ReferenceCount is the number of workloads referencing this config. | | Optional: \{\}
| | `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPExternalAuthConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| @@ -1749,6 +1969,7 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPOIDCConfig's state | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPOIDCConfig. | | Optional: \{\}
| | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\}
| +| `referenceCount` _integer_ | ReferenceCount is the number of workloads referencing this config. | | Optional: \{\}
| | `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPOIDCConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| @@ -1756,7 +1977,11 @@ _Appears in:_ -MCPRegistry is the Schema for the mcpregistries API +MCPRegistry is the Schema for the mcpregistries API. + +The MCPRegistry CRD is deprecated and will be removed in a future release. +Install the ToolHive registry server via the toolhive-registry-server Helm chart +instead: https://github.com/stacklok/toolhive-registry-server @@ -1833,6 +2058,7 @@ _Appears in:_ | `pgpassSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | PGPassSecretRef references a Secret containing a pre-created pgpass file.
Why this is a dedicated field instead of a regular volume/volumeMount:
PostgreSQL's libpq rejects pgpass files that aren't mode 0600. Kubernetes
secret volumes mount files as root-owned, and the registry-api container
runs as non-root (UID 65532). A root-owned 0600 file is unreadable by
UID 65532, and using fsGroup changes permissions to 0640 which libpq also
rejects. The only solution is an init container that copies the file to an
emptyDir as the app user and runs chmod 0600. This cannot be expressed
through volumes/volumeMounts alone -- it requires an init container, two
extra volumes (secret + emptyDir), a subPath mount, and an environment
variable, all wired together correctly.
When specified, the operator generates all of that plumbing invisibly.
The user creates the Secret with pgpass-formatted content; the operator
handles only the Kubernetes permission mechanics.
Example Secret:
apiVersion: v1
kind: Secret
metadata:
name: my-pgpass
stringData:
.pgpass: \|
postgres:5432:registry:db_app:mypassword
postgres:5432:registry:db_migrator:otherpassword
Then reference it:
pgpassSecretRef:
name: my-pgpass
key: .pgpass | | Optional: \{\}
| | `displayName` _string_ | DisplayName is a human-readable name for the registry. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the registry API server.
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the registry API server runs in, you must specify
the `registry-api` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| +| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the registry API workload.
These are applied to both the registry-api Deployment's PodSpec.ImagePullSecrets
and to the operator-managed ServiceAccount the registry API runs as, so private
images are pullable through either path.
Use this field for new manifests.
Important: this is the ONLY way to attach image-pull credentials to the
operator-managed ServiceAccount. The legacy
spec.podTemplateSpec.spec.imagePullSecrets path populates the Deployment's pod
spec ONLY — it does NOT touch the ServiceAccount. On managed Kubernetes
platforms that rely on ServiceAccount-level credential injection (for example
GKE Workload Identity, OpenShift's per-SA dockercfg secrets, EKS IRSA), using
only the legacy PodTemplateSpec path can fail to pull private images even when
the secret exists in the namespace. Always set spec.imagePullSecrets when
SA-level credentials matter.
Precedence with PodTemplateSpec:
- This field is applied first as the controller-generated default.
- Values set under spec.podTemplateSpec.spec.imagePullSecrets are user overrides
and win on overlap. If the user supplies imagePullSecrets via PodTemplateSpec,
those replace the default list on the Deployment (the list is treated atomically).
- The ServiceAccount is always populated from this field — PodTemplateSpec does not
affect the ServiceAccount.
An omitted field and an explicitly empty list are equivalent: both leave the
ServiceAccount's existing ImagePullSecrets unchanged. This preserves
platform-managed pull secrets (for example OpenShift's per-SA dockercfg
entries) when overlays or patches emit an empty list. Truly clearing the
ServiceAccount's pull secrets requires recreating the resource. | | Optional: \{\}
| #### api.v1beta1.MCPRegistryStatus @@ -1935,7 +2161,7 @@ _Appears in:_ | `remoteUrl` _string_ | RemoteURL is the URL of the remote MCP server to proxy | | Pattern: `^https?://`
Required: \{\}
| | `proxyPort` _integer_ | ProxyPort is the port to expose the MCP proxy on | 8080 | Maximum: 65535
Minimum: 1
| | `transport` _string_ | Transport is the transport method for the remote proxy (sse or streamable-http) | streamable-http | Enum: [sse streamable-http]
| -| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| +| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource.
SECURITY: if this field is omitted and no other authentication source is configured,
the proxy runs UNAUTHENTICATED. It accepts every request that can reach its port and
forwards it to the remote MCP server under a synthetic local-user identity, with no
token or credential check. Set this field to enforce identity-based access control
per request. | | Optional: \{\}
| | `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange.
When specified, the proxy will exchange validated incoming tokens for remote service tokens.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. | | Optional: \{\}
| | `authServerRef` _[api.v1beta1.AuthServerRef](#apiv1beta1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded
OAuth 2.0/OIDC authorization server to authenticate MCP clients.
Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\}
| | `headerForward` _[api.v1beta1.HeaderForwardConfig](#apiv1beta1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.
Use this to add custom headers like X-Tenant-ID or correlation IDs. | | Optional: \{\}
| @@ -2174,11 +2400,12 @@ _Appears in:_ | `permissionProfile` _[api.v1beta1.PermissionProfileRef](#apiv1beta1permissionprofileref)_ | PermissionProfile defines the permission profile to use | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the MCP server runs in, you must specify
the `mcp` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| -| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| +| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource.
SECURITY: if this field is omitted and no other authentication source is configured,
the proxy runs UNAUTHENTICATED. It accepts every request that can reach its port and
forwards it to the MCP server under a synthetic local-user identity, with no token or
credential check. Set this field to enforce identity-based access control per request. | | Optional: \{\}
| | `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server | | Optional: \{\}
| | `audit` _[api.v1beta1.AuditConfig](#apiv1beta1auditconfig)_ | Audit defines audit logging configuration for the MCP server | | Optional: \{\}
| | `toolConfigRef` _[api.v1beta1.ToolConfigRef](#apiv1beta1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | Optional: \{\}
| +| `webhookConfigRef` _[api.v1beta1.WebhookConfigRef](#apiv1beta1webhookconfigref)_ | WebhookConfigRef references a MCPWebhookConfig resource for webhook middleware configuration.
The referenced MCPWebhookConfig must exist in the same namespace as this MCPServer. | | Optional: \{\}
| | `authServerRef` _[api.v1beta1.AuthServerRef](#apiv1beta1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded
OAuth 2.0/OIDC authorization server to authenticate MCP clients.
Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\}
| | `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| @@ -2188,7 +2415,7 @@ _Appears in:_ | `replicas` _integer_ | Replicas is the desired number of proxy runner (thv run) pod replicas.
MCPServer creates two separate Deployments: one for the proxy runner and one
for the MCP server backend. This field controls the proxy runner Deployment.
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| | `backendReplicas` _integer_ | BackendReplicas is the desired number of MCP server backend pod replicas.
This controls the backend Deployment (the MCP server container itself),
independent of the proxy runner controlled by Replicas.
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| | `sessionStorage` _[api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When nil, no session storage is configured. | | Optional: \{\}
| -| `rateLimiting` _[api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig)_ | RateLimiting defines rate limiting configuration for the MCP server.
Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\}
| +| `rateLimiting` _[ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig)_ | RateLimiting defines rate limiting configuration for the MCP server.
Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\}
| #### api.v1beta1.MCPServerStatus @@ -2211,6 +2438,7 @@ _Appears in:_ | `authServerConfigHash` _string_ | AuthServerConfigHash is the hash of the referenced authServerRef spec,
used to detect configuration changes and trigger reconciliation. | | Optional: \{\}
| | `oidcConfigHash` _string_ | OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection | | Optional: \{\}
| | `telemetryConfigHash` _string_ | TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection | | Optional: \{\}
| +| `webhookConfigHash` _string_ | WebhookConfigHash is the hash of the referenced MCPWebhookConfig spec | | Optional: \{\}
| | `url` _string_ | URL is the URL where the MCP server can be accessed | | Optional: \{\}
| | `phase` _[api.v1beta1.MCPServerPhase](#apiv1beta1mcpserverphase)_ | Phase is the current phase of the MCPServer | | Enum: [Pending Ready Failed Terminating Stopped]
Optional: \{\}
| | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\}
| @@ -2431,9 +2659,44 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPToolConfig's state | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPToolConfig.
It corresponds to the MCPToolConfig's generation, which is updated on mutation by the API Server. | | Optional: \{\}
| | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\}
| +| `referenceCount` _integer_ | ReferenceCount is the number of workloads referencing this config. | | Optional: \{\}
| | `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| +#### api.v1beta1.MCPWebhookConfigSpec + + + +MCPWebhookConfigSpec defines the desired state of MCPWebhookConfig + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `validating` _[api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) array_ | Validating webhooks are called to approve or deny MCP requests. | | Optional: \{\}
| +| `mutating` _[api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) array_ | Mutating webhooks are called to transform MCP requests before processing. | | Optional: \{\}
| + + +#### api.v1beta1.MCPWebhookConfigStatus + + + +MCPWebhookConfigStatus defines the observed state of MCPWebhookConfig + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations | | Optional: \{\}
| +| `observedGeneration` _integer_ | ObservedGeneration is the last observed generation corresponding to the current status | | Optional: \{\}
| +| `configHash` _string_ | ConfigHash is a hash of the spec, used for detecting changes | | Optional: \{\}
| +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPWebhookConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| + + #### api.v1beta1.ModelCacheConfig @@ -2477,6 +2740,27 @@ _Appears in:_ OAuth2UpstreamConfig contains configuration for pure OAuth 2.0 providers. OAuth 2.0 providers require explicit endpoint configuration. +Exactly one of ClientID or DCRConfig must be set: ClientID is used when the +client is pre-provisioned out of band, DCRConfig enables RFC 7591 Dynamic +Client Registration at runtime. + +ClientSecretRef is mutually exclusive with DCRConfig: when DCRConfig is set, +the client_secret is obtained from the registration response (RFC 7591 +§3.2.1) and any static ClientSecretRef would be either dead config or a +competing source of truth. The XValidation rule below rejects the +combination at admission; ValidateOAuth2DCRConfig is the matching +reconcile-time backstop. + +Layered XOR behavior: the ClientID/DCRConfig rule treats `clientId: ""` as +absent (size>0) but treats `dcrConfig: {}` as present (has() is true for an +empty object). For input `{ clientId: "", dcrConfig: {} }` the outer rule +passes and the inner DCRUpstreamConfig XOR fires with "exactly one of +discoveryUrl or registrationEndpoint must be set". This is intentional — +adding a non-empty subfield check (e.g., +`has(self.dcrConfig.discoveryUrl) || has(self.dcrConfig.registrationEndpoint)`) +would inflate CEL cost on an already-budget-bound rule, and the inner +message is still actionable. + _Appears in:_ @@ -2486,13 +2770,34 @@ _Appears in:_ | --- | --- | --- | --- | | `authorizationEndpoint` _string_ | AuthorizationEndpoint is the URL for the OAuth authorization endpoint. | | Pattern: `^https?://.*$`
Required: \{\}
| | `tokenEndpoint` _string_ | TokenEndpoint is the URL for the OAuth token endpoint. | | Pattern: `^https?://.*$`
Required: \{\}
| -| `userInfo` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.
Required for OAuth2 providers to resolve user identity. | | Required: \{\}
| -| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\}
| +| `userInfo` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.
When omitted and IdentityFromToken is also unset, the embedded auth server runs in
synthesis mode for this upstream: a non-PII subject derived from the access token, no
Name/Email. Use this shape for upstreams with no userinfo surface and no identity in
the token response (e.g., MCP authorization servers per the MCP spec). When
IdentityFromToken is set instead, identity is resolved from the token response body
(e.g., Snowflake's "username" field, Slack's "authed_user.id"); the userinfo HTTP call
is skipped entirely. | | Optional: \{\}
| +| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.
Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained
at runtime via RFC 7591 Dynamic Client Registration and must be left empty. | | Optional: \{\}
| | `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
Optional for public clients using PKCE instead of client secret. | | Optional: \{\}
| | `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| | `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP. | | Optional: \{\}
| -| `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\}
| +| `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used.
For extracting user identity from the token response, see IdentityFromToken. | | Optional: \{\}
| +| `identityFromToken` _[api.v1beta1.IdentityFromTokenConfig](#apiv1beta1identityfromtokenconfig)_ | IdentityFromToken extracts user identity (subject, name, email) directly
from the OAuth2 token-endpoint response body using gjson dot-notation paths.
When set, the embedded auth server skips the userinfo HTTP call entirely
and resolves identity from the token response. See IdentityFromTokenConfig
for trust-model and uniqueness considerations. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| +| `dcrConfig` _[api.v1beta1.DCRUpstreamConfig](#apiv1beta1dcrupstreamconfig)_ | DCRConfig enables RFC 7591 Dynamic Client Registration against the upstream
authorization server. When set, the client credentials are obtained at
runtime rather than being pre-provisioned, and ClientID must be left empty.
Mutually exclusive with ClientID. | | Optional: \{\}
| + + +#### api.v1beta1.OBOConfig + + + +OBOConfig is a placeholder for On-Behalf-Of (OBO) external auth configuration. +The inner schema is intentionally empty in this revision; sub-fields land in a +follow-up RFC. The struct exists so OBO *OBOConfig compiles and the CRD +schema admits `spec.obo: {}` — the CEL rule "obo configuration must be set +if and only if type is 'obo'" requires has(self.obo), which evaluates true +for an empty object. Stored objects with `obo: {}` will round-trip cleanly +when sub-fields land, because Go zero values fill in. + + + +_Appears in:_ +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) + #### api.v1beta1.OIDCUpstreamConfig @@ -2643,42 +2948,8 @@ _Appears in:_ | `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the proxy runner
These are applied to both the Deployment and the ServiceAccount | | Optional: \{\}
| -#### api.v1beta1.RateLimitBucket - - - -RateLimitBucket defines a token bucket configuration with a maximum capacity -and a refill period. Used by both shared (global) and per-user rate limits. - - - -_Appears in:_ -- [api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig) -- [api.v1beta1.ToolRateLimitConfig](#apiv1beta1toolratelimitconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `maxTokens` _integer_ | MaxTokens is the maximum number of tokens (bucket capacity).
This is also the burst size: the maximum number of requests that can be served
instantaneously before the bucket is depleted. | | Minimum: 1
Required: \{\}
| -| `refillPeriod` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#duration-v1-meta)_ | RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.
The effective refill rate is maxTokens / refillPeriod tokens per second.
Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). | | Required: \{\}
| - - -#### api.v1beta1.RateLimitConfig - - - -RateLimitConfig defines rate limiting configuration for an MCP server. -At least one of shared, perUser, or tools must be configured. - - -_Appears in:_ -- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `shared` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\}
| -| `perUser` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user
at the server level. Requires authentication to be enabled.
Each unique userID creates Redis keys that expire after 2x refillPeriod.
Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\}
| -| `tools` _[api.v1beta1.ToolRateLimitConfig](#apiv1beta1toolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.
Each entry applies additional rate limits to calls targeting a specific tool name.
A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\}
| #### api.v1beta1.RedisACLUserConfig @@ -2694,7 +2965,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `usernameSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | UsernameSecretRef references a Secret containing the Redis ACL username. | | Required: \{\}
| +| `usernameSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | UsernameSecretRef references a Secret containing the Redis ACL username.
When omitted, connections use legacy password-only AUTH. Omit for managed
Redis tiers that do not support ACL users (e.g. GCP Memorystore Basic/Standard
HA, Azure Cache for Redis). Set for services that support ACL users (e.g. AWS
ElastiCache non-cluster with Redis 6+ RBAC). | | Optional: \{\}
| | `passwordSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | PasswordSecretRef references a Secret containing the Redis ACL password. | | Required: \{\}
| @@ -2721,8 +2992,13 @@ _Appears in:_ -RedisStorageConfig configures Redis-compatible storage for auth server state. -Use Address for a direct backend such as DragonflyDB, or SentinelConfig for HA Redis. +RedisStorageConfig configures Redis connection for auth server storage. +Exactly one of addr or sentinelConfig must be set. Set clusterMode to true when +addr points to a Redis Cluster discovery endpoint (GCP Memorystore Cluster, +AWS ElastiCache cluster mode enabled). + +DocPlanner fork: the pre-v0.27 field name `address` is still accepted as a +deprecated alias of `addr` until every environment has migrated (PAIINFRA-228). @@ -2731,14 +3007,16 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `address` _string_ | Address is the Redis-compatible server address for direct mode (host:port).
Mutually exclusive with SentinelConfig. | | Optional: \{\}
| -| `sentinelConfig` _[api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration for HA mode.
Mutually exclusive with Address. | | Optional: \{\}
| +| `addr` _string_ | Addr is the Redis server address (host:port). Required for standalone and cluster modes.
Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
Mutually exclusive with sentinelConfig. | | Optional: \{\}
| +| `address` _string_ | Address is a deprecated alias of addr, kept by the DocPlanner fork for
configurations written before upstream renamed the field. Use addr instead.
Deprecated: use Addr. | | Optional: \{\}
| +| `clusterMode` _boolean_ | ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
cluster mode enabled). Requires addr to be set. | | Optional: \{\}
| +| `sentinelConfig` _[api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration.
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr. | | Optional: \{\}
| | `aclUserConfig` _[api.v1beta1.RedisACLUserConfig](#apiv1beta1redisacluserconfig)_ | ACLUserConfig configures Redis ACL user authentication. | | Required: \{\}
| | `dialTimeout` _string_ | DialTimeout is the timeout for establishing connections.
Format: Go duration string (e.g., "5s", "1m"). | 5s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| | `readTimeout` _string_ | ReadTimeout is the timeout for socket reads.
Format: Go duration string (e.g., "3s", "1m"). | 3s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| | `writeTimeout` _string_ | WriteTimeout is the timeout for socket writes.
Format: Go duration string (e.g., "3s", "1m"). | 3s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| -| `tls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | TLS configures TLS for connections to the Redis/Valkey master.
Presence of this field enables TLS. Omit to use plaintext. | | Optional: \{\}
| -| `sentinelTls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | SentinelTLS configures TLS for connections to Sentinel instances.
Presence of this field enables TLS. Omit to use plaintext.
When omitted, sentinel connections use plaintext (no fallback to TLS config). | | Optional: \{\}
| +| `tls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
Presence of this field enables TLS. Omit to use plaintext. | | Optional: \{\}
| +| `sentinelTls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | SentinelTLS configures TLS for connections to Sentinel instances.
Only applies when sentinelConfig is set. Presence of this field enables TLS. | | Optional: \{\}
| #### api.v1beta1.RedisTLSConfig @@ -2865,6 +3143,7 @@ SecretKeyRef is a reference to a key within a Secret _Appears in:_ - [api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig) +- [api.v1beta1.DCRUpstreamConfig](#apiv1beta1dcrupstreamconfig) - [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) - [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) - [api.v1beta1.HeaderFromSecret](#apiv1beta1headerfromsecret) @@ -2877,6 +3156,8 @@ _Appears in:_ - [api.v1beta1.SensitiveHeader](#apiv1beta1sensitiveheader) - [api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig) - [api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig) +- [api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) +- [api.v1beta1.WebhookTLSConfig](#apiv1beta1webhooktlsconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2969,7 +3250,7 @@ _Appears in:_ TokenExchangeConfig holds configuration for RFC-8693 OAuth 2.0 Token Exchange. This configuration is used to exchange incoming authentication tokens for tokens that can be used with external services. -The structure matches the tokenexchange.Config from pkg/auth/tokenexchange/middleware.go +The structure matches the tokenexchange.Config from pkg/oauthproto/tokenexchange/middleware.go @@ -3014,6 +3295,8 @@ TokenResponseMapping maps non-standard token response fields to standard OAuth 2 using dot-notation JSON paths. This supports upstream providers like GovSlack that nest the access token under paths like "authed_user.access_token". +For extracting user identity from the token response, see IdentityFromToken. + _Appears in:_ @@ -3087,23 +3370,6 @@ _Appears in:_ | `annotations` _[api.v1beta1.ToolAnnotationsOverride](#apiv1beta1toolannotationsoverride)_ | Annotations overrides specific tool annotation fields.
Only specified fields are overridden; others pass through from the backend. | | Optional: \{\}
| -#### api.v1beta1.ToolRateLimitConfig - - - -ToolRateLimitConfig defines rate limits for a specific tool. -At least one of shared or perUser must be configured. - - - -_Appears in:_ -- [api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| -| `shared` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| -| `perUser` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| #### api.v1beta1.UpstreamInjectSpec @@ -3130,6 +3396,17 @@ _Appears in:_ UpstreamProviderConfig defines configuration for an upstream Identity Provider. +Exactly one of OIDCConfig or OAuth2Config must be set and must match the +declared Type: oidc-typed providers set OIDCConfig, oauth2-typed providers +set OAuth2Config. The CEL rule below enforces the pairing at admission; the +matching Go-level check in validateUpstreamProvider provides defense-in-depth +for stored objects. + +The rule is structured as a chain of equality checks ending in an explicit +`false`, so adding a new UpstreamProviderType value without extending this +rule fails admission instead of silently demanding the OAuth2 shape. When +adding a new type, extend both this rule and validateUpstreamProvider. + _Appears in:_ @@ -3166,7 +3443,9 @@ _Appears in:_ UserInfoConfig contains configuration for fetching user information from an upstream provider. This supports both standard OIDC UserInfo endpoints and custom provider-specific endpoints -like GitHub's /user API. +like GitHub's /user API. For providers that do not expose a usable userinfo endpoint but +include identity in the OAuth2 token response, use IdentityFromToken on OAuth2UpstreamConfig +instead. @@ -3400,6 +3679,7 @@ _Appears in:_ | `authServerConfig` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| | `replicas` _integer_ | Replicas is the desired number of vMCP pod replicas.
VirtualMCPServer creates a single Deployment for the vMCP aggregator process,
so there is only one replicas field (unlike MCPServer which has separate
Replicas and BackendReplicas for its two Deployments).
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| | `sessionStorage` _[api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When nil, no session storage is configured. | | Optional: \{\}
| +| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the vMCP workload.
These are applied to both the vMCP Deployment's PodSpec.ImagePullSecrets
and to the operator-managed ServiceAccount the vMCP server runs as, so private
images are pullable through either path.
Merge semantics with PodTemplateSpec:
The deployed PodSpec.ImagePullSecrets is the Kubernetes-native strategic-merge
union of this field and spec.podTemplateSpec.spec.imagePullSecrets, merged by
the patchStrategy:"merge" / patchMergeKey:"name" tags on corev1.PodSpec.
- This field is rendered first as the controller-generated default.
- spec.podTemplateSpec.spec.imagePullSecrets is then strategic-merge-patched
on top, keyed by Name. Distinct names from the two sources are unioned in
the resulting list; entries with the same Name are deduplicated and the
PodTemplateSpec entry wins on overlap (user override).
- Order in the resulting list is not guaranteed and should not be relied on:
strategic merge by name is order-insensitive.
- The operator-managed ServiceAccount's imagePullSecrets list is populated
ONLY from this field. spec.podTemplateSpec.spec.imagePullSecrets does not
reach the ServiceAccount because PodTemplateSpec has no notion of a
ServiceAccount. To make a secret usable via the ServiceAccount path
(e.g. for sidecars or init containers that pull images independently),
list it here rather than under spec.podTemplateSpec.
Note on cross-CRD consistency:
MCPRegistry currently uses an atomic-replace strategy for its imagePullSecrets
(the user-provided value replaces the controller-generated list rather than
being merged on top). VirtualMCPServer follows the Kubernetes-native
strategic-merge-by-name behavior described above. Aligning the two is tracked
as a separate follow-up; until then, manifests that set imagePullSecrets on
both CRDs will see different override behavior between them. | | Optional: \{\}
| #### api.v1beta1.VirtualMCPServerStatus @@ -3445,6 +3725,80 @@ _Appears in:_ | `readOnly` _boolean_ | ReadOnly specifies whether the volume should be mounted read-only | false | Optional: \{\}
| +#### api.v1beta1.WebhookConfigRef + + + +WebhookConfigRef defines a reference to a MCPWebhookConfig resource. +The referenced MCPWebhookConfig must be in the same namespace as the MCPServer. + + + +_Appears in:_ +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the MCPWebhookConfig resource | | Required: \{\}
| + + +#### api.v1beta1.WebhookFailurePolicy + +_Underlying type:_ _string_ + +WebhookFailurePolicy defines how webhook errors are handled. + + + +_Appears in:_ +- [api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) + +| Field | Description | +| --- | --- | +| `fail` | WebhookFailurePolicyFail denies the request on webhook error.
| +| `ignore` | WebhookFailurePolicyIgnore allows the request on webhook error.
| + + +#### api.v1beta1.WebhookSpec + + + +WebhookSpec defines the configuration for a single webhook middleware + + + +_Appears in:_ +- [api.v1beta1.MCPWebhookConfigSpec](#apiv1beta1mcpwebhookconfigspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is a unique identifier for this webhook | | MaxLength: 63
MinLength: 1
| +| `url` _string_ | URL is the endpoint to call for this webhook. Must be an HTTP/HTTPS URL. | | Format: uri
| +| `timeout` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#duration-v1-meta)_ | Timeout configures the maximum time to wait for the webhook to respond.
Defaults to 10s if not specified. Maximum is 30s. | | Format: duration
Type: string
Optional: \{\}
| +| `failurePolicy` _[api.v1beta1.WebhookFailurePolicy](#apiv1beta1webhookfailurepolicy)_ | FailurePolicy defines how to handle errors when communicating with the webhook.
Supported values: "fail", "ignore". Defaults to "fail". | fail | Enum: [fail ignore]
Optional: \{\}
| +| `tlsConfig` _[api.v1beta1.WebhookTLSConfig](#apiv1beta1webhooktlsconfig)_ | TLSConfig contains optional TLS configuration for the webhook connection. | | Optional: \{\}
| +| `hmacSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | HMACSecretRef references a Kubernetes Secret containing the HMAC signing key
used to sign the webhook payload. If set, the X-Toolhive-Signature header will be injected. | | Optional: \{\}
| + + +#### api.v1beta1.WebhookTLSConfig + + + +WebhookTLSConfig contains TLS configuration for secure webhook connections + + + +_Appears in:_ +- [api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `caSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | CASecretRef references a Secret containing the CA certificate bundle used to verify the webhook server's certificate.
Contains a bundle of PEM-encoded X.509 certificates. | | Optional: \{\}
| +| `clientCertSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientCertSecretRef references a Secret containing the client certificate for mTLS authentication.
The referenced key must contain a PEM-encoded client certificate.
Use ClientKeySecretRef to provide the corresponding private key. | | Optional: \{\}
| +| `clientKeySecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientKeySecretRef references a Secret containing the private key for the client certificate.
Required when ClientCertSecretRef is set to enable mTLS. | | Optional: \{\}
| +| `insecureSkipVerify` _boolean_ | InsecureSkipVerify disables server certificate verification.
WARNING: This should only be used for development/testing and not in production environments. | | Optional: \{\}
| + + #### api.v1beta1.WorkloadReference @@ -3459,6 +3813,7 @@ _Appears in:_ - [api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus) - [api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus) - [api.v1beta1.MCPToolConfigStatus](#apiv1beta1mcptoolconfigstatus) +- [api.v1beta1.MCPWebhookConfigStatus](#apiv1beta1mcpwebhookconfigstatus) | Field | Description | Default | Validation | | --- | --- | --- | --- | diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 2417da9754..9e72694faa 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -83,6 +83,35 @@ Configures authentication for clients connecting to the Virtual MCP server. Reus - `audience` (string, required): Must be unique per server to prevent token replay - `scopes` ([]string, optional): Defaults to `["openid"]` - `authzConfig` (AuthzConfigRef, optional): Authorization policy configuration + - `type` (string, required): `inline` or `configMap` + - `inline` (InlineAuthzConfig, required when type=inline): Inline Cedar policies + - `policies` ([]string, required): Cedar policy strings + - `entitiesJson` (string, optional): Cedar entities (JSON). Required when + transitive policies (e.g. `ClaimGroup` → `PlatformRole`) need a static + entity store. Defaults to `"[]"`. + - `primaryUpstreamProvider` (string, optional): **Deprecated.** Use + `.spec.authServerConfig.primaryUpstreamProvider` instead. Setting this + field still resolves a primary upstream for backward compatibility and + emits a Warning event with reason + `AuthzPrimaryUpstreamProviderDeprecated`. Planned removal one release + after the deprecation cycle. + - `configMap` (ConfigMapAuthzRef, required when type=configMap): Reference to + a ConfigMap holding Cedar policies. The operator resolves the ConfigMap at + reconcile time and bakes the policies into the rendered vmcp `config.yaml`. + Failures surface as `AuthConfigured=False` with reason + `AuthzConfigMapNotFound` (missing reference) or `AuthzConfigMapInvalid` + (parse, validation, or non-Cedar payload). + - `groupClaimName` (string, optional): JWT claim key Cedar should treat as + the group list (overrides the well-known defaults `groups`, `roles`, + `cognito:groups`). When `type` is `configMap`, the spec value overrides + any `group_claim_name` set in the ConfigMap payload. + - `roleClaimName` (string, optional): JWT claim key Cedar should treat as + the role list. Same spec-over-ConfigMap precedence as `groupClaimName`. + - `groupEntityType` (string, optional): Cedar entity type used for principal + parent UIDs synthesised from JWT group/role claims. Defaults to + `THVGroup`. Must match the entity type used in the static entity store + for transitive `in` checks to resolve. Same spec-over-ConfigMap + precedence as `groupClaimName`. **Important**: The `type` field must always be explicitly specified. When no authentication is required, use `type: anonymous`. diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md index 379ca9aaa6..e8943fd44f 100644 --- a/docs/operator/virtualmcpserver-kubernetes-guide.md +++ b/docs/operator/virtualmcpserver-kubernetes-guide.md @@ -625,6 +625,71 @@ Then gradually add restrictions. Common Cedar policy issues: - Verify attribute names match token claims - Test policies with different user roles +**Multiple upstream IDPs**: when `spec.authServerConfig` declares more than +one `upstreamProviders` entry, Cedar evaluates claims from the first one by +default. Pin a specific provider explicitly via +`spec.authServerConfig.primaryUpstreamProvider`: + +```yaml +spec: + authServerConfig: + issuer: https://vmcp.example.com + primaryUpstreamProvider: okta # must match one of the configured upstreams + upstreamProviders: + - name: okta + type: oidc + # ... + - name: github + type: oauth2 + # ... + incomingAuth: + authzConfig: + type: inline + inline: + policies: + - 'permit(principal, action, resource);' +``` + +> **Migration: `primaryUpstreamProvider` location** +> +> The field used to live under +> `spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. It has +> moved to `spec.authServerConfig.primaryUpstreamProvider` to sit next to +> the `upstreamProviders` list it selects from. The old location is read +> for one release for backward compatibility; the controller emits a +> Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated` +> whenever it consumes the deprecated location. Move the value to the new +> location to clear the warning. The deprecated field is planned for +> removal one release after the deprecation cycle. + +**Authorization policy errors**: misconfigured authz surfaces on the +`AuthConfigured` condition with one of: + +* `AuthzConfigMapNotFound`: the ConfigMap referenced by + `spec.incomingAuth.authzConfig.configMap` does not exist in the + namespace. Create it before reconciling, or fix the name. +* `AuthzConfigMapInvalid`: the ConfigMap exists but the payload is + missing the configured key, empty, malformed YAML/JSON, fails Cedar + validation, or is a registered non-Cedar authorizer (vMCP supports + Cedar only). Check the payload shape (see the Cedar v1 schema in the + example above). + +**Enterprise Cedar policies that deny every request**: when a policy +walks a transitive hierarchy like `Client → ClaimGroup → PlatformRole`, +both Cedar JWT-claim mapping settings and the static entity store must +agree on the entity type. The configuration fields live on +`spec.incomingAuth.authzConfig`: + +* `groupClaimName` / `roleClaimName`: JWT claim keys to extract. +* `groupEntityType`: Cedar entity type used for principal parent UIDs. + Must match the entity type used in `entitiesJson` (e.g. `ClaimGroup` + rather than the default `THVGroup`). + +For configMap-sourced authz, the same fields can be set in the +ConfigMap payload (`cedar.group_claim_name`, `cedar.role_claim_name`, +`cedar.group_entity_type`); spec-level values on `authzConfig` override +the ConfigMap when set. + ### Backend Discovery Issues #### Backends Not Discovered diff --git a/docs/redis-storage.md b/docs/redis-storage.md index d656858f2b..bb1a28a19a 100644 --- a/docs/redis-storage.md +++ b/docs/redis-storage.md @@ -1,98 +1,98 @@ -# Redis-Compatible Storage for Auth Server +# Redis Storage for Auth Server -This guide explains how to configure Redis-compatible storage as the backend for ToolHive's embedded authorization server, enabling horizontal scaling across multiple auth server replicas. +This guide explains how to configure Redis as the storage backend for ToolHive's embedded authorization server, enabling horizontal scaling across multiple auth server replicas. ## Overview -By default, ToolHive's embedded auth server uses in-memory storage. This works well for single-instance deployments but does not support horizontal scaling since each replica has its own isolated state. Redis-compatible storage provides a shared backend that enables multiple auth server replicas to share OAuth 2.0 state (tokens, authorization codes, clients, and user data). +By default, ToolHive's embedded auth server uses in-memory storage. This works well for single-instance deployments but does not support horizontal scaling since each replica has its own isolated state. Redis provides a shared storage backend that enables multiple auth server replicas to share OAuth 2.0 state (tokens, authorization codes, clients, and user data). **Key design decisions:** -- **Direct or Sentinel mode**: ToolHive supports either a direct Redis-compatible address (for example DragonflyDB) or a Redis Sentinel deployment for automatic failover and high availability. -- **ACL user authentication**: Only Redis ACL user authentication is supported. This is the modern Redis authentication mechanism (Redis 6+) that provides fine-grained access control. +- **Standalone or Sentinel**: Both standalone Redis (single endpoint) and Redis Sentinel (high-availability with automatic failover) are supported. Use standalone for managed Redis services that expose a single endpoint (GCP Memorystore Basic/Standard HA, Azure Cache for Redis, AWS ElastiCache non-cluster); use Sentinel for self-managed HA clusters. Redis Cluster mode is not supported. +- **ACL or legacy authentication**: Redis ACL user authentication (Redis 6+) is supported for fine-grained access control. For managed Redis tiers that do not support ACL users (e.g. GCP Memorystore Basic/Standard HA, Azure Cache for Redis), omit the username to use legacy password-only `AUTH`. - **Multi-tenancy via key prefixes**: Each auth server instance uses a unique key prefix (`thv:auth:{namespace:name}:`) to isolate its data, allowing multiple auth servers to share the same Redis deployment. ## Prerequisites -- A running Redis-compatible backend (Redis 6+ for ACL support) -- Redis ACL user configured with appropriate permissions +- A running Redis deployment accessible from the auth server pod +- Redis credentials (password, and optionally a username for ACL-based access) - For Kubernetes: Secrets containing Redis credentials ## Configuration -> **Note: No TLS support.** Redis connections are currently unencrypted. All traffic — including OAuth tokens, authorization codes, and credentials — is transmitted in plaintext between the auth server and Redis. -> In shared network environments, use Kubernetes NetworkPolicies to restrict access to Redis pods, or deploy a service mesh (e.g., Istio, Linkerd) for transparent mTLS. TLS support is planned as a future enhancement. +> **TLS support:** TLS is supported for both standalone and Sentinel connections. To enable TLS, set `tls.caCertSecretRef` to a Secret containing the CA certificate. For managed services with private CAs (e.g. GCP Memorystore), retrieve the CA certificate first: +> ```bash +> gcloud redis instances get-server-ca-certs INSTANCE_NAME --region=REGION --format=json +> ``` +> For connections without a custom CA, TLS uses the system root CAs. To skip verification (self-signed certs only, not for production), set `tls.insecureSkipVerify: true`. ### Kubernetes (MCPExternalAuthConfig CRD) When using the ToolHive operator, Redis storage is configured through the `storage` field in the embedded auth server section of `MCPExternalAuthConfig`. -Use `address` for direct Redis-compatible deployments such as DragonflyDB: +#### Standalone Redis (Managed Services) + +Use `addr` for single-endpoint Redis services such as GCP Memorystore, AWS ElastiCache, or Azure Cache for Redis. ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPExternalAuthConfig -metadata: - name: my-auth-config - namespace: default -spec: - type: embeddedAuthServer - embeddedAuthServer: - storage: - type: redis - redis: - address: dragonfly.default.svc.cluster.local:6379 - aclUserConfig: - usernameSecretRef: - name: redis-credentials - key: username - passwordSecretRef: - name: redis-credentials - key: password +storage: + type: redis + redis: + addr: "10.0.0.3:6379" # Redis endpoint + + aclUserConfig: + # Omit usernameSecretRef for managed Redis tiers that use password-only + # AUTH (e.g. GCP Memorystore Basic/Standard HA, Azure Cache for Redis). + # Include it for services that support ACL users (e.g. AWS ElastiCache + # non-cluster with Redis 6+ RBAC). + usernameSecretRef: # optional + name: redis-credentials + key: username + passwordSecretRef: + name: redis-credentials + key: password + + # Optional: TLS for managed services with private CAs (e.g. GCP Memorystore) + tls: + caCertSecretRef: + name: redis-tls-ca + key: ca.crt - # Optional timeouts (shown with defaults) - dialTimeout: "5s" - readTimeout: "3s" - writeTimeout: "3s" + # Optional timeouts (shown with defaults) + dialTimeout: "5s" + readTimeout: "3s" + writeTimeout: "3s" ``` -Use `sentinelConfig` for HA Redis deployments: +#### Redis Sentinel -```yaml -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPExternalAuthConfig -metadata: - name: my-auth-config - namespace: default -spec: - type: embeddedAuthServer - embeddedAuthServer: - # ... other auth server config ... +Use `sentinelConfig` for self-managed Redis deployments with Sentinel-based high availability. - storage: - type: redis - redis: - sentinelConfig: - masterName: mymaster - # Option 1: Direct Sentinel addresses - sentinelAddrs: - - "redis-sentinel-0.redis-sentinel:26379" - - "redis-sentinel-1.redis-sentinel:26379" - - "redis-sentinel-2.redis-sentinel:26379" - db: 0 +```yaml +storage: + type: redis + redis: + sentinelConfig: + masterName: mymaster + # Option 1: Direct Sentinel addresses + sentinelAddrs: + - "redis-sentinel-0.redis-sentinel:26379" + - "redis-sentinel-1.redis-sentinel:26379" + - "redis-sentinel-2.redis-sentinel:26379" + db: 0 - aclUserConfig: - usernameSecretRef: - name: redis-credentials - key: username - passwordSecretRef: - name: redis-credentials - key: password + aclUserConfig: + usernameSecretRef: + name: redis-credentials + key: username + passwordSecretRef: + name: redis-credentials + key: password - # Optional timeouts (shown with defaults) - dialTimeout: "5s" - readTimeout: "3s" - writeTimeout: "3s" + # Optional timeouts (shown with defaults) + dialTimeout: "5s" + readTimeout: "3s" + writeTimeout: "3s" ``` #### Sentinel Service Discovery @@ -125,7 +125,7 @@ storage: #### Redis Credentials Secret -Create a Kubernetes Secret containing the Redis ACL username and password: +Create a Kubernetes Secret containing the Redis password (and optionally a username for ACL-based access): ```yaml apiVersion: v1 @@ -135,14 +135,15 @@ metadata: namespace: default type: Opaque stringData: - username: toolhive-auth + username: toolhive-auth # omit for password-only AUTH password: "" ``` ### RunConfig (Process Boundary Configuration) -When the auth server configuration is serialized for passing across process boundaries (e.g., from operator to proxy-runner), it uses the `RunConfig` format: +When the auth server configuration is serialized for passing across process boundaries (e.g., from operator to proxy-runner), it uses the `RunConfig` format. +**Sentinel example:** ```json { "type": "redis", @@ -161,7 +162,22 @@ When the auth server configuration is serialized for passing across process boun } ``` -In RunConfig format, credentials are referenced via environment variables rather than Kubernetes Secrets. The operator handles the translation from Secret references to environment variables when constructing the proxy-runner pod. +**Standalone with password-only AUTH (no username):** +```json +{ + "type": "redis", + "redisConfig": { + "addr": "10.0.0.3:6379", + "authType": "aclUser", + "aclUserConfig": { + "passwordEnvVar": "TOOLHIVE_AS_REDIS_PASSWORD" + }, + "keyPrefix": "thv:auth:{default:my-auth-config}:" + } +} +``` + +In RunConfig format, credentials are referenced via environment variables rather than Kubernetes Secrets. The operator handles the translation from Secret references to environment variables when constructing the proxy-runner pod. When `usernameSecretRef` is omitted from the CRD, `usernameEnvVar` is omitted from the RunConfig and go-redis uses the legacy `AUTH ` form. ## Deploying Redis with the Spotahome Redis Operator @@ -407,9 +423,12 @@ Secondary index cleanup is best-effort: stale entries may remain temporarily but | Field | Type | Required | Default | Description | |---|---|---|---|---| -| `address` | `string` | One of address/sentinelConfig | — | Direct Redis-compatible host:port address | -| `sentinelConfig` | `RedisSentinelConfig` | One of address/sentinelConfig | — | Sentinel connection settings | -| `aclUserConfig` | `RedisACLUserConfig` | Yes | — | ACL user credentials | +| `addr` | `string` | One of addr/sentinelConfig | — | Standalone Redis endpoint (`host:port`). Use for managed single-endpoint Redis services (GCP Memorystore Basic/Standard HA, Azure Cache for Redis, AWS ElastiCache non-cluster). | +| `address` | `string` | No | — | Deprecated alias of `addr`, kept by the DocPlanner fork until every environment has migrated. Mutually exclusive with `addr`. | +| `sentinelConfig` | `RedisSentinelConfig` | One of addr/sentinelConfig | — | Sentinel connection settings for high-availability Redis. | +| `aclUserConfig` | `RedisACLUserConfig` | Yes | — | Authentication credentials | +| `tls` | `RedisTLSConfig` | No | — | TLS for the Redis master connection | +| `sentinelTLS` | `RedisTLSConfig` | No | — | TLS for Sentinel connections (Sentinel mode only) | | `dialTimeout` | `string` | No | `5s` | Connection establishment timeout | | `readTimeout` | `string` | No | `3s` | Socket read timeout | | `writeTimeout` | `string` | No | `3s` | Socket write timeout | @@ -435,9 +454,16 @@ Secondary index cleanup is best-effort: stale entries may remain temporarily but | Field | Type | Required | Default | Description | |---|---|---|---|---| -| `usernameSecretRef` | `SecretKeyRef` | Yes | — | Secret reference for Redis username | +| `usernameSecretRef` | `SecretKeyRef` | No | — | Secret reference for Redis username. Omit for managed tiers that use password-only AUTH (GCP Memorystore Basic/Standard HA, Azure Cache for Redis). | | `passwordSecretRef` | `SecretKeyRef` | Yes | — | Secret reference for Redis password | +### RedisTLSConfig (CRD) + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `caCertSecretRef` | `SecretKeyRef` | No | — | Secret containing a PEM-encoded CA certificate. When absent, system root CAs are used. | +| `insecureSkipVerify` | `bool` | No | `false` | Skip certificate verification. For self-signed certs only; do not use in production. | + ## Related Documentation - [Architecture Overview](arch/00-overview.md) diff --git a/docs/registry/schema.md b/docs/registry/schema.md index 99b68e8003..1aec4a2409 100644 --- a/docs/registry/schema.md +++ b/docs/registry/schema.md @@ -11,36 +11,27 @@ ToolHive MCP server registry and how to use it for validation and development. > > The registry data in this repository is now automatically synchronized from the external registry. -## Overview +## Migrating from the legacy format -The [`toolhive-legacy-registry.schema.json`](../../pkg/registry/data/toolhive-legacy-registry.schema.json) file provides comprehensive validation for the -[`registry.json`](../../pkg/registry/data/registry.json) file structure. It -ensures consistency, catches common errors, and serves as living documentation -for contributors. - -This can also be used to validate a custom registry file to be used with the -[`thv config set-registry-url`](../cli/thv_config_set-registry-url.md) command. +The legacy ToolHive registry format is no longer accepted. Run +`thv registry convert --in --in-place` to migrate any custom registry +JSON file to the upstream MCP format. The conversion is lossless: every +ToolHive-specific field maps to a publisher-provided extension on the +corresponding upstream server entry. ## Schema files -ToolHive uses multiple JSON schemas for different purposes: - -### Legacy Registry Schema - -- **File**: [`pkg/registry/data/toolhive-legacy-registry.schema.json`](../../pkg/registry/data/toolhive-legacy-registry.schema.json) -- **Schema ID**: `https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json` -- **Purpose**: Validates the internal ToolHive registry format (`registry.json`) +ToolHive consumes registries in the upstream MCP registry format. The schemas +live in the [`toolhive-core`](https://github.com/stacklok/toolhive-core) module: ### Upstream Registry Schema -- **File**: [`pkg/registry/data/upstream-registry.schema.json`](../../pkg/registry/data/upstream-registry.schema.json) -- **Schema ID**: `https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json` +- **Schema ID**: `https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/upstream-registry.schema.json` - **Purpose**: Validates registries using the upstream MCP server format. References the official MCP server schema. ### Publisher-Provided Extensions Schema -- **File**: [`pkg/registry/data/publisher-provided.schema.json`](../../pkg/registry/data/publisher-provided.schema.json) -- **Schema ID**: `https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/publisher-provided.schema.json` +- **Schema ID**: `https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/publisher-provided.schema.json` - **Purpose**: Defines the structure of ToolHive-specific metadata placed under `_meta["io.modelcontextprotocol.registry/publisher-provided"]` in MCP server definitions The publisher-provided extensions schema allows ToolHive and other publishers to add custom metadata to MCP server definitions in the upstream registry format. This metadata is stored in the `_meta` object under the key `io.modelcontextprotocol.registry/publisher-provided`. @@ -254,24 +245,23 @@ When an MCP server is deployed in Kubernetes and served via the ToolHive Registr ### Automated validation (Go tests) -The registry is automatically validated against the schema during development -and CI/CD through Go tests. This ensures that any changes to the registry data -are immediately validated. +The registry is automatically validated against the upstream schema during +development and CI/CD through Go tests. This ensures that any changes to the +registry data are immediately validated. -The validation is implemented in -[`pkg/registry/schema_validation.go`](../../pkg/registry/schema_validation.go) -and tested in +Schema validation is provided by +[`toolhive-core`](https://github.com/stacklok/toolhive-core)'s +`registry/types.ValidateUpstreamRegistryBytes` and exercised locally in [`pkg/registry/schema_validation_test.go`](../../pkg/registry/schema_validation_test.go). **Key tests:** -- `TestEmbeddedRegistrySchemaValidation` - Validates the embedded - `registry.json` against the legacy schema -- `TestRegistrySchemaValidation` - Comprehensive test suite with valid and - invalid legacy registry examples -- `TestValidateUpstreamRegistry` - Validates upstream registry format -- `TestValidatePublisherProvidedExtensions` - Validates publisher-provided - extensions structure +- `TestEmbeddedRegistrySchemaValidation` - Validates the embedded upstream + registry against the upstream registry schema +- `TestValidateEmbeddedRegistryCanLoadData` - Confirms the embedded upstream + registry parses into the internal types +- `TestUpstreamRegistryParsing` - Round-trips upstream registry data through + `parseRegistryData` **Running the validation:** @@ -308,26 +298,21 @@ Or via pipx (cross-platform): pipx install check-jsonschema ``` -Validate the registry with full format validation: +Validate a custom registry file against the upstream schema: ```bash -# Run from the root of the repository -check-jsonschema --schemafile pkg/registry/data/toolhive-legacy-registry.schema.json pkg/registry/data/registry.json +check-jsonschema \ + --schemafile https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/upstream-registry.schema.json \ + path/to/registry.json ``` #### Using ajv-cli -Install ajv-cli and ajv-formats globally: - ```bash npm install -g ajv-cli ajv-formats -``` - -Validate the registry with format validation: - -```bash -# Run from the root of the repository -ajv validate -c ajv-formats -s pkg/registry/data/toolhive-legacy-registry.schema.json -d pkg/registry/data/registry.json +ajv validate -c ajv-formats \ + -s upstream-registry.schema.json \ + -d path/to/registry.json ``` #### Using VS Code @@ -337,7 +322,7 @@ to the top of any registry JSON file: ```json { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/upstream-registry.schema.json", ... } ``` diff --git a/docs/remote-mcp-authentication.md b/docs/remote-mcp-authentication.md index a7e053975e..f67a43f034 100644 --- a/docs/remote-mcp-authentication.md +++ b/docs/remote-mcp-authentication.md @@ -48,13 +48,13 @@ ToolHive implements BOTH discovery mechanisms required by the MCP specification: - Accepts the authoritative issuer from well-known endpoints per RFC 8414 #### 4. Dynamic Client Registration (RFC 7591) -- **Location**: [`pkg/auth/oauth/dynamic_registration.go:82-200`](../pkg/auth/oauth/dynamic_registration.go#L82) +- **Location**: [`pkg/oauthproto/dcr.go`](../pkg/oauthproto/dcr.go) - Automatically registers OAuth clients when no credentials provided - Uses PKCE flow with `token_endpoint_auth_method: "none"` - Supports both manual client configuration and automatic registration #### 5. PKCE Support -- **Location**: [`pkg/auth/oauth/dynamic_registration.go:52`](../pkg/auth/oauth/dynamic_registration.go#L52) +- **Location**: [`pkg/oauthproto/dcr.go`](../pkg/oauthproto/dcr.go) - Enabled by default for enhanced security - Required for public clients as per OAuth 2.1 @@ -221,7 +221,7 @@ This approach handles cases where servers implement RFC 9728 well-known URI disc ## Dynamic Client Registration Flow -When no client credentials are provided ([`pkg/auth/oauth/dynamic_registration.go`](../pkg/auth/oauth/dynamic_registration.go)): +When no client credentials are provided ([`pkg/oauthproto/dcr.go`](../pkg/oauthproto/dcr.go)): 1. **Discover Registration Endpoint**: Via OIDC discovery or resource metadata 2. **Create Registration Request**: diff --git a/docs/server/docs.go b/docs/server/docs.go index 44e1bdf2cd..2396298795 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -44,32 +44,19 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket": { - "description": "PerUser token bucket configuration for this tool.\n+optional", - "properties": { - "maxTokens": { - "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", - "type": "integer" - }, - "refillPeriod": { - "$ref": "#/components/schemas/v1.Duration" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", "properties": { "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" }, "tools": { "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig" }, "type": "array", "uniqueItems": false @@ -77,21 +64,6 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig": { - "properties": { - "name": { - "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", - "type": "string" - }, - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_pkg_audit.Config": { "description": "DEPRECATED: Middleware configuration.\nAuditConfig contains the audit logging configuration", "properties": { @@ -240,6 +212,10 @@ const docTemplate = `{ "session_name_claim": { "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", "type": "string" + }, + "subject_provider_name": { + "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", + "type": "string" } }, "type": "object" @@ -278,6 +254,10 @@ const docTemplate = `{ "bearer_token_file": { "type": "string" }, + "cached_cimd_client_id": { + "description": "CachedCIMDClientID stores the CIMD metadata URL used as client_id when CIMD\nauthentication was used. Kept separate from CachedClientID (which holds\nDCR-issued IDs) so the two can have independent lifecycles — DCR credential\nrotation clears CachedClientID without touching the stable CIMD URL.\nRead by resolveClientCredentials to send the correct client_id on token refresh.", + "type": "string" + }, "cached_client_id": { "description": "Cached DCR client credentials for persistence across restarts.\nThese are obtained during Dynamic Client Registration and needed to refresh tokens.\nClientID is stored as plain text since it's public information.", "type": "string" @@ -354,61 +334,86 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config": { - "description": "TokenExchangeConfig contains token exchange configuration for external authentication", + "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { + "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", "properties": { - "audience": { - "description": "Audience is the target audience for the exchanged token", + "custom_header_name": { + "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", "type": "string" }, - "client_id": { - "description": "ClientID is the OAuth 2.0 client identifier", + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", "type": "string" }, - "client_secret": { - "description": "ClientSecret is the OAuth 2.0 client secret", + "provider_name": { + "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig": { + "description": "CIMD controls client_id metadata document support. When enabled, the\nembedded authorization server accepts HTTPS URLs as client_id values\nand resolves them via the CIMD protocol instead of requiring DCR.", + "properties": { + "cache_fallback_ttl": { + "description": "CacheFallbackTTL is the fixed TTL applied to every cached CIMD document.\nCache-Control header parsing is not yet implemented; all entries use this value.\nFormat: Go duration string (e.g. \"5m\", \"10m\", \"1h\").\nDefaults to 5 minutes when Enabled is true and this field is omitted.", + "example": "5m", "type": "string" }, - "external_token_header_name": { - "description": "ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"", + "cache_max_size": { + "description": "CacheMaxSize is the maximum number of CIMD documents held in the LRU cache.\nDefaults to 256 when Enabled is true and this field is zero.", + "type": "integer" + }, + "enabled": { + "description": "Enabled activates CIMD client lookup when true.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig": { + "description": "DCRConfig enables RFC 7591 Dynamic Client Registration against the\nupstream authorization server. When set, the client credentials are\nobtained at runtime rather than being pre-provisioned via ClientID /\nClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty.\nMutually exclusive with ClientID.", + "properties": { + "discovery_url": { + "description": "DiscoveryURL is the exact RFC 8414 / OIDC Discovery document URL to\nfetch at runtime. The resolver issues a single GET against this URL\n(no well-known-path fallback) and reads registration_endpoint,\nauthorization_endpoint, token_endpoint,\ntoken_endpoint_auth_methods_supported, and scopes_supported from the\nresponse. Per RFC 8414 §3.3, the document's \"issuer\" field must\nexactly match the upstream issuer configured on the parent\nrun-config.\n\nUse this field when the upstream publishes discovery metadata at a\npath that differs from the issuer-derived well-known paths — for\nexample a multi-tenant IdP whose metadata lives at\nhttps://idp.example.com/tenants/acme/.well-known/openid-configuration.\n\nMutually exclusive with RegistrationEndpoint.", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom", + "initial_access_token_env_var": { + "description": "InitialAccessTokenEnvVar is the name of an environment variable\ncontaining the RFC 7591 initial access token. Mutually exclusive with\nInitialAccessTokenFile.", "type": "string" }, - "scopes": { - "description": "Scopes is the list of scopes to request for the exchanged token", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false + "initial_access_token_file": { + "description": "InitialAccessTokenFile is the path to a file containing the RFC 7591\ninitial access token presented to the registration endpoint. Mutually\nexclusive with InitialAccessTokenEnvVar. Both may be omitted for open\nregistration endpoints.", + "type": "string" }, - "subject_token_type": { - "description": "SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.", + "registration_endpoint": { + "description": "RegistrationEndpoint is the RFC 7591 registration endpoint URL used\ndirectly, bypassing discovery. Because no discovery is performed,\nserver-capability fields (token_endpoint_auth_methods_supported,\nscopes_supported) are unavailable on this code path; the caller is\nexpected to also supply AuthorizationEndpoint, TokenEndpoint, and an\nexplicit Scopes list on the parent OAuth2UpstreamRunConfig. Auth\nmethod falls back to the resolver's default (client_secret_basic).\n\nMutually exclusive with DiscoveryURL.", "type": "string" }, - "token_url": { - "description": "TokenURL is the OAuth 2.0 token endpoint URL", + "software_id": { + "description": "SoftwareID is the RFC 7591 \"software_id\" registration metadata value,\nidentifying the client software independent of any particular\nregistration instance.", + "type": "string" + }, + "software_statement": { + "description": "SoftwareStatement is the RFC 7591 \"software_statement\" JWT asserting\nmetadata about the client software, signed by a party the authorization\nserver trusts.", "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { - "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", + "github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig": { + "description": "IdentityFromToken extracts user identity (subject, name, email) directly from the\nOAuth2 token-endpoint response body using gjson dot-notation paths. When set, the\nembedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type\n(cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative\ntrust-model and uniqueness documentation lives there.", "properties": { - "custom_header_name": { - "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", + "email_path": { + "description": "EmailPath is the dot-notation path to the email address field.", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "name_path": { + "description": "NamePath is the dot-notation path to the display name field.", "type": "string" }, - "provider_name": { - "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "subject_path": { + "description": "SubjectPath is the dot-notation path to the subject (user ID) field.\nRequired when IdentityFromToken is set.", "type": "string" } }, @@ -429,7 +434,7 @@ const docTemplate = `{ "type": "string" }, "client_id": { - "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", + "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", "type": "string" }, "client_secret_env_var": { @@ -440,6 +445,12 @@ const docTemplate = `{ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "dcr_config": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig" + }, + "identity_from_token": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to ` + "`" + `{issuer}/oauth/callback` + "`" + `.", "type": "string" @@ -524,6 +535,17 @@ const docTemplate = `{ "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n` + "`" + `{authorization_endpoint_base_url}/oauth/authorize` + "`" + ` instead of ` + "`" + `{issuer}/oauth/authorize` + "`" + `.\nAll other endpoints remain derived from the issuer.", "type": "string" }, + "baseline_client_scopes": { + "description": "BaselineClientScopes is a baseline set of OAuth 2.0 scopes unioned into every\nDCR registration. All values must appear in ScopesSupported; the auth server\nrejects this RunConfig at startup otherwise. Empty means current behavior is\npreserved (registered scope = client-requested, or DefaultScopes if empty).\nWhen ScopesSupported is empty, the subset check uses registration.DefaultScopes\n(the same set applyDefaults would substitute at startup) — so\nBaselineClientScopes containing standard OIDC scopes works without enumerating\nScopesSupported explicitly.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "cimd": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig" + }, "hmac_secret_files": { "description": "HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes\nand refresh tokens (opaque tokens).\nFirst file is the current secret (must be at least 32 bytes), subsequent files\nare for rotation/verification of existing tokens.\nIf empty, an ephemeral secret will be auto-generated (development only).", "items": { @@ -691,7 +713,7 @@ const docTemplate = `{ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig": { - "description": "UserInfo contains configuration for fetching user information (required for OAuth2).", + "description": "UserInfo contains configuration for fetching user information.\nOptional: when nil, the upstream OAuth2 provider derives a deterministic\nsubject by SHA-256-hashing the access token (with a \"tk-\" prefix) instead\nof calling a userinfo endpoint. OIDC providers always derive Subject from\nthe ID token and are unaffected.", "properties": { "additional_headers": { "additionalProperties": { @@ -734,14 +756,22 @@ const docTemplate = `{ "acl_user_config": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig" }, + "addr": { + "description": "Addr is the Redis server address (host:port). Required for standalone and cluster modes.\nMutually exclusive with SentinelConfig.", + "type": "string" + }, "address": { - "description": "Address is the Redis-compatible server address for direct mode (host:port).\nMutually exclusive with SentinelConfig.", + "description": "LegacyAddress is the pre-v0.27 serialized name of Addr. DocPlanner fork:\nstill read so an image can start from a RunConfig written by an older\noperator. Never written by the current operator. Remove with PAIINFRA-228.\n\nDeprecated: use Addr.", "type": "string" }, "auth_type": { "description": "AuthType must be \"aclUser\" - only ACL user authentication is supported.", "type": "string" }, + "cluster_mode": { + "description": "ClusterMode enables the Redis Cluster protocol. Requires Addr to be set.", + "type": "boolean" + }, "dial_timeout": { "description": "DialTimeout is the timeout for establishing connections (e.g., \"5s\").", "type": "string" @@ -771,7 +801,7 @@ const docTemplate = `{ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver_storage.RedisTLSRunConfig": { - "description": "SentinelTLS configures TLS for Sentinel connections.\nFalls back to TLS config when nil.", + "description": "SentinelTLS configures TLS for Sentinel connections. Only applies when SentinelConfig is set.", "properties": { "ca_cert_file": { "description": "CACertFile is the path to a PEM-encoded CA certificate file.", @@ -798,7 +828,7 @@ const docTemplate = `{ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver_storage.SentinelRunConfig": { - "description": "SentinelConfig contains Sentinel-specific configuration for HA deployments.\nMutually exclusive with Address.", + "description": "SentinelConfig contains Sentinel-specific configuration.\nMutually exclusive with Addr.", "properties": { "db": { "description": "DB is the Redis database number (default: 0).", @@ -862,7 +892,8 @@ const docTemplate = `{ "mistral-vibe", "codex", "kimi-cli", - "factory" + "factory", + "copilot-cli" ], "type": "string", "x-enum-varnames": [ @@ -892,7 +923,8 @@ const docTemplate = `{ "MistralVibe", "Codex", "KimiCli", - "Factory" + "Factory", + "CopilotCli" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { @@ -1098,6 +1130,76 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config": { + "description": "TokenExchangeConfig contains token exchange configuration for external authentication", + "properties": { + "audience": { + "description": "Audience is the target audience for the exchanged token", + "type": "string" + }, + "client_id": { + "description": "ClientID is the OAuth 2.0 client identifier", + "type": "string" + }, + "client_secret": { + "description": "ClientSecret is the OAuth 2.0 client secret", + "type": "string" + }, + "external_token_header_name": { + "description": "ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"", + "type": "string" + }, + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom", + "type": "string" + }, + "scopes": { + "description": "Scopes is the list of scopes to request for the exchanged token", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject_token_type": { + "description": "SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: oauthproto.TokenTypeAccessToken (default), oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT.\nIf empty, defaults to oauthproto.TokenTypeAccessToken.", + "type": "string" + }, + "token_url": { + "description": "TokenURL is the OAuth 2.0 token endpoint URL", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { + "description": "PerUser token bucket configuration for this tool.\n+optional", + "properties": { + "maxTokens": { + "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", + "type": "integer" + }, + "refillPeriod": { + "$ref": "#/components/schemas/v1.Duration" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig": { + "properties": { + "name": { + "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + }, + "perUser": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig": { "description": "AuthConfig contains the non-secret OAuth configuration when auth is configured.\nNil when auth_status is \"none\".", "properties": { @@ -1238,6 +1340,10 @@ const docTemplate = `{ "description": "K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime", "type": "string" }, + "mcpserver_generation": { + "description": "MCPServerGeneration is the K8s .metadata.generation of the MCPServer CR that rendered\nthis RunConfig. The Kubernetes runtime uses it as a monotonic version to prevent stale\nrolling-update pods from overwriting a newer RunConfig's StatefulSet apply. Zero value\nmeans unversioned (backward-compat with older operators, or non-operator callers).", + "type": "integer" + }, "middleware_configs": { "description": "MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.", "items": { @@ -1324,6 +1430,11 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "session_ttl": { + "description": "SessionTTL is the inactivity timeout for proxy sessions, expressed as a Go\nduration string (e.g. \"30m\", \"2h\", \"168h\"). Empty uses the transport\ndefault (2h). Negative durations and values that fail time.ParseDuration\nare rejected at runtime.\nString (not time.Duration) keeps the wire format unit-explicit: a\ntime.Duration field serializes as nanoseconds in JSON.", + "example": "2h", + "type": "string" + }, "stateless": { "description": "Stateless indicates the server only supports POST (no SSE/GET).\nWhen true, the proxy returns 405 for incoming GET requests and uses a\nPOST-based health check instead of the default GET probe.\nApplies to both remote URLs and local container workloads.", "type": "boolean" @@ -1344,7 +1455,7 @@ const docTemplate = `{ "type": "string" }, "token_exchange_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config" }, "tools_filter": { "description": "DEPRECATED: Middleware configuration.\nToolsFilter is the list of tools to filter", @@ -1866,6 +1977,130 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult": { + "description": "Result is the upgrade-check outcome for the workload. It carries only\nmetadata (status, image references, drift) and never secret values.", + "properties": { + "candidate_image": { + "description": "CandidateImage is the image reference the registry currently reports.", + "type": "string" + }, + "config_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift" + }, + "current_image": { + "description": "CurrentImage is the image reference the workload is currently running.", + "type": "string" + }, + "env_var_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift" + }, + "reason": { + "description": "Reason provides additional context, primarily for StatusUnknown.", + "type": "string" + }, + "registry_server": { + "description": "RegistryServer is the registry entry name the workload was sourced from.\nEmpty when the workload is not registry-sourced.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus" + }, + "workload_name": { + "description": "WorkloadName is the name of the workload that was checked.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift": { + "description": "ConfigDrift describes posture differences (transport, permission profile)\nbetween the workload and the candidate registry entry.", + "properties": { + "permission_profile": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + }, + "transport": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift": { + "description": "EnvVarDrift describes environment variables the candidate registry entry\ndeclares that differ from the workload's current configuration.", + "properties": { + "added": { + "description": "Added lists environment variables the candidate declares that the\nworkload does not currently supply (via plain env vars or secrets).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + }, + "removed": { + "description": "Removed lists environment variables the workload supplies that the\ncandidate no longer declares. Populated on a best-effort basis; may be\nempty even when removals exist (forward-compatible field).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo": { + "properties": { + "default": { + "description": "Default is the candidate's default value. It is cleared (left empty)\nwhenever Secret is true: a secret env var's default could carry sensitive\ndata, and surfacing it in a drift report (which may be logged or returned\nover the API) would leak it. Non-secret defaults are safe to display.", + "type": "string" + }, + "description": { + "description": "Description is the human-readable purpose of the variable.", + "type": "string" + }, + "name": { + "description": "Name is the environment variable name.", + "type": "string" + }, + "required": { + "description": "Required indicates whether the candidate marks the variable as required.", + "type": "boolean" + }, + "secret": { + "description": "Secret indicates whether the variable holds sensitive data.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange": { + "description": "PermissionProfile is set when the candidate's permission profile differs\nfrom the workload's current profile.", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus": { + "description": "Status is the upgrade status for the workload.", + "enum": [ + "up-to-date", + "upgrade-available", + "not-registry-sourced", + "server-not-found", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "StatusUpToDate", + "StatusUpgradeAvailable", + "StatusNotRegistrySourced", + "StatusServerNotFound", + "StatusUnknown" + ] + }, "model.Argument": { "properties": { "choices": { @@ -3286,30 +3521,74 @@ const docTemplate = `{ }, "type": "object" }, - "pkg_api_v1.validateSkillRequest": { - "description": "Request to validate a skill definition", + "pkg_api_v1.upgradeCheckBulkResponse": { + "description": "Results of checking multiple workloads for available upgrades", "properties": { - "path": { - "description": "Path to the skill definition directory", - "type": "string" + "results": { + "description": "Results holds one upgrade-check outcome per scoped workload, in the order\nthe workloads were enumerated. Each entry carries only metadata and never\nsecret values.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + }, + "type": "array", + "uniqueItems": false } }, "type": "object" }, - "pkg_api_v1.versionResponse": { + "pkg_api_v1.upgradeCheckResponse": { + "description": "Result of checking a single workload for an available upgrade", "properties": { - "version": { - "type": "string" + "result": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" } }, "type": "object" }, - "pkg_api_v1.workloadListResponse": { - "description": "Response containing a list of workloads", + "pkg_api_v1.upgradeRequest": { + "description": "Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's existing configuration.", "properties": { - "workloads": { - "description": "List of container information for each workload", - "items": { + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Env holds additional or overriding environment variables to merge into the\nupgraded workload's configuration.", + "type": "object" + }, + "secrets": { + "description": "Secrets holds additional secret parameters (` + "`" + `\u003cname\u003e,target=\u003cenv\u003e` + "`" + `) to merge\ninto the upgraded workload's configuration. Only references are accepted;\nno secret values are transmitted in the request.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "pkg_api_v1.validateSkillRequest": { + "description": "Request to validate a skill definition", + "properties": { + "path": { + "description": "Path to the skill definition directory", + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.versionResponse": { + "properties": { + "version": { + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.workloadListResponse": { + "description": "Response containing a list of workloads", + "properties": { + "workloads": { + "description": "List of container information for each workload", + "items": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_core.Workload" }, "type": "array", @@ -3477,6 +3756,10 @@ const docTemplate = `{ "description": "RepositoryURL is the URL to the source code repository for the server", "type": "string" }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET)", + "type": "boolean" + }, "status": { "description": "Status indicates whether the server is currently active or deprecated", "type": "string" @@ -3689,6 +3972,10 @@ const docTemplate = `{ "description": "RepositoryURL is the URL to the source code repository for the server", "type": "string" }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET)", + "type": "boolean" + }, "status": { "description": "Status indicates whether the server is currently active or deprecated", "type": "string" @@ -4817,6 +5104,71 @@ const docTemplate = `{ ] } }, + "/api/v1beta/registry/{name}/refresh": { + "post": { + "description": "Force a refresh of the server-side registry cache for the default registry", + "parameters": [ + { + "description": "Registry name (must be 'default')", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Registry refreshed" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Refresh registry cache", + "tags": [ + "registry" + ] + } + }, "/api/v1beta/registry/{name}/servers": { "get": { "description": "Get a list of servers in a specific registry", @@ -5423,6 +5775,26 @@ const docTemplate = `{ }, "description": "Bad Request" }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, "409": { "content": { "application/json": { @@ -5433,6 +5805,16 @@ const docTemplate = `{ }, "description": "Conflict" }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, "500": { "content": { "application/json": { @@ -5451,7 +5833,17 @@ const docTemplate = `{ } } }, - "description": "Bad Gateway" + "description": "Bad Gateway (upstream registry failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" } }, "summary": "Install a skill", @@ -5639,6 +6031,36 @@ const docTemplate = `{ }, "description": "Bad Request" }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, "500": { "content": { "application/json": { @@ -5657,7 +6079,17 @@ const docTemplate = `{ } } }, - "description": "Bad Gateway" + "description": "Bad Gateway (upstream registry or git resolver failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" } }, "summary": "Get skill content", @@ -6242,6 +6674,65 @@ const docTemplate = `{ ] } }, + "/api/v1beta/workloads/upgrade-check": { + "get": { + "description": "Check all workloads (optionally filtered by group) for newer\nimages available in their source registries. This is an offline\nmetadata comparison; it does not pull images. Secret values are\nnever returned.", + "parameters": [ + { + "description": "Include stopped workloads", + "in": "query", + "name": "all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Filter workloads by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Group not found" + } + }, + "summary": "Check workloads for available upgrades", + "tags": [ + "workloads" + ] + } + }, "/api/v1beta/workloads/{name}": { "delete": { "description": "Delete a workload asynchronously. Returns 202 Accepted immediately.\nThe deletion happens in the background. Poll the workload list to confirm deletion.", @@ -6698,6 +7189,149 @@ const docTemplate = `{ ] } }, + "/api/v1beta/workloads/{name}/upgrade": { + "post": { + "description": "Apply a registry-sourced upgrade to a single workload. This\nre-resolves and verifies the candidate image, pulls it, and only\nthen recreates the workload with the new image, preserving the\nexisting configuration. If the workload is already up to date or\nis not registry-sourced, the current check result is returned\nunchanged (no-op). Secret values are never accepted or returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.upgradeRequest", + "summary": "request", + "description": "Upgrade options" + } + ] + } + } + }, + "description": "Upgrade options" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Apply an available upgrade to a workload", + "tags": [ + "workloads" + ] + } + }, + "/api/v1beta/workloads/{name}/upgrade-check": { + "get": { + "description": "Check whether a single workload has a newer image available in\nits source registry. This is an offline metadata comparison; it\ndoes not pull images. Secret values are never returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "summary": "Check a workload for an available upgrade", + "tags": [ + "workloads" + ] + } + }, "/health": { "get": { "description": "Check if the API is healthy", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index b52327565a..52a8ab88bc 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -37,32 +37,19 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket": { - "description": "PerUser token bucket configuration for this tool.\n+optional", - "properties": { - "maxTokens": { - "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", - "type": "integer" - }, - "refillPeriod": { - "$ref": "#/components/schemas/v1.Duration" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", "properties": { "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" }, "tools": { "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig" }, "type": "array", "uniqueItems": false @@ -70,21 +57,6 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig": { - "properties": { - "name": { - "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", - "type": "string" - }, - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_pkg_audit.Config": { "description": "DEPRECATED: Middleware configuration.\nAuditConfig contains the audit logging configuration", "properties": { @@ -233,6 +205,10 @@ "session_name_claim": { "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", "type": "string" + }, + "subject_provider_name": { + "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", + "type": "string" } }, "type": "object" @@ -271,6 +247,10 @@ "bearer_token_file": { "type": "string" }, + "cached_cimd_client_id": { + "description": "CachedCIMDClientID stores the CIMD metadata URL used as client_id when CIMD\nauthentication was used. Kept separate from CachedClientID (which holds\nDCR-issued IDs) so the two can have independent lifecycles — DCR credential\nrotation clears CachedClientID without touching the stable CIMD URL.\nRead by resolveClientCredentials to send the correct client_id on token refresh.", + "type": "string" + }, "cached_client_id": { "description": "Cached DCR client credentials for persistence across restarts.\nThese are obtained during Dynamic Client Registration and needed to refresh tokens.\nClientID is stored as plain text since it's public information.", "type": "string" @@ -347,61 +327,86 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config": { - "description": "TokenExchangeConfig contains token exchange configuration for external authentication", + "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { + "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", "properties": { - "audience": { - "description": "Audience is the target audience for the exchanged token", + "custom_header_name": { + "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", "type": "string" }, - "client_id": { - "description": "ClientID is the OAuth 2.0 client identifier", + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", "type": "string" }, - "client_secret": { - "description": "ClientSecret is the OAuth 2.0 client secret", + "provider_name": { + "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig": { + "description": "CIMD controls client_id metadata document support. When enabled, the\nembedded authorization server accepts HTTPS URLs as client_id values\nand resolves them via the CIMD protocol instead of requiring DCR.", + "properties": { + "cache_fallback_ttl": { + "description": "CacheFallbackTTL is the fixed TTL applied to every cached CIMD document.\nCache-Control header parsing is not yet implemented; all entries use this value.\nFormat: Go duration string (e.g. \"5m\", \"10m\", \"1h\").\nDefaults to 5 minutes when Enabled is true and this field is omitted.", + "example": "5m", "type": "string" }, - "external_token_header_name": { - "description": "ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"", + "cache_max_size": { + "description": "CacheMaxSize is the maximum number of CIMD documents held in the LRU cache.\nDefaults to 256 when Enabled is true and this field is zero.", + "type": "integer" + }, + "enabled": { + "description": "Enabled activates CIMD client lookup when true.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig": { + "description": "DCRConfig enables RFC 7591 Dynamic Client Registration against the\nupstream authorization server. When set, the client credentials are\nobtained at runtime rather than being pre-provisioned via ClientID /\nClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty.\nMutually exclusive with ClientID.", + "properties": { + "discovery_url": { + "description": "DiscoveryURL is the exact RFC 8414 / OIDC Discovery document URL to\nfetch at runtime. The resolver issues a single GET against this URL\n(no well-known-path fallback) and reads registration_endpoint,\nauthorization_endpoint, token_endpoint,\ntoken_endpoint_auth_methods_supported, and scopes_supported from the\nresponse. Per RFC 8414 §3.3, the document's \"issuer\" field must\nexactly match the upstream issuer configured on the parent\nrun-config.\n\nUse this field when the upstream publishes discovery metadata at a\npath that differs from the issuer-derived well-known paths — for\nexample a multi-tenant IdP whose metadata lives at\nhttps://idp.example.com/tenants/acme/.well-known/openid-configuration.\n\nMutually exclusive with RegistrationEndpoint.", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom", + "initial_access_token_env_var": { + "description": "InitialAccessTokenEnvVar is the name of an environment variable\ncontaining the RFC 7591 initial access token. Mutually exclusive with\nInitialAccessTokenFile.", "type": "string" }, - "scopes": { - "description": "Scopes is the list of scopes to request for the exchanged token", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false + "initial_access_token_file": { + "description": "InitialAccessTokenFile is the path to a file containing the RFC 7591\ninitial access token presented to the registration endpoint. Mutually\nexclusive with InitialAccessTokenEnvVar. Both may be omitted for open\nregistration endpoints.", + "type": "string" }, - "subject_token_type": { - "description": "SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.", + "registration_endpoint": { + "description": "RegistrationEndpoint is the RFC 7591 registration endpoint URL used\ndirectly, bypassing discovery. Because no discovery is performed,\nserver-capability fields (token_endpoint_auth_methods_supported,\nscopes_supported) are unavailable on this code path; the caller is\nexpected to also supply AuthorizationEndpoint, TokenEndpoint, and an\nexplicit Scopes list on the parent OAuth2UpstreamRunConfig. Auth\nmethod falls back to the resolver's default (client_secret_basic).\n\nMutually exclusive with DiscoveryURL.", "type": "string" }, - "token_url": { - "description": "TokenURL is the OAuth 2.0 token endpoint URL", + "software_id": { + "description": "SoftwareID is the RFC 7591 \"software_id\" registration metadata value,\nidentifying the client software independent of any particular\nregistration instance.", + "type": "string" + }, + "software_statement": { + "description": "SoftwareStatement is the RFC 7591 \"software_statement\" JWT asserting\nmetadata about the client software, signed by a party the authorization\nserver trusts.", "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { - "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", + "github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig": { + "description": "IdentityFromToken extracts user identity (subject, name, email) directly from the\nOAuth2 token-endpoint response body using gjson dot-notation paths. When set, the\nembedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type\n(cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative\ntrust-model and uniqueness documentation lives there.", "properties": { - "custom_header_name": { - "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", + "email_path": { + "description": "EmailPath is the dot-notation path to the email address field.", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "name_path": { + "description": "NamePath is the dot-notation path to the display name field.", "type": "string" }, - "provider_name": { - "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "subject_path": { + "description": "SubjectPath is the dot-notation path to the subject (user ID) field.\nRequired when IdentityFromToken is set.", "type": "string" } }, @@ -422,7 +427,7 @@ "type": "string" }, "client_id": { - "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", + "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", "type": "string" }, "client_secret_env_var": { @@ -433,6 +438,12 @@ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "dcr_config": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig" + }, + "identity_from_token": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to `{issuer}/oauth/callback`.", "type": "string" @@ -517,6 +528,17 @@ "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorization_endpoint_base_url}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints remain derived from the issuer.", "type": "string" }, + "baseline_client_scopes": { + "description": "BaselineClientScopes is a baseline set of OAuth 2.0 scopes unioned into every\nDCR registration. All values must appear in ScopesSupported; the auth server\nrejects this RunConfig at startup otherwise. Empty means current behavior is\npreserved (registered scope = client-requested, or DefaultScopes if empty).\nWhen ScopesSupported is empty, the subset check uses registration.DefaultScopes\n(the same set applyDefaults would substitute at startup) — so\nBaselineClientScopes containing standard OIDC scopes works without enumerating\nScopesSupported explicitly.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "cimd": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig" + }, "hmac_secret_files": { "description": "HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes\nand refresh tokens (opaque tokens).\nFirst file is the current secret (must be at least 32 bytes), subsequent files\nare for rotation/verification of existing tokens.\nIf empty, an ephemeral secret will be auto-generated (development only).", "items": { @@ -684,7 +706,7 @@ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig": { - "description": "UserInfo contains configuration for fetching user information (required for OAuth2).", + "description": "UserInfo contains configuration for fetching user information.\nOptional: when nil, the upstream OAuth2 provider derives a deterministic\nsubject by SHA-256-hashing the access token (with a \"tk-\" prefix) instead\nof calling a userinfo endpoint. OIDC providers always derive Subject from\nthe ID token and are unaffected.", "properties": { "additional_headers": { "additionalProperties": { @@ -727,14 +749,22 @@ "acl_user_config": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig" }, + "addr": { + "description": "Addr is the Redis server address (host:port). Required for standalone and cluster modes.\nMutually exclusive with SentinelConfig.", + "type": "string" + }, "address": { - "description": "Address is the Redis-compatible server address for direct mode (host:port).\nMutually exclusive with SentinelConfig.", + "description": "LegacyAddress is the pre-v0.27 serialized name of Addr. DocPlanner fork:\nstill read so an image can start from a RunConfig written by an older\noperator. Never written by the current operator. Remove with PAIINFRA-228.\n\nDeprecated: use Addr.", "type": "string" }, "auth_type": { "description": "AuthType must be \"aclUser\" - only ACL user authentication is supported.", "type": "string" }, + "cluster_mode": { + "description": "ClusterMode enables the Redis Cluster protocol. Requires Addr to be set.", + "type": "boolean" + }, "dial_timeout": { "description": "DialTimeout is the timeout for establishing connections (e.g., \"5s\").", "type": "string" @@ -764,7 +794,7 @@ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver_storage.RedisTLSRunConfig": { - "description": "SentinelTLS configures TLS for Sentinel connections.\nFalls back to TLS config when nil.", + "description": "SentinelTLS configures TLS for Sentinel connections. Only applies when SentinelConfig is set.", "properties": { "ca_cert_file": { "description": "CACertFile is the path to a PEM-encoded CA certificate file.", @@ -791,7 +821,7 @@ "type": "object" }, "github_com_stacklok_toolhive_pkg_authserver_storage.SentinelRunConfig": { - "description": "SentinelConfig contains Sentinel-specific configuration for HA deployments.\nMutually exclusive with Address.", + "description": "SentinelConfig contains Sentinel-specific configuration.\nMutually exclusive with Addr.", "properties": { "db": { "description": "DB is the Redis database number (default: 0).", @@ -855,7 +885,8 @@ "mistral-vibe", "codex", "kimi-cli", - "factory" + "factory", + "copilot-cli" ], "type": "string", "x-enum-varnames": [ @@ -885,7 +916,8 @@ "MistralVibe", "Codex", "KimiCli", - "Factory" + "Factory", + "CopilotCli" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { @@ -1091,6 +1123,76 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config": { + "description": "TokenExchangeConfig contains token exchange configuration for external authentication", + "properties": { + "audience": { + "description": "Audience is the target audience for the exchanged token", + "type": "string" + }, + "client_id": { + "description": "ClientID is the OAuth 2.0 client identifier", + "type": "string" + }, + "client_secret": { + "description": "ClientSecret is the OAuth 2.0 client secret", + "type": "string" + }, + "external_token_header_name": { + "description": "ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"", + "type": "string" + }, + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom", + "type": "string" + }, + "scopes": { + "description": "Scopes is the list of scopes to request for the exchanged token", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject_token_type": { + "description": "SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: oauthproto.TokenTypeAccessToken (default), oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT.\nIf empty, defaults to oauthproto.TokenTypeAccessToken.", + "type": "string" + }, + "token_url": { + "description": "TokenURL is the OAuth 2.0 token endpoint URL", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { + "description": "PerUser token bucket configuration for this tool.\n+optional", + "properties": { + "maxTokens": { + "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", + "type": "integer" + }, + "refillPeriod": { + "$ref": "#/components/schemas/v1.Duration" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig": { + "properties": { + "name": { + "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + }, + "perUser": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig": { "description": "AuthConfig contains the non-secret OAuth configuration when auth is configured.\nNil when auth_status is \"none\".", "properties": { @@ -1231,6 +1333,10 @@ "description": "K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime", "type": "string" }, + "mcpserver_generation": { + "description": "MCPServerGeneration is the K8s .metadata.generation of the MCPServer CR that rendered\nthis RunConfig. The Kubernetes runtime uses it as a monotonic version to prevent stale\nrolling-update pods from overwriting a newer RunConfig's StatefulSet apply. Zero value\nmeans unversioned (backward-compat with older operators, or non-operator callers).", + "type": "integer" + }, "middleware_configs": { "description": "MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.", "items": { @@ -1317,6 +1423,11 @@ "type": "array", "uniqueItems": false }, + "session_ttl": { + "description": "SessionTTL is the inactivity timeout for proxy sessions, expressed as a Go\nduration string (e.g. \"30m\", \"2h\", \"168h\"). Empty uses the transport\ndefault (2h). Negative durations and values that fail time.ParseDuration\nare rejected at runtime.\nString (not time.Duration) keeps the wire format unit-explicit: a\ntime.Duration field serializes as nanoseconds in JSON.", + "example": "2h", + "type": "string" + }, "stateless": { "description": "Stateless indicates the server only supports POST (no SSE/GET).\nWhen true, the proxy returns 405 for incoming GET requests and uses a\nPOST-based health check instead of the default GET probe.\nApplies to both remote URLs and local container workloads.", "type": "boolean" @@ -1337,7 +1448,7 @@ "type": "string" }, "token_exchange_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config" }, "tools_filter": { "description": "DEPRECATED: Middleware configuration.\nToolsFilter is the list of tools to filter", @@ -1859,6 +1970,130 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult": { + "description": "Result is the upgrade-check outcome for the workload. It carries only\nmetadata (status, image references, drift) and never secret values.", + "properties": { + "candidate_image": { + "description": "CandidateImage is the image reference the registry currently reports.", + "type": "string" + }, + "config_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift" + }, + "current_image": { + "description": "CurrentImage is the image reference the workload is currently running.", + "type": "string" + }, + "env_var_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift" + }, + "reason": { + "description": "Reason provides additional context, primarily for StatusUnknown.", + "type": "string" + }, + "registry_server": { + "description": "RegistryServer is the registry entry name the workload was sourced from.\nEmpty when the workload is not registry-sourced.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus" + }, + "workload_name": { + "description": "WorkloadName is the name of the workload that was checked.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift": { + "description": "ConfigDrift describes posture differences (transport, permission profile)\nbetween the workload and the candidate registry entry.", + "properties": { + "permission_profile": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + }, + "transport": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift": { + "description": "EnvVarDrift describes environment variables the candidate registry entry\ndeclares that differ from the workload's current configuration.", + "properties": { + "added": { + "description": "Added lists environment variables the candidate declares that the\nworkload does not currently supply (via plain env vars or secrets).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + }, + "removed": { + "description": "Removed lists environment variables the workload supplies that the\ncandidate no longer declares. Populated on a best-effort basis; may be\nempty even when removals exist (forward-compatible field).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo": { + "properties": { + "default": { + "description": "Default is the candidate's default value. It is cleared (left empty)\nwhenever Secret is true: a secret env var's default could carry sensitive\ndata, and surfacing it in a drift report (which may be logged or returned\nover the API) would leak it. Non-secret defaults are safe to display.", + "type": "string" + }, + "description": { + "description": "Description is the human-readable purpose of the variable.", + "type": "string" + }, + "name": { + "description": "Name is the environment variable name.", + "type": "string" + }, + "required": { + "description": "Required indicates whether the candidate marks the variable as required.", + "type": "boolean" + }, + "secret": { + "description": "Secret indicates whether the variable holds sensitive data.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange": { + "description": "PermissionProfile is set when the candidate's permission profile differs\nfrom the workload's current profile.", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus": { + "description": "Status is the upgrade status for the workload.", + "enum": [ + "up-to-date", + "upgrade-available", + "not-registry-sourced", + "server-not-found", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "StatusUpToDate", + "StatusUpgradeAvailable", + "StatusNotRegistrySourced", + "StatusServerNotFound", + "StatusUnknown" + ] + }, "model.Argument": { "properties": { "choices": { @@ -3279,30 +3514,74 @@ }, "type": "object" }, - "pkg_api_v1.validateSkillRequest": { - "description": "Request to validate a skill definition", + "pkg_api_v1.upgradeCheckBulkResponse": { + "description": "Results of checking multiple workloads for available upgrades", "properties": { - "path": { - "description": "Path to the skill definition directory", - "type": "string" + "results": { + "description": "Results holds one upgrade-check outcome per scoped workload, in the order\nthe workloads were enumerated. Each entry carries only metadata and never\nsecret values.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + }, + "type": "array", + "uniqueItems": false } }, "type": "object" }, - "pkg_api_v1.versionResponse": { + "pkg_api_v1.upgradeCheckResponse": { + "description": "Result of checking a single workload for an available upgrade", "properties": { - "version": { - "type": "string" + "result": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" } }, "type": "object" }, - "pkg_api_v1.workloadListResponse": { - "description": "Response containing a list of workloads", + "pkg_api_v1.upgradeRequest": { + "description": "Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's existing configuration.", "properties": { - "workloads": { - "description": "List of container information for each workload", - "items": { + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Env holds additional or overriding environment variables to merge into the\nupgraded workload's configuration.", + "type": "object" + }, + "secrets": { + "description": "Secrets holds additional secret parameters (`\u003cname\u003e,target=\u003cenv\u003e`) to merge\ninto the upgraded workload's configuration. Only references are accepted;\nno secret values are transmitted in the request.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "pkg_api_v1.validateSkillRequest": { + "description": "Request to validate a skill definition", + "properties": { + "path": { + "description": "Path to the skill definition directory", + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.versionResponse": { + "properties": { + "version": { + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.workloadListResponse": { + "description": "Response containing a list of workloads", + "properties": { + "workloads": { + "description": "List of container information for each workload", + "items": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_core.Workload" }, "type": "array", @@ -3470,6 +3749,10 @@ "description": "RepositoryURL is the URL to the source code repository for the server", "type": "string" }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET)", + "type": "boolean" + }, "status": { "description": "Status indicates whether the server is currently active or deprecated", "type": "string" @@ -3682,6 +3965,10 @@ "description": "RepositoryURL is the URL to the source code repository for the server", "type": "string" }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET)", + "type": "boolean" + }, "status": { "description": "Status indicates whether the server is currently active or deprecated", "type": "string" @@ -4810,6 +5097,71 @@ ] } }, + "/api/v1beta/registry/{name}/refresh": { + "post": { + "description": "Force a refresh of the server-side registry cache for the default registry", + "parameters": [ + { + "description": "Registry name (must be 'default')", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Registry refreshed" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Refresh registry cache", + "tags": [ + "registry" + ] + } + }, "/api/v1beta/registry/{name}/servers": { "get": { "description": "Get a list of servers in a specific registry", @@ -5416,6 +5768,26 @@ }, "description": "Bad Request" }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, "409": { "content": { "application/json": { @@ -5426,6 +5798,16 @@ }, "description": "Conflict" }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, "500": { "content": { "application/json": { @@ -5444,7 +5826,17 @@ } } }, - "description": "Bad Gateway" + "description": "Bad Gateway (upstream registry failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" } }, "summary": "Install a skill", @@ -5632,6 +6024,36 @@ }, "description": "Bad Request" }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, "500": { "content": { "application/json": { @@ -5650,7 +6072,17 @@ } } }, - "description": "Bad Gateway" + "description": "Bad Gateway (upstream registry or git resolver failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" } }, "summary": "Get skill content", @@ -6235,6 +6667,65 @@ ] } }, + "/api/v1beta/workloads/upgrade-check": { + "get": { + "description": "Check all workloads (optionally filtered by group) for newer\nimages available in their source registries. This is an offline\nmetadata comparison; it does not pull images. Secret values are\nnever returned.", + "parameters": [ + { + "description": "Include stopped workloads", + "in": "query", + "name": "all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Filter workloads by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Group not found" + } + }, + "summary": "Check workloads for available upgrades", + "tags": [ + "workloads" + ] + } + }, "/api/v1beta/workloads/{name}": { "delete": { "description": "Delete a workload asynchronously. Returns 202 Accepted immediately.\nThe deletion happens in the background. Poll the workload list to confirm deletion.", @@ -6691,6 +7182,149 @@ ] } }, + "/api/v1beta/workloads/{name}/upgrade": { + "post": { + "description": "Apply a registry-sourced upgrade to a single workload. This\nre-resolves and verifies the candidate image, pulls it, and only\nthen recreates the workload with the new image, preserving the\nexisting configuration. If the workload is already up to date or\nis not registry-sourced, the current check result is returned\nunchanged (no-op). Secret values are never accepted or returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.upgradeRequest", + "summary": "request", + "description": "Upgrade options" + } + ] + } + } + }, + "description": "Upgrade options" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Apply an available upgrade to a workload", + "tags": [ + "workloads" + ] + } + }, + "/api/v1beta/workloads/{name}/upgrade-check": { + "get": { + "description": "Check whether a single workload has a newer image available in\nits source registry. This is an offline metadata comparison; it\ndoes not pull images. Secret values are never returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "summary": "Check a workload for an available upgrade", + "tags": [ + "workloads" + ] + } + }, "/health": { "get": { "description": "Check if the API is healthy", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index eee68e5de1..dbd83aaae2 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -31,31 +31,15 @@ components: description: Version is the schema version of the registry type: string type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket: - description: |- - PerUser token bucket configuration for this tool. - +optional - properties: - maxTokens: - description: |- - MaxTokens is the maximum number of tokens (bucket capacity). - This is also the burst size: the maximum number of requests that can be served - instantaneously before the bucket is depleted. - +kubebuilder:validation:Required - +kubebuilder:validation:Minimum=1 - type: integer - refillPeriod: - $ref: '#/components/schemas/v1.Duration' - type: object github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig: description: |- RateLimitConfig contains the CRD rate limiting configuration. When set, rate limiting middleware is added to the proxy middleware chain. properties: perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' tools: description: |- Tools defines per-tool rate limit overrides. @@ -65,23 +49,10 @@ components: +listMapKey=name +optional items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig' + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig' type: array uniqueItems: false type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig: - properties: - name: - description: |- - Name is the MCP tool name this limit applies to. - +kubebuilder:validation:Required - +kubebuilder:validation:MinLength=1 - type: string - perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' - shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' - type: object github_com_stacklok_toolhive_pkg_audit.Config: description: |- DEPRECATED: Middleware configuration. @@ -242,6 +213,12 @@ components: description: 'SessionNameClaim is the JWT claim to use for role session name (default: "sub").' type: string + subject_provider_name: + description: |- + SubjectProviderName identifies which upstream provider's access token to use + for STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer + token from the incoming HTTP request is used. + type: string type: object github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping: properties: @@ -281,6 +258,14 @@ components: type: string bearer_token_file: type: string + cached_cimd_client_id: + description: |- + CachedCIMDClientID stores the CIMD metadata URL used as client_id when CIMD + authentication was used. Kept separate from CachedClientID (which holds + DCR-issued IDs) so the two can have independent lifecycles — DCR credential + rotation clears CachedClientID without touching the stable CIMD URL. + Read by resolveClientCredentials to send the correct client_id on token refresh. + type: string cached_client_id: description: |- Cached DCR client credentials for persistence across restarts. @@ -348,44 +333,6 @@ components: use_pkce: type: boolean type: object - github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config: - description: TokenExchangeConfig contains token exchange configuration for external - authentication - properties: - audience: - description: Audience is the target audience for the exchanged token - type: string - client_id: - description: ClientID is the OAuth 2.0 client identifier - type: string - client_secret: - description: ClientSecret is the OAuth 2.0 client secret - type: string - external_token_header_name: - description: ExternalTokenHeaderName is the name of the custom header to - use when HeaderStrategy is "custom" - type: string - header_strategy: - description: |- - HeaderStrategy determines how to inject the token - Valid values: HeaderStrategyReplace (default), HeaderStrategyCustom - type: string - scopes: - description: Scopes is the list of scopes to request for the exchanged token - items: - type: string - type: array - uniqueItems: false - subject_token_type: - description: |- - SubjectTokenType specifies the type of the subject token being exchanged. - Common values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT. - If empty, defaults to tokenTypeAccessToken. - type: string - token_url: - description: TokenURL is the OAuth 2.0 token endpoint URL - type: string - type: object github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config: description: |- UpstreamSwapConfig contains configuration for upstream token swap middleware. @@ -406,6 +353,113 @@ components: This is required and must match a configured upstream provider name. type: string type: object + github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig: + description: |- + CIMD controls client_id metadata document support. When enabled, the + embedded authorization server accepts HTTPS URLs as client_id values + and resolves them via the CIMD protocol instead of requiring DCR. + properties: + cache_fallback_ttl: + description: |- + CacheFallbackTTL is the fixed TTL applied to every cached CIMD document. + Cache-Control header parsing is not yet implemented; all entries use this value. + Format: Go duration string (e.g. "5m", "10m", "1h"). + Defaults to 5 minutes when Enabled is true and this field is omitted. + example: 5m + type: string + cache_max_size: + description: |- + CacheMaxSize is the maximum number of CIMD documents held in the LRU cache. + Defaults to 256 when Enabled is true and this field is zero. + type: integer + enabled: + description: Enabled activates CIMD client lookup when true. + type: boolean + type: object + github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig: + description: |- + DCRConfig enables RFC 7591 Dynamic Client Registration against the + upstream authorization server. When set, the client credentials are + obtained at runtime rather than being pre-provisioned via ClientID / + ClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty. + Mutually exclusive with ClientID. + properties: + discovery_url: + description: |- + DiscoveryURL is the exact RFC 8414 / OIDC Discovery document URL to + fetch at runtime. The resolver issues a single GET against this URL + (no well-known-path fallback) and reads registration_endpoint, + authorization_endpoint, token_endpoint, + token_endpoint_auth_methods_supported, and scopes_supported from the + response. Per RFC 8414 §3.3, the document's "issuer" field must + exactly match the upstream issuer configured on the parent + run-config. + + Use this field when the upstream publishes discovery metadata at a + path that differs from the issuer-derived well-known paths — for + example a multi-tenant IdP whose metadata lives at + https://idp.example.com/tenants/acme/.well-known/openid-configuration. + + Mutually exclusive with RegistrationEndpoint. + type: string + initial_access_token_env_var: + description: |- + InitialAccessTokenEnvVar is the name of an environment variable + containing the RFC 7591 initial access token. Mutually exclusive with + InitialAccessTokenFile. + type: string + initial_access_token_file: + description: |- + InitialAccessTokenFile is the path to a file containing the RFC 7591 + initial access token presented to the registration endpoint. Mutually + exclusive with InitialAccessTokenEnvVar. Both may be omitted for open + registration endpoints. + type: string + registration_endpoint: + description: |- + RegistrationEndpoint is the RFC 7591 registration endpoint URL used + directly, bypassing discovery. Because no discovery is performed, + server-capability fields (token_endpoint_auth_methods_supported, + scopes_supported) are unavailable on this code path; the caller is + expected to also supply AuthorizationEndpoint, TokenEndpoint, and an + explicit Scopes list on the parent OAuth2UpstreamRunConfig. Auth + method falls back to the resolver's default (client_secret_basic). + + Mutually exclusive with DiscoveryURL. + type: string + software_id: + description: |- + SoftwareID is the RFC 7591 "software_id" registration metadata value, + identifying the client software independent of any particular + registration instance. + type: string + software_statement: + description: |- + SoftwareStatement is the RFC 7591 "software_statement" JWT asserting + metadata about the client software, signed by a party the authorization + server trusts. + type: string + type: object + github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig: + description: |- + IdentityFromToken extracts user identity (subject, name, email) directly from the + OAuth2 token-endpoint response body using gjson dot-notation paths. When set, the + embedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type + (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative + trust-model and uniqueness documentation lives there. + properties: + email_path: + description: EmailPath is the dot-notation path to the email address field. + type: string + name_path: + description: NamePath is the dot-notation path to the display name field. + type: string + subject_path: + description: |- + SubjectPath is the dot-notation path to the subject (user ID) field. + Required when IdentityFromToken is set. + type: string + type: object github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig: description: |- OAuth2Config contains OAuth 2.0-specific configuration. @@ -424,8 +478,10 @@ components: endpoint. type: string client_id: - description: ClientID is the OAuth 2.0 client identifier registered with - the upstream IDP. + description: |- + ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. + Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained + at runtime via RFC 7591 Dynamic Client Registration and must be left empty. type: string client_secret_env_var: description: |- @@ -437,6 +493,10 @@ components: ClientSecretFile is the path to a file containing the OAuth 2.0 client secret. Mutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE. type: string + dcr_config: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig' + identity_from_token: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig' redirect_uri: description: |- RedirectURI is the callback URL where the upstream IDP will redirect after authentication. @@ -529,6 +589,22 @@ components: `{authorization_endpoint_base_url}/oauth/authorize` instead of `{issuer}/oauth/authorize`. All other endpoints remain derived from the issuer. type: string + baseline_client_scopes: + description: |- + BaselineClientScopes is a baseline set of OAuth 2.0 scopes unioned into every + DCR registration. All values must appear in ScopesSupported; the auth server + rejects this RunConfig at startup otherwise. Empty means current behavior is + preserved (registered scope = client-requested, or DefaultScopes if empty). + When ScopesSupported is empty, the subset check uses registration.DefaultScopes + (the same set applyDefaults would substitute at startup) — so + BaselineClientScopes containing standard OIDC scopes works without enumerating + ScopesSupported explicitly. + items: + type: string + type: array + uniqueItems: false + cimd: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig' hmac_secret_files: description: |- HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes @@ -701,8 +777,12 @@ components: uniqueItems: false type: object github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig: - description: UserInfo contains configuration for fetching user information (required - for OAuth2). + description: |- + UserInfo contains configuration for fetching user information. + Optional: when nil, the upstream OAuth2 provider derives a deterministic + subject by SHA-256-hashing the access token (with a "tk-" prefix) instead + of calling a userinfo endpoint. OIDC providers always derive Subject from + the ID token and are unaffected. properties: additional_headers: additionalProperties: @@ -739,15 +819,27 @@ components: properties: acl_user_config: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig' - address: + addr: description: |- - Address is the Redis-compatible server address for direct mode (host:port). + Addr is the Redis server address (host:port). Required for standalone and cluster modes. Mutually exclusive with SentinelConfig. type: string + address: + description: |- + LegacyAddress is the pre-v0.27 serialized name of Addr. DocPlanner fork: + still read so an image can start from a RunConfig written by an older + operator. Never written by the current operator. Remove with PAIINFRA-228. + + Deprecated: use Addr. + type: string auth_type: description: AuthType must be "aclUser" - only ACL user authentication is supported. type: string + cluster_mode: + description: ClusterMode enables the Redis Cluster protocol. Requires Addr + to be set. + type: boolean dial_timeout: description: DialTimeout is the timeout for establishing connections (e.g., "5s"). @@ -769,9 +861,8 @@ components: type: string type: object github_com_stacklok_toolhive_pkg_authserver_storage.RedisTLSRunConfig: - description: |- - SentinelTLS configures TLS for Sentinel connections. - Falls back to TLS config when nil. + description: SentinelTLS configures TLS for Sentinel connections. Only applies + when SentinelConfig is set. properties: ca_cert_file: description: CACertFile is the path to a PEM-encoded CA certificate file. @@ -793,8 +884,8 @@ components: type: object github_com_stacklok_toolhive_pkg_authserver_storage.SentinelRunConfig: description: |- - SentinelConfig contains Sentinel-specific configuration for HA deployments. - Mutually exclusive with Address. + SentinelConfig contains Sentinel-specific configuration. + Mutually exclusive with Addr. properties: db: description: 'DB is the Redis database number (default: 0).' @@ -851,6 +942,7 @@ components: - codex - kimi-cli - factory + - copilot-cli type: string x-enum-varnames: - RooCode @@ -880,6 +972,7 @@ components: - Codex - KimiCli - Factory + - CopilotCli github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: @@ -1061,6 +1154,73 @@ components: description: Whether to print resolved overlay paths for debugging type: boolean type: object + github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config: + description: TokenExchangeConfig contains token exchange configuration for external + authentication + properties: + audience: + description: Audience is the target audience for the exchanged token + type: string + client_id: + description: ClientID is the OAuth 2.0 client identifier + type: string + client_secret: + description: ClientSecret is the OAuth 2.0 client secret + type: string + external_token_header_name: + description: ExternalTokenHeaderName is the name of the custom header to + use when HeaderStrategy is "custom" + type: string + header_strategy: + description: |- + HeaderStrategy determines how to inject the token + Valid values: HeaderStrategyReplace (default), HeaderStrategyCustom + type: string + scopes: + description: Scopes is the list of scopes to request for the exchanged token + items: + type: string + type: array + uniqueItems: false + subject_token_type: + description: |- + SubjectTokenType specifies the type of the subject token being exchanged. + Common values: oauthproto.TokenTypeAccessToken (default), oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT. + If empty, defaults to oauthproto.TokenTypeAccessToken. + type: string + token_url: + description: TokenURL is the OAuth 2.0 token endpoint URL + type: string + type: object + github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket: + description: |- + PerUser token bucket configuration for this tool. + +optional + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + +kubebuilder:validation:Required + +kubebuilder:validation:Minimum=1 + type: integer + refillPeriod: + $ref: '#/components/schemas/v1.Duration' + type: object + github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig: + properties: + name: + description: |- + Name is the MCP tool name this limit applies to. + +kubebuilder:validation:Required + +kubebuilder:validation:MinLength=1 + type: string + perUser: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' + shared: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' + type: object github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig: description: |- AuthConfig contains the non-secret OAuth configuration when auth is configured. @@ -1190,6 +1350,13 @@ components: K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template Only applicable when using Kubernetes runtime type: string + mcpserver_generation: + description: |- + MCPServerGeneration is the K8s .metadata.generation of the MCPServer CR that rendered + this RunConfig. The Kubernetes runtime uses it as a monotonic version to prevent stale + rolling-update pods from overwriting a newer RunConfig's StatefulSet apply. Zero value + means unversioned (backward-compat with older operators, or non-operator callers). + type: integer middleware_configs: description: |- MiddlewareConfigs contains the list of middleware to apply to the transport @@ -1267,6 +1434,16 @@ components: type: string type: array uniqueItems: false + session_ttl: + description: |- + SessionTTL is the inactivity timeout for proxy sessions, expressed as a Go + duration string (e.g. "30m", "2h", "168h"). Empty uses the transport + default (2h). Negative durations and values that fail time.ParseDuration + are rejected at runtime. + String (not time.Duration) keeps the wire format unit-explicit: a + time.Duration field serializes as nanoseconds in JSON. + example: 2h + type: string stateless: description: |- Stateless indicates the server only supports POST (no SSE/GET). @@ -1290,7 +1467,7 @@ components: ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations type: string token_exchange_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_auth_tokenexchange.Config' + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_oauthproto_tokenexchange.Config' tools_filter: description: |- DEPRECATED: Middleware configuration. @@ -1761,6 +1938,118 @@ components: WARNING: This should only be used for development/testing. type: boolean type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult: + description: |- + Result is the upgrade-check outcome for the workload. It carries only + metadata (status, image references, drift) and never secret values. + properties: + candidate_image: + description: CandidateImage is the image reference the registry currently + reports. + type: string + config_drift: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift' + current_image: + description: CurrentImage is the image reference the workload is currently + running. + type: string + env_var_drift: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift' + reason: + description: Reason provides additional context, primarily for StatusUnknown. + type: string + registry_server: + description: |- + RegistryServer is the registry entry name the workload was sourced from. + Empty when the workload is not registry-sourced. + type: string + status: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus' + workload_name: + description: WorkloadName is the name of the workload that was checked. + type: string + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift: + description: |- + ConfigDrift describes posture differences (transport, permission profile) + between the workload and the candidate registry entry. + properties: + permission_profile: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange' + transport: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange' + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift: + description: |- + EnvVarDrift describes environment variables the candidate registry entry + declares that differ from the workload's current configuration. + properties: + added: + description: |- + Added lists environment variables the candidate declares that the + workload does not currently supply (via plain env vars or secrets). + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo' + type: array + uniqueItems: false + removed: + description: |- + Removed lists environment variables the workload supplies that the + candidate no longer declares. Populated on a best-effort basis; may be + empty even when removals exist (forward-compatible field). + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo' + type: array + uniqueItems: false + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo: + properties: + default: + description: |- + Default is the candidate's default value. It is cleared (left empty) + whenever Secret is true: a secret env var's default could carry sensitive + data, and surfacing it in a drift report (which may be logged or returned + over the API) would leak it. Non-secret defaults are safe to display. + type: string + description: + description: Description is the human-readable purpose of the variable. + type: string + name: + description: Name is the environment variable name. + type: string + required: + description: Required indicates whether the candidate marks the variable + as required. + type: boolean + secret: + description: Secret indicates whether the variable holds sensitive data. + type: boolean + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange: + description: |- + PermissionProfile is set when the candidate's permission profile differs + from the workload's current profile. + properties: + from: + type: string + to: + type: string + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus: + description: Status is the upgrade status for the workload. + enum: + - up-to-date + - upgrade-available + - not-registry-sourced + - server-not-found + - unknown + type: string + x-enum-varnames: + - StatusUpToDate + - StatusUpgradeAvailable + - StatusNotRegistrySourced + - StatusServerNotFound + - StatusUnknown model.Argument: properties: choices: @@ -2837,6 +3126,47 @@ components: description: Success message type: string type: object + pkg_api_v1.upgradeCheckBulkResponse: + description: Results of checking multiple workloads for available upgrades + properties: + results: + description: |- + Results holds one upgrade-check outcome per scoped workload, in the order + the workloads were enumerated. Each entry carries only metadata and never + secret values. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' + type: array + uniqueItems: false + type: object + pkg_api_v1.upgradeCheckResponse: + description: Result of checking a single workload for an available upgrade + properties: + result: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' + type: object + pkg_api_v1.upgradeRequest: + description: Request to apply an available upgrade to a workload. All fields + are optional; an empty body applies the upgrade preserving the workload's + existing configuration. + properties: + env: + additionalProperties: + type: string + description: |- + Env holds additional or overriding environment variables to merge into the + upgraded workload's configuration. + type: object + secrets: + description: |- + Secrets holds additional secret parameters (`,target=`) to merge + into the upgraded workload's configuration. Only references are accepted; + no secret values are transmitted in the request. + items: + type: string + type: array + uniqueItems: false + type: object pkg_api_v1.validateSkillRequest: description: Request to validate a skill definition properties: @@ -3007,6 +3337,9 @@ components: description: RepositoryURL is the URL to the source code repository for the server type: string + stateless: + description: Stateless indicates the server only supports POST (no SSE/GET) + type: boolean status: description: Status indicates whether the server is currently active or deprecated @@ -3211,6 +3544,9 @@ components: description: RepositoryURL is the URL to the source code repository for the server type: string + stateless: + description: Stateless indicates the server only supports POST (no SSE/GET) + type: boolean status: description: Status indicates whether the server is currently active or deprecated @@ -3915,6 +4251,47 @@ paths: summary: Update registry configuration tags: - registry + /api/v1beta/registry/{name}/refresh: + post: + description: Force a refresh of the server-side registry cache for the default + registry + parameters: + - description: Registry name (must be 'default') + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Registry refreshed + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Registry authentication required or upstream registry unavailable + summary: Refresh registry cache + tags: + - registry /api/v1beta/registry/{name}/servers: get: description: Get a list of servers in a specific registry @@ -4342,12 +4719,30 @@ paths: schema: type: string description: Bad Request + "401": + content: + application/json: + schema: + type: string + description: Unauthorized (registry refused credentials) + "404": + content: + application/json: + schema: + type: string + description: Not Found (artifact not present in registry) "409": content: application/json: schema: type: string description: Conflict + "429": + content: + application/json: + schema: + type: string + description: Too Many Requests (registry rate limit) "500": content: application/json: @@ -4359,7 +4754,13 @@ paths: application/json: schema: type: string - description: Bad Gateway + description: Bad Gateway (upstream registry failure) + "504": + content: + application/json: + schema: + type: string + description: Gateway Timeout (upstream pull timed out) summary: Install a skill tags: - skills @@ -4578,6 +4979,24 @@ paths: schema: type: string description: Bad Request + "401": + content: + application/json: + schema: + type: string + description: Unauthorized (registry refused credentials) + "404": + content: + application/json: + schema: + type: string + description: Not Found (artifact not present in registry) + "429": + content: + application/json: + schema: + type: string + description: Too Many Requests (registry rate limit) "500": content: application/json: @@ -4589,7 +5008,13 @@ paths: application/json: schema: type: string - description: Bad Gateway + description: Bad Gateway (upstream registry or git resolver failure) + "504": + content: + application/json: + schema: + type: string + description: Gateway Timeout (upstream pull timed out) summary: Get skill content tags: - skills @@ -5033,6 +5458,101 @@ paths: summary: Stop a workload tags: - workloads + /api/v1beta/workloads/{name}/upgrade: + post: + description: |- + Apply a registry-sourced upgrade to a single workload. This + re-resolves and verifies the candidate image, pulls it, and only + then recreates the workload with the new image, preserving the + existing configuration. If the workload is already up to date or + is not registry-sourced, the current check result is returned + unchanged (no-op). Secret values are never accepted or returned. + parameters: + - description: Workload name + in: path + name: name + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.upgradeRequest' + description: Upgrade options + summary: request + description: Upgrade options + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.upgradeCheckResponse' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + "422": + content: + application/json: + schema: + type: string + description: Unprocessable Entity + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Apply an available upgrade to a workload + tags: + - workloads + /api/v1beta/workloads/{name}/upgrade-check: + get: + description: |- + Check whether a single workload has a newer image available in + its source registry. This is an offline metadata comparison; it + does not pull images. Secret values are never returned. + parameters: + - description: Workload name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.upgradeCheckResponse' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + summary: Check a workload for an available upgrade + tags: + - workloads /api/v1beta/workloads/delete: post: description: |- @@ -5125,6 +5645,46 @@ paths: summary: Stop workloads in bulk tags: - workloads + /api/v1beta/workloads/upgrade-check: + get: + description: |- + Check all workloads (optionally filtered by group) for newer + images available in their source registries. This is an offline + metadata comparison; it does not pull images. Secret values are + never returned. + parameters: + - description: Include stopped workloads + in: query + name: all + schema: + type: boolean + - description: Filter workloads by group name + in: query + name: group + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Group not found + summary: Check workloads for available upgrades + tags: + - workloads /health: get: description: Check if the API is healthy diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml index ab732816c0..75714c2bbb 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml @@ -5,10 +5,6 @@ # HTTP from another registry server, so no volumes or volume mounts are # needed -- the registry server handles the network call internally. # -# API sources must use "format: upstream" because the remote registry -# serves data in the upstream registry server format (not the legacy -# toolhive JSON format). -# # This is functionally equivalent to mcpregistry-api-basic.yaml but uses # the decoupled configYAML path instead of the legacy typed fields. @@ -22,7 +18,6 @@ spec: configYAML: | sources: - name: upstream - format: upstream api: # Base API URL for the upstream registry server endpoint: http://upstream-registry.default.svc.cluster.local:8080 diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml index bdfec882e9..80548210b7 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml @@ -23,55 +23,77 @@ metadata: data: registry.json: | { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/upstream-registry.schema.json", "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "filesystem": { - "description": "Allows you to do filesystem operations", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": ["read_file", "write_file", "list_directory"], - "tags": ["filesystem", "production"], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} + "meta": { + "last_updated": "2025-09-08T12:00:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.example/filesystem", + "description": "Allows you to do filesystem operations", + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "docker.io/mcp/filesystem:latest", + "transport": { "type": "stdio" } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.example": { + "docker.io/mcp/filesystem:latest": { + "tags": ["filesystem", "production"] + } + } + } } - } - }, - "github": { - "description": "Provides integration with GitHub APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": ["create_issue", "create_pull_request"], - "tags": ["github", "production"], - "image": "ghcr.io/github/github-mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [".github.com"], - "allow_port": [443] + }, + { + "name": "io.github.example/github", + "description": "Provides integration with GitHub APIs", + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:latest", + "transport": { "type": "stdio" } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.example": { + "ghcr.io/github/github-mcp-server:latest": { + "tags": ["github", "production"] + } + } } } - } - }, - "experimental-ai": { - "description": "Experimental AI tools - not production ready", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": ["analyze_code"], - "tags": ["ai", "experimental"], - "image": "docker.io/mcp/experimental-ai:latest", - "permissions": { - "network": { - "outbound": {} + }, + { + "name": "io.github.example/experimental-ai", + "description": "Experimental AI tools - not production ready", + "version": "0.1.0", + "packages": [ + { + "registryType": "oci", + "identifier": "docker.io/mcp/experimental-ai:latest", + "transport": { "type": "stdio" } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.example": { + "docker.io/mcp/experimental-ai:latest": { + "tags": ["ai", "experimental"] + } + } + } } } - } + ] } } --- @@ -85,7 +107,6 @@ spec: configYAML: | sources: - name: production - format: toolhive file: # This path must match the volumeMount mountPath below path: /config/registry/production/registry.json diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml index aedc734bd5..31b879ef28 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml @@ -43,7 +43,6 @@ spec: configYAML: | sources: - name: private-repo - format: toolhive git: repository: https://github.com/your-org/private-mcp-registry branch: main diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml index 9c37d6beb2..859821387a 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml @@ -23,7 +23,6 @@ spec: configYAML: | sources: - name: k8s - format: upstream kubernetes: {} registries: - name: default diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml index d9f60bc1e4..0c6d2a6840 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml @@ -25,7 +25,6 @@ spec: configYAML: | sources: - name: production - format: toolhive file: path: /config/registry/production/registry.json registries: diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml index 69fd5e4d34..83d6952bec 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml @@ -47,7 +47,6 @@ spec: configYAML: | sources: - name: production - format: toolhive file: path: /config/registry/production/registry.json registries: diff --git a/examples/registry-with-remote-servers.json b/examples/registry-with-remote-servers.json index e0a96a1f42..720517b556 100644 --- a/examples/registry-with-remote-servers.json +++ b/examples/registry-with-remote-servers.json @@ -1,39 +1,89 @@ { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/upstream-registry.schema.json", "version": "1.0.0", - "last_updated": "2025-01-12T00:00:00Z", - "servers": { - "example-container": { - "image": "example/mcp-server:latest", - "description": "Example container-based MCP server", - "tier": "community", - "status": "active", - "transport": "stdio", - "tools": ["example-tool"], - "tags": ["example", "container"] - } + "meta": { + "last_updated": "2025-01-12T00:00:00Z" }, - "remote_servers": { - "example-remote": { - "url": "https://api.example.com/mcp", - "description": "Example remote MCP server accessed via HTTP", - "tier": "community", - "status": "active", - "transport": "sse", - "tools": ["remote-tool"], - "tags": ["example", "remote"], - "headers": [ - { - "name": "X-API-Key", - "description": "API key for authentication", - "required": true, - "secret": true + "data": { + "servers": [ + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/example-container", + "description": "Example container-based MCP server", + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "example/mcp-server:latest", + "transport": { + "type": "stdio" + } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "example/mcp-server:latest": { + "status": "active", + "tags": [ + "example", + "container" + ], + "tier": "Community", + "tools": [ + "example-tool" + ] + } + } + } + } + }, + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/example-remote", + "description": "Example remote MCP server accessed via HTTP", + "version": "1.0.0", + "remotes": [ + { + "type": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "description": "API key for authentication", + "isRequired": true, + "isSecret": true, + "name": "X-API-Key" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "https://api.example.com/mcp": { + "oauth_config": { + "client_id": "example-client-id", + "issuer": "https://accounts.example.com", + "scopes": [ + "openid", + "profile", + "email" + ] + }, + "status": "active", + "tags": [ + "example", + "remote" + ], + "tier": "Community", + "tools": [ + "remote-tool" + ] + } + } + } } - ], - "oauth_config": { - "issuer": "https://accounts.example.com", - "client_id": "example-client-id", - "scopes": ["openid", "profile", "email"] } - } + ] } } \ No newline at end of file diff --git a/go.mod b/go.mod index 1cab9fc180..d4dc11c786 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,20 @@ go 1.26.0 require ( dario.cat/mergo v1.0.2 github.com/1password/onepassword-sdk-go v0.3.1 - github.com/alicebob/miniredis/v2 v2.37.0 - github.com/aws/aws-sdk-go-v2 v1.41.7 - github.com/aws/aws-sdk-go-v2/config v1.32.17 - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 - github.com/cedar-policy/cedar-go v1.6.0 + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/atotto/clipboard v0.1.4 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.24 + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 + github.com/cedar-policy/cedar-go v1.6.1 github.com/cenkalti/backoff/v5 v5.0.3 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/containerd/errdefs v1.0.0 github.com/coreos/go-oidc/v3 v3.18.0 - github.com/docker/docker v28.5.2+incompatible - github.com/docker/go-connections v0.7.0 + github.com/docker/go-connections v0.7.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-chi/chi/v5 v5.3.0 github.com/go-git/go-billy/v5 v5.9.0 @@ -30,7 +32,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lestrrat-go/httprc/v3 v3.0.5 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mark3labs/mcp-go v0.48.0 + github.com/mark3labs/mcp-go v0.54.1 github.com/moby/moby/client v0.4.1 github.com/modelcontextprotocol/registry v1.7.9 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 @@ -38,19 +40,19 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/ory/fosite v0.49.0 - github.com/pelletier/go-toml/v2 v2.3.0 + github.com/pelletier/go-toml/v2 v2.3.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 - github.com/redis/go-redis/v9 v9.18.0 - github.com/shirou/gopsutil/v4 v4.26.2 + github.com/redis/go-redis/v9 v9.20.0 + github.com/shirou/gopsutil/v4 v4.26.3 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260421.0 - github.com/stacklok/toolhive-core v0.0.16 + github.com/stacklok/toolhive-catalog v0.20260610.0 + github.com/stacklok/toolhive-core v0.0.24 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd - github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go v0.42.0 github.com/tidwall/gjson v1.18.0 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zalando/go-keyring v0.2.8 @@ -61,8 +63,8 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.45.0 go.uber.org/mock v0.6.0 go.uber.org/zap v1.28.0 - golang.ngrok.com/ngrok/v2 v2.1.2 - golang.org/x/exp/jsonrpc2 v0.0.0-20260410095643-746e56fc9e2f + golang.ngrok.com/ngrok/v2 v2.1.4 + golang.org/x/exp/jsonrpc2 v0.0.0-20260603202125-055de637280b golang.org/x/mod v0.40.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 @@ -81,13 +83,18 @@ require github.com/getsentry/sentry-go/otel v0.44.1 require github.com/hashicorp/golang-lru/v2 v2.0.7 -require go.starlark.net v0.0.0-20260326113308-fadfc96def35 +require go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 require ( - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect ) @@ -99,27 +106,26 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.10.2 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -131,7 +137,6 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/docker/cli v29.4.3+incompatible // indirect @@ -196,18 +201,18 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.7 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/goveralls v0.0.12 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/moby/go-archive v0.1.0 // indirect - github.com/moby/moby/api v1.54.2 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.2 + github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect @@ -216,10 +221,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/morikuni/aec v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/muesli/termenv v0.16.0 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -257,7 +261,7 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/sv-tools/openapi v0.4.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect @@ -290,7 +294,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.ngrok.com/muxado/v2 v2.0.1 // indirect - golang.org/x/exp/event v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/exp/event v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect @@ -309,7 +313,7 @@ require ( modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - oras.land/oras-go/v2 v2.6.2 // indirect + oras.land/oras-go/v2 v2.6.2 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect @@ -349,3 +353,10 @@ require ( golang.org/x/sys v0.47.0 k8s.io/client-go v0.35.3 ) + +// go-containerregistry v0.21.6 introduced a semaphore leak that never releases +// its acquired slots, causing image pulls of 5+ layers to hang indefinitely. +// The fix is merged upstream (google/go-containerregistry#2308) but not yet +// released. Pin to the last known-good release until a fixed version ships. +// TODO: remove this replace once go-containerregistry > v0.21.6 is released. +replace github.com/google/go-containerregistry => github.com/google/go-containerregistry v0.21.5 diff --git a/go.sum b/go.sum index 47ce11423d..45acfd7141 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -10,8 +14,12 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -26,35 +34,12 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -63,39 +48,95 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/config v1.32.24 h1:aEDEj533yGdVvEHfkCY0D/1FbDrjnZr4pIulxRjqpHs= +github.com/aws/aws-sdk-go-v2/config v1.32.24/go.mod h1:yZtrGKJGlqfEW+/m2uTsJK+Jz7xF5R0eZfgcIG9m1ss= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/credentials v1.19.23 h1:Zhu3GOpRCkNjtE/gJpuPDsytSnaCCTQk8neAGsgzG5Y= +github.com/aws/aws-sdk-go-v2/credentials v1.19.23/go.mod h1:VsJF2ropPB37gDr7M2rLSpCE8IQWdpl62uae7qxZmqU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 h1:s/zDSG/a/Su9aX+v0Ld9cimUCdkr5FWPmBV8owaEbZY= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3/go.mod h1:/iSgiUor15ZuxFGQSTf3lA2FmKxFsQoc2tADOarQBSw= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 h1:6Xt6Ztjkwdia/7EtEaG7ki/qZUYlCcd7tGUotQed1QE= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.5/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= @@ -104,30 +145,47 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/cedar-policy/cedar-go v1.6.0 h1:5dYWkrQjza+GzdJxnzmus7Ag/2pHv4bYWe460/kDlAM= github.com/cedar-policy/cedar-go v1.6.0/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= +github.com/cedar-policy/cedar-go v1.6.1 h1:eoBQI/Owk6XQIXTjFYnvL/mX3Iu/Ecns9RH0SdQM9Mk= +github.com/cedar-policy/cedar-go v1.6.1/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -141,6 +199,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -151,9 +211,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/cristalhq/jwt/v4 v4.0.2 h1:g/AD3h0VicDamtlM70GWGElp8kssQEv+5wYd7L9WOhU= github.com/cristalhq/jwt/v4 v4.0.2/go.mod h1:HnYraSNKDRag1DZP92rYHyrjyQHnVEHPNqesmzs+miQ= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= @@ -181,6 +241,10 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= @@ -235,6 +299,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -245,6 +311,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= @@ -254,6 +322,8 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -262,58 +332,104 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= +github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= +github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= +github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= +github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= +github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= +github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -363,8 +479,12 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -373,6 +493,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -380,17 +502,25 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= +github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= @@ -398,6 +528,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= @@ -430,6 +562,8 @@ github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA= github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= +github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= @@ -443,38 +577,46 @@ github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgO github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jandelgado/gcov2lcov v1.0.5 h1:rkBt40h0CVK4oCb8Dps950gvfd1rYvQ8+cWa346lVU0= github.com/jandelgado/gcov2lcov v1.0.5/go.mod h1:NnSxK6TMlg1oGDBfGelGbjgorT5/L3cchlbtgFYZSss= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -483,6 +625,8 @@ github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -496,6 +640,10 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -518,6 +666,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= @@ -534,15 +684,19 @@ github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0 github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= +github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= @@ -550,26 +704,35 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= +github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA= +github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0= +github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg= github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -577,6 +740,9 @@ github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGA github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -589,12 +755,16 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -657,6 +827,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= +github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/ory/fosite v0.49.0 h1:KNqO7RVt/1X8F08/UI0Y+GRvcpscCWgjqvpLBQPRovo= @@ -674,6 +846,8 @@ github.com/ory/x v0.0.665/go.mod h1:7SCTki3N0De3ZpqlxhxU/94ZrOCfNEnXwVtd0xVt+L8= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -701,16 +875,22 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -720,6 +900,8 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= @@ -727,6 +909,8 @@ github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PK github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME= github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg= +github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= +github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= @@ -740,26 +924,46 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.0 h1:rL7SghHd5HLCtsCrxw0yQg+NczGvM75EjSPPWuGjaiQ= +github.com/sigstore/rekor v1.5.0/go.mod h1:D7JoVCUkxwQOpPDNYeu+CE8zeBC18Y5uDo6tF8s2rcQ= github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= +github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= +github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= +github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= +github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5 h1:+9C6CUkv+J4iT67Lx+H1EGBfAdoAHqXumHadeIj9jA4= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5 h1:BpQx6AhjwIN9LmlO4ypkcMcHiWiepgZQGSw5U69frHU= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5 h1:OFwQZgWkB/6J6W5sy3SkXE4pJnhNRnE2cJd8ySXmHpo= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= +github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -778,19 +982,27 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stacklok/toolhive-catalog v0.20260421.0 h1:aHHjwseUNq0RS1OltWFOgh6z+dik6bmEx2X/cBIVuls= github.com/stacklok/toolhive-catalog v0.20260421.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= +github.com/stacklok/toolhive-catalog v0.20260518.0 h1:J4Adoaedsm09RB/8Dx2PSlGgZ4BscYOgmUrWzMIcMY4= +github.com/stacklok/toolhive-catalog v0.20260518.0/go.mod h1:e/zMn+cmBvXMGtRJu0WYO71h0UVRgXTq32849PkQgss= +github.com/stacklok/toolhive-catalog v0.20260610.0 h1:egZqiHXIfxjda8DTQHkP7yLiQbXdryuqqTboVWKH82Q= +github.com/stacklok/toolhive-catalog v0.20260610.0/go.mod h1:SxQdTOXo7YsJNZVQ8iMmEYoMWSO+/nZeIzeWD1ZFWH4= github.com/stacklok/toolhive-core v0.0.16 h1:Td/o/zrO3Pbr6tkx4r0k8Bqn9DySlwgah2vknE8tj5A= github.com/stacklok/toolhive-core v0.0.16/go.mod h1:iHp39rCHZxXzU5FmXKK9aoH48NE+K0ob+MtdTS9CvAk= +github.com/stacklok/toolhive-core v0.0.21 h1:UxCDSmhw0k4QP6gr0F1CLfPuI3bVHBu3VuHt/DLYH50= +github.com/stacklok/toolhive-core v0.0.21/go.mod h1:59Fo8iGJEEGJB6i/N/4fiB2Z+7AhBbPrguiggtED5TU= +github.com/stacklok/toolhive-core v0.0.24 h1:FdZTaXu3yopS7v5+x6UYk8ZxdGa7JLJA7jUDz9k/9+I= +github.com/stacklok/toolhive-core v0.0.24/go.mod h1:h03BJmsQpriiZiawiIQPn/2mc48Pc0sHaqKLZxrfRHI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -798,6 +1010,10 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -806,8 +1022,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= @@ -818,12 +1032,16 @@ github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHq github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= +github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -835,10 +1053,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= @@ -849,6 +1071,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= @@ -857,6 +1081,8 @@ github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -886,9 +1112,13 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A= @@ -901,57 +1131,95 @@ go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 h1:f4beMGDKiVzg9IcX7/VuWV go.opentelemetry.io/contrib/propagators/jaeger v1.21.1/go.mod h1:U9jhkEl8d1LL+QXY7q3kneJWJugiN3kZJV2OWz3hkBY= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 h1:Qb+5A+JbIjXwO7l4HkRUhgIn4Bzz0GNS2q+qdmSx+0c= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1/go.mod h1:G4vNCm7fRk0kjZ6pGNLo5SpLxAUvOfSrcaegnT8TPck= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= +go.opentelemetry.io/otel/exporters/zipkin v1.21.0/go.mod h1:83oMKR6DzmHisFOW3I+yIMGZUTjxiWaiBI8M8+TU5zE= go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4= go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 h1:3cIeOhZXLdLHnBoLyKdJu4SEAEuM/av/VFzB5twLo8k= +go.starlark.net v0.0.0-20260522144826-ec58d4b459e2/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= +go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -960,6 +1228,8 @@ golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM= golang.ngrok.com/ngrok/v2 v2.1.2 h1:8ll1/EH/jlos6/BWhmtoz/Y4+GgSW3e3+JKu4BJrQU8= golang.ngrok.com/ngrok/v2 v2.1.2/go.mod h1:1bwK0+ZB4RJCJdqaXs2mvdsjeSk+x4YrrLn8IqOrIGo= +golang.ngrok.com/ngrok/v2 v2.1.4 h1:0JQZRqzVGBYluIi5MuhxNYx653qxpN7AiNwNJzoa9DQ= +golang.ngrok.com/ngrok/v2 v2.1.4/go.mod h1:1bwK0+ZB4RJCJdqaXs2mvdsjeSk+x4YrrLn8IqOrIGo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -973,25 +1243,35 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/event v0.0.0-20260312153236-7ab1446f8b90 h1:VIKxsuSw/bPhvjnuIZPuMSWDEDvHGAmMytHXdtWuO68= golang.org/x/exp/event v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:fkoWXYWD397AL2Y3xF7vvyrz6dhJ5rDRrKMZvfnrM3o= +golang.org/x/exp/event v0.0.0-20260410095643-746e56fc9e2f h1:YHf3wxw5NqqQAc7dMuxCMInepnLP+u/jBVjet0qpYXw= +golang.org/x/exp/event v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:k42SSvLLwqm/AUsIHNoVjjzXtDkAkMs4ttVJfiwxIfk= golang.org/x/exp/jsonrpc2 v0.0.0-20260410095643-746e56fc9e2f h1:u1LeTNol3OqLaQNr9EKsmTz3y9cJ0O3nxvDR4JSV/+8= golang.org/x/exp/jsonrpc2 v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:fA1ErkYRDYEBIaye2R4yrszC5HFVyLmGigxSQxH+NHs= +golang.org/x/exp/jsonrpc2 v0.0.0-20260508232706-74f9aab9d74a h1:M/srLnnRhZyDcQZFIzJlM+ljIdP9dF5rBnoBRye2mYs= +golang.org/x/exp/jsonrpc2 v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:T8WG9RoOCLSSBGdia5R05Tcnfzy1ISOxcWjPC8Vqsm4= +golang.org/x/exp/jsonrpc2 v0.0.0-20260603202125-055de637280b h1:oe0t8Wf6SAJL9TJj1xl0m6bUb6Oakx26GG5RCtxpDAI= +golang.org/x/exp/jsonrpc2 v0.0.0-20260603202125-055de637280b/go.mod h1:T8WG9RoOCLSSBGdia5R05Tcnfzy1ISOxcWjPC8Vqsm4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1004,11 +1284,13 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1018,6 +1300,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1046,36 +1330,46 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1089,12 +1383,16 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1107,14 +1405,28 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -1149,6 +1461,8 @@ k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= @@ -1183,6 +1497,10 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs= +oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/pkg/api/errors/handler.go b/pkg/api/errors/handler.go index 9a28f33620..cf774263de 100644 --- a/pkg/api/errors/handler.go +++ b/pkg/api/errors/handler.go @@ -44,7 +44,11 @@ func ErrorHandler(fn HandlerWithError) http.HandlerFunc { // Extract HTTP status code from the error code := httperr.Code(err) - // For 5xx errors, log the full error but return a generic message + // For 5xx errors, log the full error and report it to Sentry/OTel. + // 500 Internal Server Error may wrap internal details (DB drivers, + // container runtimes, connection strings) so we return only the + // generic status text. 502/503/504 represent upstream failures the + // caller can act on — their messages are safe to return verbatim. if code >= http.StatusInternalServerError { slog.Error("internal server error", "error", err) span := trace.SpanFromContext(r.Context()) @@ -56,6 +60,11 @@ func ErrorHandler(fn HandlerWithError) http.HandlerFunc { // Sentry span processor only creates transactions; call CaptureException // explicitly so 5xx errors also appear as Issues in the Sentry Issues tab. sentrypkg.CaptureException(r, err) + + if isUpstreamStatus(code) { + http.Error(w, err.Error(), code) + return + } http.Error(w, http.StatusText(code), code) return } @@ -64,3 +73,14 @@ func ErrorHandler(fn HandlerWithError) http.HandlerFunc { http.Error(w, err.Error(), code) } } + +// isUpstreamStatus reports whether code represents an upstream/gateway +// failure whose error message can safely be returned to the client. +func isUpstreamStatus(code int) bool { + switch code { + case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false + } +} diff --git a/pkg/api/errors/handler_test.go b/pkg/api/errors/handler_test.go index ed0fd2370b..fdcb8a867a 100644 --- a/pkg/api/errors/handler_test.go +++ b/pkg/api/errors/handler_test.go @@ -116,6 +116,64 @@ func TestErrorHandler(t *testing.T) { require.Contains(t, rec.Body.String(), "Internal Server Error") }) + t.Run("surfaces 502 upstream error message to client", func(t *testing.T) { + t.Parallel() + + handler := ErrorHandler(func(_ http.ResponseWriter, _ *http.Request) error { + return httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: registry returned 401", "ghcr.io/org/skill:v1"), + http.StatusBadGateway, + ) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Contains(t, rec.Body.String(), "pulling OCI artifact") + require.Contains(t, rec.Body.String(), "registry returned 401") + }) + + t.Run("surfaces 503 upstream error message to client", func(t *testing.T) { + t.Parallel() + + handler := ErrorHandler(func(_ http.ResponseWriter, _ *http.Request) error { + return httperr.WithCode( + fmt.Errorf("downstream service unavailable"), + http.StatusServiceUnavailable, + ) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Contains(t, rec.Body.String(), "downstream service unavailable") + }) + + t.Run("surfaces 504 gateway timeout message to client", func(t *testing.T) { + t.Parallel() + + handler := ErrorHandler(func(_ http.ResponseWriter, _ *http.Request) error { + return httperr.WithCode( + fmt.Errorf("upstream deadline exceeded while pulling %q", "ghcr.io/org/skill:v1"), + http.StatusGatewayTimeout, + ) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusGatewayTimeout, rec.Code) + require.Contains(t, rec.Body.String(), "upstream deadline exceeded") + }) + t.Run("error without code defaults to 500 with generic message", func(t *testing.T) { t.Parallel() diff --git a/pkg/api/server.go b/pkg/api/server.go index bc23dcd490..bd6638a61d 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -61,6 +61,7 @@ import ( const ( middlewareTimeout = 60 * time.Second readHeaderTimeout = 10 * time.Second + idleTimeout = 120 * time.Second shutdownTimeout = 30 * time.Second nonceBytes = 16 socketPermissions = 0660 // Socket file permissions (owner/group read-write) @@ -141,50 +142,35 @@ func (b *ServerBuilder) WithOtelEnabled(enabled bool) *ServerBuilder { return b } -// WithMiddleware adds middleware to the server +// WithMiddleware appends HTTP middleware that runs after the default middleware +// stack (request-ID, body-size limit, headers, update-check, auth) and before +// route handlers. Part of the ApplyServerExtensions extension point — used by +// downstream consumers to inject custom authentication or request-scoping +// middleware into the API server. +// +// Public extension API. Do not remove based on deadcode analysis alone: +// callers may live in repositories that are not visible to this module's +// analyzer. The test in server_test.go intentionally exercises this method +// to keep it reachable. func (b *ServerBuilder) WithMiddleware(mw ...func(http.Handler) http.Handler) *ServerBuilder { b.middlewares = append(b.middlewares, mw...) return b } -// WithRoute adds a custom route to the server +// WithRoute mounts a sub-router at the given prefix. The caller is responsible +// for any per-route timeout middleware. Part of the ApplyServerExtensions +// extension point — used by downstream consumers to add API surface alongside +// the built-in routes. +// +// Public extension API. Do not remove based on deadcode analysis alone: +// callers may live in repositories that are not visible to this module's +// analyzer. The test in server_test.go intentionally exercises this method +// to keep it reachable. func (b *ServerBuilder) WithRoute(prefix string, handler http.Handler) *ServerBuilder { b.customRoutes[prefix] = handler return b } -// WithContainerRuntime sets the container runtime -func (b *ServerBuilder) WithContainerRuntime(containerRuntime runtime.Runtime) *ServerBuilder { - b.containerRuntime = containerRuntime - return b -} - -// WithClientManager sets the client manager -func (b *ServerBuilder) WithClientManager(manager client.Manager) *ServerBuilder { - b.clientManager = manager - return b -} - -// WithWorkloadManager sets the workload manager -func (b *ServerBuilder) WithWorkloadManager(manager workloads.Manager) *ServerBuilder { - b.workloadManager = manager - return b -} - -// WithGroupManager sets the group manager -func (b *ServerBuilder) WithGroupManager(manager groups.Manager) *ServerBuilder { - b.groupManager = manager - return b -} - -// WithSkillManager sets the skill service manager. -// The caller is responsible for closing any underlying resources -// when providing an external skill service. -func (b *ServerBuilder) WithSkillManager(manager skills.SkillService) *ServerBuilder { - b.skillManager = manager - return b -} - // Build creates and configures the HTTP router func (b *ServerBuilder) Build(ctx context.Context) (*chi.Mux, error) { r := chi.NewRouter() @@ -366,41 +352,25 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) { } } -func setupTCPListener(address string) (net.Listener, error) { - return net.Listen("tcp", address) -} - -func setupUnixSocket(address string) (net.Listener, error) { - // Remove the socket file if it already exists - if _, err := os.Stat(address); err == nil { - if err := os.Remove(address); err != nil { - return nil, fmt.Errorf("failed to remove existing socket: %w", err) - } - } - - // Create the directory for the socket file if it doesn't exist - if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil { - return nil, fmt.Errorf("failed to create socket directory: %w", err) - } - - // Create UNIX socket listener - listener, err := net.Listen("unix", address) - if err != nil { - return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err) - } +// namedPipePrefix is the Windows named-pipe namespace prefix. The canonical +// definition lives in pkg/server/discovery so the listener and dialer cannot +// drift; pkg/api re-aliases it here so per-platform socket files do not need +// to import discovery directly. +const namedPipePrefix = discovery.NamedPipePrefix - // Set file permissions on the socket to allow other local processes to connect - if err := os.Chmod(address, socketPermissions); err != nil { - return nil, fmt.Errorf("failed to set socket permissions: %w", err) - } - - return listener, nil +// isNamedPipeAddress reports whether address is a Windows named-pipe path. +// The check is platform-agnostic so callers on non-Windows can fail fast with +// a clear error before reaching the listener code. The comparison is +// case-insensitive because the Windows pipe namespace is case-insensitive at +// the kernel layer; without EqualFold an address like \\.\Pipe\foo would +// silently fall through to AF_UNIX and then fail to bind. +func isNamedPipeAddress(address string) bool { + return len(address) >= len(namedPipePrefix) && + strings.EqualFold(address[:len(namedPipePrefix)], namedPipePrefix) } -func cleanupUnixSocket(address string) { - if err := os.Remove(address); err != nil && !os.IsNotExist(err) { - slog.Warn("failed to remove socket file", "error", err) - } +func setupTCPListener(address string) (net.Listener, error) { + return net.Listen("tcp", address) } func headersMiddleware(next http.Handler) http.Handler { @@ -408,6 +378,8 @@ func headersMiddleware(next http.Handler) http.Handler { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Type", "application/json") } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") next.ServeHTTP(w, r) }) } @@ -575,6 +547,12 @@ func NewServer(ctx context.Context, builder *ServerBuilder) (*Server, error) { Addr: builder.address, Handler: handler, ReadHeaderTimeout: readHeaderTimeout, + // IdleTimeout caps how long a keep-alive connection can sit idle. + // On Windows named pipes winio.MaxInstances defaults to 255, so a + // slow client cannot hold an instance forever and starve new + // connections; on POSIX it bounds keep-alive resource use the same + // way the http stdlib defaults would for a tcp listener. + IdleTimeout: idleTimeout, } return &Server{ @@ -592,7 +570,7 @@ func NewServer(ctx context.Context, builder *ServerBuilder) (*Server, error) { // bound address from the listener (important when binding to port 0). func (s *Server) ListenURL() string { if s.isUnixSocket { - return fmt.Sprintf("unix://%s", s.address) + return socketURL(s.address) } return fmt.Sprintf("http://%s", s.listener.Addr().String()) } @@ -715,24 +693,30 @@ func (s *Server) cleanup() { } } -// createListener creates the appropriate listener based on the configuration +// createListener creates the appropriate listener based on the configuration. +// Named-pipe addresses are only supported on Windows; other platforms reject +// them up front rather than creating a literal-backslash file via AF_UNIX. func createListener(address string, isUnixSocket bool) (net.Listener, string, error) { - var listener net.Listener - var addrType string - var err error + if !isUnixSocket { + listener, err := setupTCPListener(address) + if err != nil { + return nil, "", err + } + return listener, "HTTP", nil + } - if isUnixSocket { - listener, err = setupUnixSocket(address) - addrType = "UNIX socket" - } else { - listener, err = setupTCPListener(address) - addrType = "HTTP" + addrType := "UNIX socket" + if isNamedPipeAddress(address) { + if !supportsNamedPipe() { + return nil, "", fmt.Errorf("named pipe addresses are only supported on Windows: %s", address) + } + addrType = "Windows named pipe" } + listener, err := setupUnixSocket(address) if err != nil { return nil, "", err } - return listener, addrType, nil } @@ -825,41 +809,3 @@ func GenerateNonce() (string, error) { } return hex.EncodeToString(b), nil } - -// Serve starts the server on the given address and serves the API. -// It is assumed that the caller sets up appropriate signal handling. -// If isUnixSocket is true, address is treated as a UNIX socket path. -// If oidcConfig is provided, OIDC authentication will be enabled for all API endpoints. -// Serve is a convenience wrapper that builds and starts the API server. -// For callers that need to configure OTEL or other builder options not exposed -// here, use NewServerBuilder and NewServer directly. -func Serve( - ctx context.Context, - address string, - isUnixSocket bool, - debugMode bool, - enableDocs bool, - oidcConfig *auth.TokenValidatorConfig, - middlewares ...func(http.Handler) http.Handler, -) error { - nonce, err := GenerateNonce() - if err != nil { - return err - } - - builder := NewServerBuilder(). - WithAddress(address). - WithUnixSocket(isUnixSocket). - WithDebugMode(debugMode). - WithDocs(enableDocs). - WithNonce(nonce). - WithOIDCConfig(oidcConfig). - WithMiddleware(middlewares...) - - server, err := NewServer(ctx, builder) - if err != nil { - return err - } - - return server.Start(ctx) -} diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 6ba0da90ac..a1bfd6d3e5 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -4,11 +4,15 @@ package api import ( + "context" "fmt" "net" + "net/http" + "net/http/httptest" "regexp" "testing" + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -86,3 +90,58 @@ func TestListenURL(t *testing.T) { }) } } + +// TestServerBuilderExtensionPoints exercises WithMiddleware and WithRoute so +// they remain reachable to deadcode analysis. Both methods form the public +// surface for ApplyServerExtensions consumers, whose callers may live in +// downstream repositories that this module's analyzer cannot see. Without +// this test, a future deadcode pass would flag them as unreachable (as +// happened in #5355) even though external callers depend on them. +func TestSecurityHeaders(t *testing.T) { + t.Parallel() + + b := NewServerBuilder() + router, err := b.Build(context.Background()) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "same-origin", rec.Header().Get("Cross-Origin-Resource-Policy")) +} + +func TestServerBuilderExtensionPoints(t *testing.T) { + t.Parallel() + + t.Run("WithMiddleware appends to middleware chain", func(t *testing.T) { + t.Parallel() + + b := NewServerBuilder() + mw := func(next http.Handler) http.Handler { return next } + b.WithMiddleware(mw, mw) + + assert.Len(t, b.middlewares, 2) + }) + + t.Run("WithRoute registers handler at prefix", func(t *testing.T) { + t.Parallel() + + b := NewServerBuilder() + b.WithRoute("/ext", chi.NewRouter()) + + _, ok := b.customRoutes["/ext"] + assert.True(t, ok, "expected /ext to be registered") + }) + + t.Run("methods chain on the builder", func(t *testing.T) { + t.Parallel() + + b := NewServerBuilder(). + WithMiddleware(func(next http.Handler) http.Handler { return next }). + WithRoute("/ext", chi.NewRouter()) + + assert.NotNil(t, b) + }) +} diff --git a/pkg/api/socket_unix.go b/pkg/api/socket_unix.go new file mode 100644 index 0000000000..39cf23a3f4 --- /dev/null +++ b/pkg/api/socket_unix.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package api + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/url" + "os" + "path/filepath" +) + +// supportsNamedPipe reports whether the current build target can host a +// Windows named-pipe listener. Used by createListener to reject pipe addresses +// before reaching the per-platform setupUnixSocket implementation. +func supportsNamedPipe() bool { return false } + +// setupUnixSocket creates a UNIX domain socket listener at the given path. +// On non-Windows platforms named-pipe addresses are not supported; callers +// guard against that in createListener. +func setupUnixSocket(address string) (net.Listener, error) { + if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("failed to remove existing socket: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil { + return nil, fmt.Errorf("failed to create socket directory: %w", err) + } + + listener, err := net.Listen("unix", address) + if err != nil { + return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err) + } + + if err := os.Chmod(address, socketPermissions); err != nil { + // Roll back the bound listener and the socket file rather than leaking + // either: the listener owns the AF_UNIX file and net.Listen will not + // rebind the same path while the file exists. + _ = listener.Close() + _ = os.Remove(address) + return nil, fmt.Errorf("failed to set socket permissions: %w", err) + } + + return listener, nil +} + +// cleanupUnixSocket removes the socket file at address. Missing files are not +// an error since cleanup may run after a partial startup. +func cleanupUnixSocket(address string) { + if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) { + slog.Warn("failed to remove socket file", "error", err) + } +} + +// socketURL returns the URL form of a Unix-socket address for the discovery +// file. Non-Windows platforms only ever produce unix:// URLs. Built via +// (&url.URL{}).String() so the discovery dialer can round-trip the value back +// through net/url without surprises (matters mostly on Windows but kept here +// for symmetry). +func socketURL(address string) string { + return (&url.URL{Scheme: "unix", Path: address}).String() +} diff --git a/pkg/api/socket_unix_test.go b/pkg/api/socket_unix_test.go new file mode 100644 index 0000000000..f2f8705743 --- /dev/null +++ b/pkg/api/socket_unix_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/server/discovery" +) + +func TestSocketURL_Unix(t *testing.T) { + t.Parallel() + assert.Equal(t, "unix:///tmp/test.sock", socketURL("/tmp/test.sock")) +} + +// TestSocketURL_RoundTrip_Unix pins that socketURL output is always parseable +// by ParseUnixSocketPath, closing the producer/consumer loop. Without the +// net/url-based emit form this would silently break for any path that needs +// percent-encoding. +func TestSocketURL_RoundTrip_Unix(t *testing.T) { + t.Parallel() + addr := "/tmp/test.sock" + got, err := discovery.ParseUnixSocketPath(socketURL(addr)) + require.NoError(t, err) + assert.Equal(t, addr, got) +} + +func TestIsNamedPipeAddress(t *testing.T) { + t.Parallel() + tests := []struct { + name string + address string + want bool + }{ + {"plain socket", "/tmp/thv.sock", false}, + {"named pipe", `\\.\pipe\thv-api`, true}, + {"named pipe mixed case", `\\.\Pipe\thv-api`, true}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isNamedPipeAddress(tt.address)) + }) + } +} + +// TestCreateListener_NamedPipe_Unsupported asserts that createListener rejects +// pipe addresses on non-Windows up front, mirroring the dialer-side guard +// covered by TestCheckHealth_NamedPipe_Unsupported_OnNonWindows. +func TestCreateListener_NamedPipe_Unsupported(t *testing.T) { + t.Parallel() + _, _, err := createListener(`\\.\pipe\thv-api`, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "only supported on Windows") +} diff --git a/pkg/api/socket_windows.go b/pkg/api/socket_windows.go new file mode 100644 index 0000000000..73e31a4b52 --- /dev/null +++ b/pkg/api/socket_windows.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package api + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/url" + "os" + "path/filepath" + + "github.com/Microsoft/go-winio" +) + +// namedPipeBufferSize is the size of the input/output buffers winio allocates +// per pipe instance. 64 KiB matches what go-winio uses in similar consumers +// (Docker, containerd, Podman) and is well above any single HTTP header chunk. +const namedPipeBufferSize = 64 * 1024 + +// namedPipeSDDL restricts the named-pipe DACL to the creating user (OW) and +// SYSTEM (SY), each granted GenericAll. The Protected flag (P) blocks ACE +// inheritance from any container object. Without an explicit DACL, winio +// inherits CreateNamedPipeW's default which permits other interactive users +// to open the pipe and exercise the entire REST API; on shared, RDP, or +// Citrix hosts that is reachable by anyone with a desktop session, since +// OIDC is opt-in (WithOIDCConfig) rather than always-on. +const namedPipeSDDL = `D:P(A;;GA;;;OW)(A;;GA;;;SY)` + +// supportsNamedPipe reports whether the current build target can host a +// Windows named-pipe listener. Used by createListener to choose between the +// pipe and AF_UNIX paths without dragging the runtime package into server.go. +func supportsNamedPipe() bool { return true } + +// setupUnixSocket creates either a Windows named-pipe listener (when address +// has the \\.\pipe\ prefix) or an AF_UNIX listener at a filesystem path. +// +// Named pipes are kernel objects rather than files, so the os.Remove, +// os.MkdirAll, and os.Chmod steps are skipped: the pipe namespace has no +// parent directory, and access control is governed by the SDDL passed to +// winio.ListenPipe (see namedPipeSDDL). +// +// AF_UNIX is supported on Windows 10 1803+. The chmod step is dropped on this +// path because POSIX file modes do not apply on Windows; the resulting +// .sock file inherits the parent directory's NTFS ACL, which is best-effort. +// Prefer the named-pipe path when listener access control matters. +func setupUnixSocket(address string) (net.Listener, error) { + if isNamedPipeAddress(address) { + // MessageMode is left at false (byte stream) explicitly because HTTP + // requires byte-oriented framing. SecurityDescriptor restricts the + // pipe DACL to the creating user and SYSTEM; without it the winio + // default permits other interactive users to dial the pipe. + listener, err := winio.ListenPipe(address, &winio.PipeConfig{ + MessageMode: false, + InputBufferSize: namedPipeBufferSize, + OutputBufferSize: namedPipeBufferSize, + SecurityDescriptor: namedPipeSDDL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create named pipe listener: %w", err) + } + return listener, nil + } + + if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("failed to remove existing socket: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil { + return nil, fmt.Errorf("failed to create socket directory: %w", err) + } + + listener, err := net.Listen("unix", address) + if err != nil { + return nil, fmt.Errorf("failed to create UNIX socket listener: %w", err) + } + + return listener, nil +} + +// cleanupUnixSocket removes the AF_UNIX socket file at address, or no-ops for +// named pipes (the pipe is destroyed when the listener closes). +func cleanupUnixSocket(address string) { + if isNamedPipeAddress(address) { + return + } + if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) { + slog.Warn("failed to remove socket file", "error", err) + } +} + +// socketURL returns the URL form of a Unix-socket or named-pipe address for +// the discovery file. Named pipes are emitted as npipe:// where +// is everything after the \\.\pipe\ prefix. AF_UNIX paths are emitted as +// unix:/// so a Windows drive-letter path round-trips through net/url +// cleanly (the previous concatenation form produced unix://C:\... , which +// url.Parse rejects with "invalid port"). +// +// The synthetic leading slash is required: with Host == "" and a Path that +// does not start with /, url.URL.String() emits only scheme://+path (two +// slashes), and url.Parse then mis-reads the drive letter as host:port. +// Forcing the leading slash yields the three-slash form, and the consumer +// (ParseUnixSocketPath) strips that synthetic slash before the drive letter +// so the round-trip closes. +func socketURL(address string) string { + if isNamedPipeAddress(address) { + name := address[len(namedPipePrefix):] + return (&url.URL{Scheme: "npipe", Host: name}).String() + } + return (&url.URL{Scheme: "unix", Path: "/" + address}).String() +} diff --git a/pkg/api/socket_windows_test.go b/pkg/api/socket_windows_test.go new file mode 100644 index 0000000000..2756f517c3 --- /dev/null +++ b/pkg/api/socket_windows_test.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package api + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/server/discovery" +) + +// pipeNameSeq disambiguates concurrent test pipes so parallel runs don't +// collide on the global Windows pipe namespace. +var pipeNameSeq atomic.Uint64 + +func uniqueTestPipe() string { + return fmt.Sprintf(`\\.\pipe\thv-api-test-%d`, pipeNameSeq.Add(1)) +} + +func TestSocketURL_Windows(t *testing.T) { + t.Parallel() + tests := []struct { + name string + address string + want string + }{ + {"named pipe", `\\.\pipe\thv-api`, "npipe://thv-api"}, + // AF_UNIX Windows paths are now percent-encoded so the resulting URL + // round-trips through net/url.Parse cleanly. The previous form + // (unix://C:\path\thv.sock) was rejected by url.Parse with + // "invalid port :\\path\\thv.sock". + {"af_unix windows path", `C:\path\thv.sock`, `unix:///C:%5Cpath%5Cthv.sock`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, socketURL(tt.address)) + }) + } +} + +// TestSocketURL_RoundTrip_NamedPipe pins that socketURL output is always +// parseable by ParseNamedPipeURL, closing the producer/consumer loop. +func TestSocketURL_RoundTrip_NamedPipe(t *testing.T) { + t.Parallel() + addr := `\\.\pipe\thv-api` + got, err := discovery.ParseNamedPipeURL(socketURL(addr)) + require.NoError(t, err) + assert.Equal(t, addr, got) +} + +// TestSocketURL_RoundTrip_AFUnix pins that socketURL output for an AF_UNIX +// drive-letter path is always parseable by ParseUnixSocketPath, closing the +// producer/consumer loop on the case where percent-encoding matters most. +// Without the synthetic leading slash in socketURL, url.URL.String() emits +// only two slashes and url.Parse mis-reads the drive letter as host:port. +func TestSocketURL_RoundTrip_AFUnix(t *testing.T) { + t.Parallel() + addr := `C:\path\thv.sock` + got, err := discovery.ParseUnixSocketPath(socketURL(addr)) + require.NoError(t, err) + assert.Equal(t, addr, got) +} + +func TestSetupUnixSocket_NamedPipe(t *testing.T) { + t.Parallel() + pipePath := uniqueTestPipe() + + listener, err := setupUnixSocket(pipePath) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + // The listener should accept a winio dial within a short timeout, proving + // it is wired to the named-pipe namespace and not to AF_UNIX. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + connCh := make(chan error, 1) + go func() { + conn, dialErr := winio.DialPipeContext(ctx, pipePath) + if conn != nil { + _ = conn.Close() + } + connCh <- dialErr + }() + + go func() { + conn, _ := listener.Accept() + if conn != nil { + _ = conn.Close() + } + }() + + select { + case err := <-connCh: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("dial against named-pipe listener timed out") + } +} + +func TestCleanupUnixSocket_NamedPipe_NoOp(t *testing.T) { + t.Parallel() + // Passing a pipe address to cleanup must not error or panic. There is no + // file to remove; the assertion here is simply that the call returns + // cleanly. + cleanupUnixSocket(`\\.\pipe\thv-api-cleanup-noop`) +} + +// TestSetupUnixSocket_NamedPipe_FirstInstanceWins pins winio's first-wins +// semantics: ListenPipe sets FILE_FLAG_FIRST_PIPE_INSTANCE, so a second +// listener targeting the same name must fail. This is the safety net the +// discovery layer relies on to detect a stale-PID conflict; if a future winio +// bump silently relaxed it, two thv processes could bind the same pipe and +// quietly race for traffic. +func TestSetupUnixSocket_NamedPipe_FirstInstanceWins(t *testing.T) { + t.Parallel() + pipePath := uniqueTestPipe() + + first, err := setupUnixSocket(pipePath) + require.NoError(t, err) + t.Cleanup(func() { _ = first.Close() }) + + // setupUnixSocket returns (nil, err) on the named-pipe failure path, so + // no defensive Close is needed for the second listener. + _, err = setupUnixSocket(pipePath) + require.Error(t, err, "second ListenPipe on the same name must fail") + assert.Contains(t, err.Error(), "failed to create named pipe listener") +} diff --git a/pkg/api/v1/registry.go b/pkg/api/v1/registry.go index 3727ef3f8f..a3f685a240 100644 --- a/pkg/api/v1/registry.go +++ b/pkg/api/v1/registry.go @@ -56,6 +56,14 @@ func writeRegistryAuthRequiredError(w http.ResponseWriter) { // structured JSON 503 response when the upstream registry is unreachable. const RegistryUnavailableCode = "registry_unavailable" +// RegistryLegacyFormatCode is the machine-readable error code returned in the +// structured JSON 502 response when the configured registry source serves data +// in the legacy ToolHive format instead of the upstream MCP registry format. +// Desktop clients (Studio) match on this value to display a targeted recovery +// flow (reset to default registry, point to a `?format=upstream` endpoint, or +// run `thv registry convert`). +const RegistryLegacyFormatCode = "registry_legacy_format" + // writeRegistryUnavailableError writes a structured JSON 503 response when the // upstream registry cannot be reached or returns an unexpected error (e.g. 404). func writeRegistryUnavailableError(w http.ResponseWriter, unavailableErr *regpkg.UnavailableError) { @@ -68,6 +76,48 @@ func writeRegistryUnavailableError(w http.ResponseWriter, unavailableErr *regpkg _ = json.NewEncoder(w).Encode(body) } +// writeRegistryLegacyFormatError writes a structured JSON 502 Bad Gateway +// response when the configured registry source returns data in the legacy +// ToolHive format. HTTP 502 is correct per RFC 9110 §15.6.3: thv serve is +// acting as a gateway to the upstream registry, and the upstream returned a +// response we cannot process. It also aligns with the existing PUT handler +// which already returns 502 for the same legacy-format detection. The message +// embeds legacyhint.MigrationMessage so CLI users still see the actionable +// migration step, while desktop clients can branch on Code. +func writeRegistryLegacyFormatError(w http.ResponseWriter, legacyErr *regpkg.LegacyFormatError) { + body := registryErrorResponse{ + Code: RegistryLegacyFormatCode, + Message: legacyErr.Error(), + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(body) +} + +// writeProviderError translates a registry provider error into a structured +// HTTP response if it matches a known failure mode (auth required, legacy +// format, upstream unavailable) and returns true. The caller is responsible +// for writing a generic fallback response when this returns false. +func writeProviderError(w http.ResponseWriter, err error) bool { + if isRegistryAuthError(err) { + writeRegistryAuthRequiredError(w) + return true + } + var legacyErr *regpkg.LegacyFormatError + if errors.As(err, &legacyErr) { + slog.Warn("upstream registry in legacy format", "error", err) + writeRegistryLegacyFormatError(w, legacyErr) + return true + } + var unavailableErr *regpkg.UnavailableError + if errors.As(err, &unavailableErr) { + slog.Error("upstream registry unavailable", "error", err) + writeRegistryUnavailableError(w, unavailableErr) + return true + } + return false +} + // resolveAuthStatus returns the auth_status and auth_type strings for API responses // by delegating to the AuthManager. func (rr *RegistryRoutes) resolveAuthStatus() (authStatus, authType string) { @@ -263,14 +313,7 @@ func (rr *RegistryRoutes) getCurrentProvider(w http.ResponseWriter) (regpkg.Prov } provider, err := regpkg.GetDefaultProviderWithConfig(rr.configProvider, opts...) if err != nil { - if isRegistryAuthError(err) { - writeRegistryAuthRequiredError(w) - return nil, false - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) + if writeProviderError(w, err) { return nil, false } http.Error(w, "Failed to get registry provider", http.StatusInternalServerError) @@ -333,6 +376,7 @@ func RegistryRouter(serveMode bool) http.Handler { r.Get("/{name}", routes.getRegistry) r.Put("/{name}", routes.updateRegistry) r.Delete("/{name}", routes.removeRegistry) + r.Post("/{name}/refresh", routes.refreshRegistry) // Add nested routes for servers within a registry r.Route("/{name}/servers", func(r chi.Router) { @@ -369,14 +413,7 @@ func (rr *RegistryRoutes) listRegistries(w http.ResponseWriter, _ *http.Request) reg, err := provider.GetRegistry() if err != nil { - if isRegistryAuthError(err) { - writeRegistryAuthRequiredError(w) - return - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) + if writeProviderError(w, err) { return } http.Error(w, "Failed to get registry", http.StatusInternalServerError) @@ -450,14 +487,7 @@ func (rr *RegistryRoutes) getRegistry(w http.ResponseWriter, r *http.Request) { reg, err := provider.GetRegistry() if err != nil { - if isRegistryAuthError(err) { - writeRegistryAuthRequiredError(w) - return - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) + if writeProviderError(w, err) { return } http.Error(w, "Failed to get registry", http.StatusInternalServerError) @@ -489,6 +519,51 @@ func (rr *RegistryRoutes) getRegistry(w http.ResponseWriter, r *http.Request) { } } +// refreshRegistry +// +// @Summary Refresh registry cache +// @Description Force a refresh of the server-side registry cache for the default registry +// @Tags registry +// @Produce json +// @Param name path string true "Registry name (must be 'default')" +// @Success 200 {object} map[string]string "Registry refreshed" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" +// @Router /api/v1beta/registry/{name}/refresh [post] +func (rr *RegistryRoutes) refreshRegistry(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + + // Only "default" registry is supported currently + if name != defaultRegistryName { + http.Error(w, "Registry not found", http.StatusNotFound) + return + } + + provider, ok := rr.getCurrentProvider(w) + if !ok { + return + } + + if cached, ok := provider.(*regpkg.CachedAPIRegistryProvider); ok { + if err := cached.ForceRefresh(); err != nil { + if writeProviderError(w, err) { + return + } + slog.Error("failed to refresh registry", "error", err) + http.Error(w, "Failed to refresh registry", http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"status": "refreshed"}); err != nil { + slog.Error("failed to encode response", "error", err) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + // updateRegistry // // @Summary Update registry configuration @@ -737,14 +812,7 @@ func (rr *RegistryRoutes) listServers(w http.ResponseWriter, r *http.Request) { // Get the full registry to access both container and remote servers reg, err := provider.GetRegistry() if err != nil { - if isRegistryAuthError(err) { - writeRegistryAuthRequiredError(w) - return - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) + if writeProviderError(w, err) { return } slog.Error("failed to get registry", "error", err) diff --git a/pkg/api/v1/registry_factory_test.go b/pkg/api/v1/registry_factory_test.go index d3d8f81911..dc40a181d9 100644 --- a/pkg/api/v1/registry_factory_test.go +++ b/pkg/api/v1/registry_factory_test.go @@ -21,36 +21,34 @@ import ( "github.com/stacklok/toolhive/pkg/registry" ) -// writeFactorySentinelRegistry creates a legacy-format registry JSON file with a -// single server named sentinelName and a YAML config pointing to it. +// writeFactorySentinelRegistry creates an upstream-format registry JSON file +// with a single server named sentinelName and a YAML config pointing to it. // Returns the config file path. func writeFactorySentinelRegistry(t *testing.T, sentinelName string) string { t.Helper() dir := t.TempDir() - // Write legacy registry JSON with the sentinel server. - type serverEntry struct { - Image string `json:"image"` - Description string `json:"description"` - } - type registryFile struct { - Version string `json:"version"` - LastUpdated string `json:"last_updated"` - Servers map[string]serverEntry `json:"servers"` - } - - regData, err := json.Marshal(registryFile{ - Version: "1.0.0", - LastUpdated: "2025-01-01T00:00:00Z", - Servers: map[string]serverEntry{ - sentinelName: { - Image: "factory/server:latest", - Description: "Factory sentinel server", - }, - }, - }) - require.NoError(t, err) + regData := []byte(`{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": { + "servers": [ + { + "name": "` + sentinelName + `", + "description": "Factory sentinel server", + "packages": [ + { + "registryType": "oci", + "identifier": "factory/server:latest", + "transport": {"type": "stdio"} + } + ] + } + ] + } + }`) registryPath := filepath.Join(dir, "registry.json") require.NoError(t, os.WriteFile(registryPath, regData, 0600)) diff --git a/pkg/api/v1/registry_test.go b/pkg/api/v1/registry_test.go index 10b5b7e326..73aee33170 100644 --- a/pkg/api/v1/registry_test.go +++ b/pkg/api/v1/registry_test.go @@ -12,14 +12,18 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/go-chi/chi/v5" + v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/registry" + regauth "github.com/stacklok/toolhive/pkg/registry/auth" ) func CreateTestConfigProvider(t *testing.T, cfg *config.Config) (config.Provider, func()) { @@ -140,6 +144,113 @@ func TestRegistryAPI_GetEndpoint_UnavailableUpstream(t *testing.T) { } } +// TestRegistryAPI_GetEndpoint_LegacyFormat tests that GET endpoints return 502 +// with a structured "registry_legacy_format" code when the configured custom +// registry URL serves data in the legacy ToolHive format. 502 is correct per +// RFC 9110 §15.6.3: thv serve acts as a gateway to the upstream registry and +// the upstream returned a response we cannot process. +// +//nolint:paralleltest // Uses global registry provider singleton +func TestRegistryAPI_GetEndpoint_LegacyFormat(t *testing.T) { + // Mock server that returns a valid HTTP 200 but with legacy ToolHive + // registry JSON (top-level "servers" instead of nested "data.servers"). + // validateConnectivity() must detect this and reject the provider with a + // *LegacyFormatError. + legacyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "version": "1.0.0", + "servers": {"example": {"image": "example:latest"}} + }`)) + })) + defer legacyServer.Close() + + // Configure registry to point at the mock legacy-format server via the + // remote URL provider path (RegistryUrl, not RegistryApiUrl). + cfg := &config.Config{ + RegistryUrl: legacyServer.URL, + AllowPrivateRegistryIp: true, + } + configProvider, cleanup := CreateTestConfigProvider(t, cfg) + defer cleanup() + + registry.ResetDefaultProvider() + t.Cleanup(registry.ResetDefaultProvider) + + routes := &RegistryRoutes{ + configProvider: configProvider, + configService: registry.NewConfiguratorWithProvider(configProvider), + serveMode: true, + } + + endpoints := []struct { + name string + method string + path string + handler http.HandlerFunc + urlParams map[string]string + }{ + { + name: "listRegistries", + method: http.MethodGet, + path: "/", + handler: routes.listRegistries, + }, + { + name: "getRegistry", + method: http.MethodGet, + path: "/default", + handler: routes.getRegistry, + urlParams: map[string]string{"name": "default"}, + }, + { + name: "listServers", + method: http.MethodGet, + path: "/default/servers", + handler: routes.listServers, + urlParams: map[string]string{"name": "default"}, + }, + } + + for _, ep := range endpoints { + t.Run(ep.name, func(t *testing.T) { + registry.ResetDefaultProvider() + + req := httptest.NewRequest(ep.method, ep.path, nil) + if ep.urlParams != nil { + rctx := chi.NewRouteContext() + for k, v := range ep.urlParams { + rctx.URLParams.Add(k, v) + } + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + } + + w := httptest.NewRecorder() + ep.handler(w, req) + + assert.Equal(t, http.StatusBadGateway, w.Code, + "Expected 502 Bad Gateway when registry is in legacy format") + + var body registryErrorResponse + err := json.NewDecoder(w.Body).Decode(&body) + require.NoError(t, err, "Response should be valid JSON") + assert.Equal(t, RegistryLegacyFormatCode, body.Code, + "Response code should be registry_legacy_format") + // The body must carry the actionable migration hint so CLI users + // and desktop clients both have something to act on. + assert.Contains(t, body.Message, "legacy ToolHive format", + "Response message should mention the legacy format") + assert.Contains(t, body.Message, "thv registry convert", + "Response message should include the migration command hint") + assert.Contains(t, body.Message, legacyServer.URL, + "Response message should identify the offending source URL") + assert.Contains(t, w.Header().Get("Content-Type"), "application/json", + "Response Content-Type should be application/json") + }) + } +} + func TestRegistryRouter(t *testing.T) { t.Parallel() @@ -149,6 +260,209 @@ func TestRegistryRouter(t *testing.T) { assert.NotNil(t, routes) } +//nolint:paralleltest // Uses global registry provider singleton +func TestRefreshRegistry_RefreshesServerSideCache(t *testing.T) { + var ( + mu sync.RWMutex + serverNames = []string{"io.example/old-server"} + ) + + setServerNames := func(names ...string) { + mu.Lock() + defer mu.Unlock() + serverNames = append([]string(nil), names...) + } + getServerNames := func() []string { + mu.RLock() + defer mu.RUnlock() + return append([]string(nil), serverNames...) + } + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v0.1/servers" { + http.NotFound(w, r) + return + } + + names := getServerNames() + servers := make([]v0.ServerResponse, 0, len(names)) + for _, name := range names { + servers = append(servers, v0.ServerResponse{ + Server: v0.ServerJSON{ + Schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + Name: name, + Description: "Test registry server", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/example/" + strings.ReplaceAll(name, "/", "-") + ":1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }, + }) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v0.ServerListResponse{ + Servers: servers, + Metadata: v0.Metadata{ + Count: len(servers), + }, + }); err != nil { + t.Errorf("failed to encode registry response: %v", err) + } + })) + defer upstream.Close() + + cacheFile, err := regauth.RegistryCacheFilePath(upstream.URL) + require.NoError(t, err) + require.NoError(t, os.RemoveAll(cacheFile)) + t.Cleanup(func() { + _ = os.Remove(cacheFile) + }) + + cfg := &config.Config{ + RegistryApiUrl: upstream.URL, + AllowPrivateRegistryIp: true, + } + configProvider, cleanup := CreateTestConfigProvider(t, cfg) + defer cleanup() + + registry.ResetDefaultProvider() + t.Cleanup(registry.ResetDefaultProvider) + + routes := &RegistryRoutes{ + configProvider: configProvider, + configService: registry.NewConfiguratorWithProvider(configProvider), + serveMode: true, + } + + first := getRegistryForTest(t, routes) + require.Equal(t, 1, first.ServerCount) + require.Contains(t, first.Registry.Servers, "io.example/old-server") + + setServerNames("io.example/old-server", "io.example/new-server") + + staleRegistry := getRegistryForTest(t, routes) + require.Equal(t, 1, staleRegistry.ServerCount) + require.NotContains(t, staleRegistry.Registry.Servers, "io.example/new-server") + + staleServers := listServersForTest(t, routes) + require.Len(t, staleServers.Servers, 1) + + refreshReq := requestWithRegistryName(http.MethodPost, "/default/refresh", "default") + refreshRecorder := httptest.NewRecorder() + routes.refreshRegistry(refreshRecorder, refreshReq) + require.Equal(t, http.StatusOK, refreshRecorder.Code) + + var refreshBody map[string]string + require.NoError(t, json.NewDecoder(refreshRecorder.Body).Decode(&refreshBody)) + require.Equal(t, "refreshed", refreshBody["status"]) + + refreshedRegistry := getRegistryForTest(t, routes) + require.Equal(t, 2, refreshedRegistry.ServerCount) + require.Contains(t, refreshedRegistry.Registry.Servers, "io.example/new-server") + + refreshedServers := listServersForTest(t, routes) + require.Len(t, refreshedServers.Servers, 2) +} + +//nolint:paralleltest // Uses global registry provider singleton +func TestRefreshRegistry_NonDefaultRegistryNotFound(t *testing.T) { + registry.ResetDefaultProvider() + t.Cleanup(registry.ResetDefaultProvider) + + routes := &RegistryRoutes{} + req := requestWithRegistryName(http.MethodPost, "/other/refresh", "other") + w := httptest.NewRecorder() + + routes.refreshRegistry(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Body.String(), "Registry not found") +} + +//nolint:paralleltest // Uses global registry provider singleton +func TestRefreshRegistry_UnavailableUpstream(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "upstream unavailable", http.StatusInternalServerError) + })) + defer upstream.Close() + + cacheFile, err := regauth.RegistryCacheFilePath(upstream.URL) + require.NoError(t, err) + require.NoError(t, os.RemoveAll(cacheFile)) + t.Cleanup(func() { + _ = os.Remove(cacheFile) + }) + + cfg := &config.Config{ + RegistryApiUrl: upstream.URL, + AllowPrivateRegistryIp: true, + } + configProvider, cleanup := CreateTestConfigProvider(t, cfg) + defer cleanup() + + registry.ResetDefaultProvider() + t.Cleanup(registry.ResetDefaultProvider) + + routes := &RegistryRoutes{ + configProvider: configProvider, + configService: registry.NewConfiguratorWithProvider(configProvider), + serveMode: true, + } + + req := requestWithRegistryName(http.MethodPost, "/default/refresh", "default") + w := httptest.NewRecorder() + + routes.refreshRegistry(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + + var body registryErrorResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + require.Equal(t, RegistryUnavailableCode, body.Code) +} + +func requestWithRegistryName(method, target, name string) *http.Request { + req := httptest.NewRequest(method, target, http.NoBody) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", name) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func getRegistryForTest(t *testing.T, routes *RegistryRoutes) getRegistryResponse { + t.Helper() + + req := requestWithRegistryName(http.MethodGet, "/default", "default") + w := httptest.NewRecorder() + routes.getRegistry(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response getRegistryResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&response)) + return response +} + +func listServersForTest(t *testing.T, routes *RegistryRoutes) listServersResponse { + t.Helper() + + req := requestWithRegistryName(http.MethodGet, "/default/servers", "default") + w := httptest.NewRecorder() + routes.listServers(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response listServersResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&response)) + return response +} + //nolint:paralleltest // Cannot use t.Parallel() with t.Setenv() in Go 1.24+ func TestGetRegistryInfo(t *testing.T) { t.Parallel() @@ -211,10 +525,12 @@ func TestRegistryAPI_PutEndpoint(t *testing.T) { validRegistryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") registryData := map[string]interface{}{ - "servers": map[string]interface{}{ - "test-server": map[string]interface{}{ - "command": []string{"test"}, - "args": []string{}, + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": map[string]interface{}{"last_updated": "2025-01-01T00:00:00Z"}, + "data": map[string]interface{}{ + "servers": []interface{}{ + map[string]interface{}{"name": "io.example.test-server"}, }, }, } @@ -246,7 +562,7 @@ func TestRegistryAPI_PutEndpoint(t *testing.T) { t.Helper() // Create a temporary file with valid registry JSON tempFile := filepath.Join(t.TempDir(), "valid-registry.json") - validJSON := `{"servers": {"test-server": {"command": ["test"], "args": []}}}` + validJSON := `{"data": {"servers": [{"name": "io.example.test-server"}]}}` err := os.WriteFile(tempFile, []byte(validJSON), 0600) require.NoError(t, err) return `{"local_path":"` + tempFile + `"}` diff --git a/pkg/api/v1/registry_v01.go b/pkg/api/v1/registry_v01.go index 7c69790093..574bb98318 100644 --- a/pkg/api/v1/registry_v01.go +++ b/pkg/api/v1/registry_v01.go @@ -5,7 +5,6 @@ package v1 import ( "encoding/json" - "errors" "log/slog" "math" "net/http" @@ -15,7 +14,6 @@ import ( "github.com/stacklok/toolhive/pkg/config" regpkg "github.com/stacklok/toolhive/pkg/registry" - "github.com/stacklok/toolhive/pkg/registry/auth" ) const ( @@ -48,14 +46,7 @@ func getRegistryProvider(w http.ResponseWriter) (regpkg.Provider, bool) { regpkg.WithInteractive(false), ) if err != nil { - if errors.Is(err, auth.ErrRegistryAuthRequired) { - writeRegistryAuthRequiredError(w) - return nil, false - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) + if writeProviderError(w, err) { return nil, false } writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to get registry provider") diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index 23667222b1..b0f58c9001 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -85,9 +85,13 @@ func (s *SkillsRoutes) listSkills(w http.ResponseWriter, r *http.Request) error // @Success 201 {object} installSkillResponse // @Header 201 {string} Location "URI of the installed skill resource" // @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized (registry refused credentials)" +// @Failure 404 {string} string "Not Found (artifact not present in registry)" // @Failure 409 {string} string "Conflict" +// @Failure 429 {string} string "Too Many Requests (registry rate limit)" // @Failure 500 {string} string "Internal Server Error" -// @Failure 502 {string} string "Bad Gateway" +// @Failure 502 {string} string "Bad Gateway (upstream registry failure)" +// @Failure 504 {string} string "Gateway Timeout (upstream pull timed out)" // @Router /api/v1beta/skills [post] func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) error { var req installSkillRequest @@ -335,8 +339,12 @@ func (s *SkillsRoutes) deleteBuild(w http.ResponseWriter, r *http.Request) error // @Param ref query string true "OCI reference or local build tag" // @Success 200 {object} skills.SkillContent // @Failure 400 {string} string "Bad Request" -// @Failure 502 {string} string "Bad Gateway" +// @Failure 401 {string} string "Unauthorized (registry refused credentials)" +// @Failure 404 {string} string "Not Found (artifact not present in registry)" +// @Failure 429 {string} string "Too Many Requests (registry rate limit)" // @Failure 500 {string} string "Internal Server Error" +// @Failure 502 {string} string "Bad Gateway (upstream registry or git resolver failure)" +// @Failure 504 {string} string "Gateway Timeout (upstream pull timed out)" // @Router /api/v1beta/skills/content [get] func (s *SkillsRoutes) getSkillContent(w http.ResponseWriter, r *http.Request) error { ref := r.URL.Query().Get("ref") diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index ceb304a91f..e0d899f8ec 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -28,6 +28,7 @@ import ( "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/runner/retriever" "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport" "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/workloads" @@ -312,6 +313,12 @@ func (s *WorkloadService) BuildFullRunConfig( // call are consistent with each other, even if a concurrent registry update fires mid-call. cfg := s.configProvider.GetConfig() + // Apply telemetry defaults from the application config so workloads created + // via the API inherit the same OTEL settings as workloads created via + // "thv run". The API request struct does not expose OTEL fields, so the + // application config is the only source. See issue #5253. + telemetryConfig := runner.BuildTelemetryConfigFromAppConfig(cfg.OTEL, "", nil, "") + // Resolve registry source URLs and server name when the server was discovered via registry lookup. regAPIURL, regURL := runner.ResolveRegistrySourceURLs(serverMetadata, cfg) regServerName := runner.ResolveRegistryServerName(serverMetadata) @@ -342,7 +349,7 @@ func (s *WorkloadService) BuildFullRunConfig( req.OIDC.ClientID, "", "", "", "", false, false, req.OIDC.Scopes), runner.WithToolsFilter(req.ToolsFilter), runner.WithToolsOverride(toolsOverride), - runner.WithTelemetryConfigFromFlags("", false, false, false, "", 0.0, nil, false, nil, false), + runner.WithTelemetryConfig(telemetryConfig), runner.WithRegistrySourceURLs(regAPIURL, regURL), runner.WithRegistryServerName(regServerName), } @@ -382,6 +389,10 @@ func (s *WorkloadService) BuildFullRunConfig( } } + // Resolve the OTel service name from the workload name when not explicitly + // set. Mirrors what the CLI does in configureMiddlewareAndAuthOptions. + telemetry.ResolveServiceName(telemetryConfig, req.Name) + // Configure middleware from flags options = append(options, runner.WithMiddlewareFromFlags( @@ -389,7 +400,7 @@ func (s *WorkloadService) BuildFullRunConfig( nil, // tokenExchangeConfig - not supported via API yet req.ToolsFilter, toolsOverride, - nil, + telemetryConfig, req.AuthzConfig, false, "", @@ -429,14 +440,11 @@ func buildRemoteAuthConfigFromMetadata(req *createRequest, md *regtypes.RemoteSe return nil } - // Default resource: user-provided > registry metadata > derived from remote URL + // Default resource: user-provided > registry metadata resource := req.OAuthConfig.Resource if resource == "" { resource = md.OAuthConfig.Resource } - if resource == "" && md.URL != "" { - resource = remote.DefaultResourceIndicator(md.URL) - } cfg := &remote.Config{ ClientID: req.OAuthConfig.ClientID, @@ -466,12 +474,6 @@ func createRequestToRemoteAuthConfig( req *createRequest, ) *remote.Config { - // Default resource: user-provided > derived from remote URL - resource := req.OAuthConfig.Resource - if resource == "" && req.URL != "" { - resource = remote.DefaultResourceIndicator(req.URL) - } - // Create RemoteAuthConfig remoteAuthConfig := &remote.Config{ ClientID: req.OAuthConfig.ClientID, @@ -480,7 +482,7 @@ func createRequestToRemoteAuthConfig( AuthorizeURL: req.OAuthConfig.AuthorizeURL, TokenURL: req.OAuthConfig.TokenURL, UsePKCE: req.OAuthConfig.UsePKCE, - Resource: resource, + Resource: req.OAuthConfig.Resource, OAuthParams: req.OAuthConfig.OAuthParams, CallbackPort: req.OAuthConfig.CallbackPort, SkipBrowser: req.OAuthConfig.SkipBrowser, diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index 4387e0593e..81b7d92e68 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -210,6 +210,110 @@ func TestBuildFullRunConfig_ThreadsImageVerification(t *testing.T) { "imageRetriever must receive s.imageVerification verbatim") } +// TestBuildFullRunConfig_AppliesOtelFromConfig verifies that workloads created +// via the API inherit OpenTelemetry settings from the application config (i.e. +// from "thv config set-otel-endpoint" or an enterprise config server). Without +// this, telemetry silently doesn't work for any workload created via +// POST /api/v1/workloads — only CLI-created workloads would have telemetry. +// See issue #5253. +func TestBuildFullRunConfig_AppliesOtelFromConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGroupManager := groupsmocks.NewMockManager(ctrl) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + + const ( + testImage = "test-image" + otelEndpoint = "http://collector:4318" + ) + + mockRetriever := func( + _ context.Context, + _ string, _ string, _ string, _ string, + _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + return testImage, ®types.ImageMetadata{Image: testImage}, nil + } + + configPath := t.TempDir() + "/config.yaml" + provider := config.NewPathProvider(configPath) + require.NoError(t, provider.UpdateConfig(func(c *config.Config) error { + c.OTEL = config.OpenTelemetryConfig{ + Endpoint: otelEndpoint, + SamplingRate: 0.1, + } + return nil + })) + + service := &WorkloadService{ + groupManager: mockGroupManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: provider, + imageVerification: retriever.VerifyImageWarn, + } + + req := &createRequest{ + Name: "testserver", + updateRequest: updateRequest{Image: testImage}, + } + + runConfig, err := service.BuildFullRunConfig(context.Background(), req, 0) + require.NoError(t, err) + require.NotNil(t, runConfig.TelemetryConfig, + "TelemetryConfig must be populated from config.OTEL when set — workloads created via the API would otherwise drop the endpoint") + assert.Equal(t, otelEndpoint, runConfig.TelemetryConfig.Endpoint, + "endpoint must be carried over from config.OTEL.Endpoint") + assert.True(t, runConfig.TelemetryConfig.TracingEnabled, + "TracingEnabled must default to true when config does not set it, matching CLI behavior") + assert.True(t, runConfig.TelemetryConfig.MetricsEnabled, + "MetricsEnabled must default to true when config does not set it, matching CLI behavior") +} + +// TestBuildFullRunConfig_NoOtelConfigLeavesTelemetryNil verifies that when no +// OpenTelemetry config is set, the RunConfig's TelemetryConfig remains nil — +// the API path does not invent an endpoint. +func TestBuildFullRunConfig_NoOtelConfigLeavesTelemetryNil(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGroupManager := groupsmocks.NewMockManager(ctrl) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + + const testImage = "test-image" + + mockRetriever := func( + _ context.Context, + _ string, _ string, _ string, _ string, + _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + return testImage, ®types.ImageMetadata{Image: testImage}, nil + } + + service := &WorkloadService{ + groupManager: mockGroupManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewPathProvider(t.TempDir() + "/empty.yaml"), + imageVerification: retriever.VerifyImageWarn, + } + + req := &createRequest{ + Name: "testserver", + updateRequest: updateRequest{Image: testImage}, + } + + runConfig, err := service.BuildFullRunConfig(context.Background(), req, 0) + require.NoError(t, err) + assert.Nil(t, runConfig.TelemetryConfig, + "TelemetryConfig must remain nil when config.OTEL has no endpoint or prometheus path") +} + // writeFactorySentinelConfig writes a YAML config file with DisableUsageMetrics: true // as a sentinel value and returns its path. func writeFactorySentinelConfig(t *testing.T, dir string) string { @@ -753,7 +857,7 @@ func TestBuildRemoteAuthConfigFromMetadata(t *testing.T) { assert.Equal(t, "https://resource.example.com", cfg.Resource) }) - t.Run("resource derived from URL when both user and metadata unset", func(t *testing.T) { + t.Run("resource empty when both user and metadata unset", func(t *testing.T) { t.Parallel() md := baseMetadata() @@ -762,7 +866,7 @@ func TestBuildRemoteAuthConfigFromMetadata(t *testing.T) { cfg := buildRemoteAuthConfigFromMetadata(&createRequest{}, md) require.NotNil(t, cfg) - assert.NotEmpty(t, cfg.Resource, "resource should be derived from md.URL") + assert.Empty(t, cfg.Resource, "resource should not be auto-derived from URL") }) t.Run("user ClientSecret is applied in CLI string format", func(t *testing.T) { diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index 180e4e5045..9d51f17c8a 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -16,6 +16,7 @@ import ( "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/transport/middleware" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" ) // workloadListResponse represents the response for listing workloads @@ -183,6 +184,40 @@ type oidcOptions struct { Scopes []string `json:"scopes,omitempty"` } +// upgradeCheckResponse is the response for a single-workload upgrade check. +// +// @Description Result of checking a single workload for an available upgrade +type upgradeCheckResponse struct { + // Result is the upgrade-check outcome for the workload. It carries only + // metadata (status, image references, drift) and never secret values. + Result *upgrade.CheckResult `json:"result"` +} + +// upgradeRequest is the request body for applying an upgrade to a workload. +// +// @Description Request to apply an available upgrade to a workload. All fields +// @Description are optional; an empty body applies the upgrade preserving the +// @Description workload's existing configuration. +type upgradeRequest struct { + // Env holds additional or overriding environment variables to merge into the + // upgraded workload's configuration. + Env map[string]string `json:"env,omitempty"` + // Secrets holds additional secret parameters (`,target=`) to merge + // into the upgraded workload's configuration. Only references are accepted; + // no secret values are transmitted in the request. + Secrets []string `json:"secrets,omitempty"` +} + +// upgradeCheckBulkResponse is the response for a batch upgrade check. +// +// @Description Results of checking multiple workloads for available upgrades +type upgradeCheckBulkResponse struct { + // Results holds one upgrade-check outcome per scoped workload, in the order + // the workloads were enumerated. Each entry carries only metadata and never + // secret values. + Results []*upgrade.CheckResult `json:"results"` +} + // createWorkloadResponse represents the response for workload creation // // @Description Response after successfully creating a workload diff --git a/pkg/api/v1/workloads.go b/pkg/api/v1/workloads.go index f10c4eb9ec..5956318909 100644 --- a/pkg/api/v1/workloads.go +++ b/pkg/api/v1/workloads.go @@ -18,9 +18,11 @@ import ( apierrors "github.com/stacklok/toolhive/pkg/api/errors" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/workloads" wt "github.com/stacklok/toolhive/pkg/workloads/types" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" ) const ( @@ -41,6 +43,21 @@ type WorkloadRoutes struct { debugMode bool groupManager groups.Manager workloadService *WorkloadService + + // loadRunConfig loads a single workload's saved RunConfig. Injected so the + // upgrade-check handlers can be unit tested without the global state store. + loadRunConfig runConfigLoader + // listRunConfigNames lists all saved RunConfig names. Injected for the same + // testability reason as loadRunConfig. + listRunConfigNames runConfigLister + // registryProvider returns the registry provider used by upgrade checks. + // Injected so tests can supply a mock provider. + registryProvider registryProviderFunc + // applierFactory builds the applier used by the POST upgrade handler. It is + // always populated in WorkloadRouter with the real registry/pull-backed + // upgrade.Applier; injected so tests can supply a stub that exercises the + // apply path without resolving and pulling a real image. + applierFactory upgradeApplierFactory } // @title ToolHive API @@ -64,11 +81,33 @@ func WorkloadRouter( ) routes := WorkloadRoutes{ - workloadManager: workloadManager, - containerRuntime: containerRuntime, - debugMode: debugMode, - groupManager: groupManager, - workloadService: workloadService, + workloadManager: workloadManager, + containerRuntime: containerRuntime, + debugMode: debugMode, + groupManager: groupManager, + workloadService: workloadService, + loadRunConfig: runner.LoadState, + listRunConfigNames: defaultRunConfigLister, + registryProvider: func() (registry.Provider, error) { + return registry.GetDefaultProviderWithConfig( + workloadService.configProvider, + registry.WithInteractive(false), + ) + }, + } + // applierFactory builds the real registry/pull-backed upgrade applier. It is + // assigned after routes is constructed so the closure can reuse the route's + // own newUpgradeChecker (which shares the registry provider above). + routes.applierFactory = func() (workloadUpgradeApplier, error) { + checker, err := routes.newUpgradeChecker() + if err != nil { + return nil, err + } + applier, err := upgrade.NewApplier(workloadManager, checker, workloadService.configProvider) + if err != nil { + return nil, fmt.Errorf("failed to create upgrade applier: %w", err) + } + return applier, nil } r := chi.NewRouter() @@ -80,6 +119,14 @@ func WorkloadRouter( r.With(stdTimeout).Post("/stop", apierrors.ErrorHandler(routes.stopWorkloadsBulk)) r.With(stdTimeout).Post("/restart", apierrors.ErrorHandler(routes.restartWorkloadsBulk)) r.With(stdTimeout).Post("/delete", apierrors.ErrorHandler(routes.deleteWorkloadsBulk)) + // Register the literal /upgrade-check before /{name} so chi routes it + // distinctly from the single-workload wildcard. + r.With(stdTimeout).Get("/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckBulk)) + r.With(stdTimeout).Get("/{name}/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckSingle)) + // The upgrade apply path verifies and pulls the candidate image, so it gets + // the longer timeout. The /{name}/upgrade sub-path is distinct from /{name} + // and is never shadowed by the single-workload wildcard. + r.With(longTimeout).Post("/{name}/upgrade", apierrors.ErrorHandler(routes.upgradeWorkload)) r.With(stdTimeout).Get("/{name}", apierrors.ErrorHandler(routes.getWorkload)) r.With(longTimeout).Post("/{name}/edit", apierrors.ErrorHandler(routes.updateWorkload)) r.With(stdTimeout).Post("/{name}/stop", apierrors.ErrorHandler(routes.stopWorkload)) @@ -121,9 +168,18 @@ func (s *WorkloadRoutes) listWorkloads(w http.ResponseWriter, r *http.Request) e http.StatusBadRequest, ) } + // FilterByGroup silently returns an empty slice for an unknown group, + // so check existence explicitly to honor the documented 404. + exists, existsErr := s.groupManager.Exists(ctx, groupFilter) + if existsErr != nil { + return fmt.Errorf("failed to check group existence: %w", existsErr) + } + if !exists { + return fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupFilter) + } workloadList, err = workloads.FilterByGroup(workloadList, groupFilter) if err != nil { - return err // groups.ErrGroupNotFound already has 404 status code + return err } } diff --git a/pkg/api/v1/workloads_types_test.go b/pkg/api/v1/workloads_types_test.go index 7d00e9e8f4..cad8ff09bc 100644 --- a/pkg/api/v1/workloads_types_test.go +++ b/pkg/api/v1/workloads_types_test.go @@ -515,6 +515,44 @@ func TestCreateRequestToRemoteAuthConfig(t *testing.T) { assert.Equal(t, tt.expectedBearerToken, result.BearerToken) }) } + + // Guard against reintroduction of RFC 8707 auto-derivation from URL. + // Resource must only be set when explicitly provided (see #5203). + t.Run("resource empty when URL set but resource not explicitly provided", func(t *testing.T) { + t.Parallel() + + req := &createRequest{ + updateRequest: updateRequest{ + URL: "https://mcp.example.com/mcp", + OAuthConfig: remoteOAuthConfig{ClientID: "some-client"}, + }, + } + + cfg := createRequestToRemoteAuthConfig(context.Background(), req) + + require.NotNil(t, cfg) + assert.Empty(t, cfg.Resource, "resource must not be auto-derived from URL when not explicitly set") + }) + + t.Run("resource preserved when user provides it explicitly", func(t *testing.T) { + t.Parallel() + + req := &createRequest{ + updateRequest: updateRequest{ + URL: "https://mcp.example.com/mcp", + OAuthConfig: remoteOAuthConfig{ + ClientID: "some-client", + Resource: "https://explicit-resource.example.com", + }, + }, + } + + cfg := createRequestToRemoteAuthConfig(context.Background(), req) + + require.NotNil(t, cfg) + assert.Equal(t, "https://explicit-resource.example.com", cfg.Resource, + "user-provided resource must be preserved verbatim") + }) } func TestValidateHeaderForwardConfig(t *testing.T) { diff --git a/pkg/api/v1/workloads_upgrade.go b/pkg/api/v1/workloads_upgrade.go new file mode 100644 index 0000000000..4b23161d9f --- /dev/null +++ b/pkg/api/v1/workloads_upgrade.go @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/stacklok/toolhive-core/httperr" + groupval "github.com/stacklok/toolhive-core/validation/group" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/state" + "github.com/stacklok/toolhive/pkg/workloads" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +// workloadUpgradeApplier is the subset of *upgrade.Applier the POST handler +// depends on. It exists so the apply path can be unit tested with a stub, +// without resolving and pulling a real candidate image. +type workloadUpgradeApplier interface { + Apply(ctx context.Context, name string, opts upgrade.ApplyOptions) (*upgrade.CheckResult, error) +} + +// upgradeApplierFactory builds the applier used to materialize an upgrade for a +// single workload. It is always populated in WorkloadRouter with the real +// registry/pull-backed applier; it is injected so tests can supply a stub. +type upgradeApplierFactory func() (workloadUpgradeApplier, error) + +// runConfigLoader loads a single workload's saved RunConfig by name. +type runConfigLoader func(ctx context.Context, name string) (*runner.RunConfig, error) + +// runConfigLister lists the names of all saved RunConfigs. +type runConfigLister func(ctx context.Context) ([]string, error) + +// registryProviderFunc returns a registry provider for upgrade checks. +type registryProviderFunc func() (registry.Provider, error) + +// defaultRunConfigLister lists saved RunConfig names from the local state store. +// It mirrors the enumeration used by manager.ListWorkloadsUsingSecret. +func defaultRunConfigLister(ctx context.Context) ([]string, error) { + store, err := state.NewRunConfigStore(state.DefaultAppName) + if err != nil { + return nil, fmt.Errorf("failed to create state store: %w", err) + } + return store.List(ctx) +} + +// upgradeCheckSingle handles GET /workloads/{name}/upgrade-check. +// +// @Summary Check a workload for an available upgrade +// @Description Check whether a single workload has a newer image available in +// @Description its source registry. This is an offline metadata comparison; it +// @Description does not pull images. Secret values are never returned. +// @Tags workloads +// @Produce json +// @Param name path string true "Workload name" +// @Success 200 {object} upgradeCheckResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Router /api/v1beta/workloads/{name}/upgrade-check [get] +func (s *WorkloadRoutes) upgradeCheckSingle(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + name := chi.URLParam(r, "name") + + // Check if workload exists first (mirrors getWorkload's existence check). + if _, err := s.workloadManager.GetWorkload(ctx, name); err != nil { + return err // ErrWorkloadNotFound (404) or ErrInvalidWorkloadName (400) already have status codes + } + + runConfig, err := s.loadRunConfig(ctx, name) + if err != nil { + return httperr.WithCode( + fmt.Errorf("workload configuration not found: %w", err), + http.StatusNotFound, + ) + } + + checker, err := s.newUpgradeChecker() + if err != nil { + return err + } + + result, err := checker.Check(ctx, runConfig) + if err != nil { + return fmt.Errorf("failed to check workload for upgrade: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(upgradeCheckResponse{Result: result}); err != nil { + return fmt.Errorf("failed to marshal upgrade check result: %w", err) + } + return nil +} + +// upgradeWorkload handles POST /workloads/{name}/upgrade. +// +// @Summary Apply an available upgrade to a workload +// @Description Apply a registry-sourced upgrade to a single workload. This +// @Description re-resolves and verifies the candidate image, pulls it, and only +// @Description then recreates the workload with the new image, preserving the +// @Description existing configuration. If the workload is already up to date or +// @Description is not registry-sourced, the current check result is returned +// @Description unchanged (no-op). Secret values are never accepted or returned. +// @Tags workloads +// @Accept json +// @Produce json +// @Param name path string true "Workload name" +// @Param request body upgradeRequest false "Upgrade options" +// @Success 200 {object} upgradeCheckResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Failure 422 {string} string "Unprocessable Entity" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/workloads/{name}/upgrade [post] +func (s *WorkloadRoutes) upgradeWorkload(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + name := chi.URLParam(r, "name") + + // Check if workload exists first (mirrors upgradeCheckSingle's existence check). + if _, err := s.workloadManager.GetWorkload(ctx, name); err != nil { + return err // ErrWorkloadNotFound (404) or ErrInvalidWorkloadName (400) already have status codes + } + + // Decode the optional request body. An empty body is valid: it applies the + // upgrade preserving the workload's existing configuration. + var req upgradeRequest + if r.Body != nil && r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("failed to decode request: %w", err), + http.StatusBadRequest, + ) + } + } + + applier, err := s.applierFactory() + if err != nil { + return err + } + + // The API path is always non-interactive: use the detached validator so a + // candidate that newly requires an unsupplied secret fails loud rather than + // prompting. s.workloadService.imageVerification keeps API verification in + // sync with the create/edit paths. + opts := upgrade.ApplyOptions{ + EnvVars: req.Env, + Secrets: req.Secrets, + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + VerifySetting: s.workloadService.imageVerification, + } + + // Apply is synchronous and returns the CheckResult that drove the upgrade. + // We deliberately return 200 (not 202) because Apply runs the entire verify + // -> pull -> recreate sequence inline and reports the applied result; a 202 + // would lose that result and force the client to re-poll. The longTimeout on + // this route accommodates the image pull. + result, err := applier.Apply(ctx, name, opts) + if err != nil { + // apierrors.ErrorHandler returns the error message verbatim to the client + // for 4xx codes, so the underlying error must NOT be wrapped into the + // response: it may reference the request's secret parameters (e.g. an + // env/secret validation failure). Log the detailed cause server-side and + // return a sanitized, secret-free message to the caller. The log line + // carries only the error chain (which itself references secrets by name, + // never resolved values). + slog.Error("failed to apply workload upgrade", "workload", name, "error", err) + + // A failure tagged ErrApplyAfterDestroy happened AFTER the destructive + // recreate began: the workload may be stopped, deleted, or only partially + // recreated, so its state is uncertain. That is a 5xx-class condition, not + // a 422 — returning 422 ("request couldn't be processed, nothing changed") + // would wrongly tell the client it is safe to retry against an intact + // workload. + if errors.Is(err, upgrade.ErrApplyAfterDestroy) { + return httperr.WithCode( + fmt.Errorf("upgrade of workload %q failed after the recreate began; its state is uncertain", name), + http.StatusInternalServerError, + ) + } + + // Preparation failures (resolve/verify/pull/build/validate) map to 422: + // the request was well-formed but the candidate could not be applied, and + // the running workload is untouched. + return httperr.WithCode( + fmt.Errorf("failed to apply upgrade for workload %q", name), + http.StatusUnprocessableEntity, + ) + } + + // On a no-op (already up to date / not registry-sourced) Apply returns the + // current result without recreating the workload. Either way we return the + // result so the client can see what happened. + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(upgradeCheckResponse{Result: result}); err != nil { + return fmt.Errorf("failed to marshal upgrade result: %w", err) + } + return nil +} + +// upgradeCheckBulk handles GET /workloads/upgrade-check. +// +// @Summary Check workloads for available upgrades +// @Description Check all workloads (optionally filtered by group) for newer +// @Description images available in their source registries. This is an offline +// @Description metadata comparison; it does not pull images. Secret values are +// @Description never returned. +// @Tags workloads +// @Produce json +// @Param all query bool false "Include stopped workloads" +// @Param group query string false "Filter workloads by group name" +// @Success 200 {object} upgradeCheckBulkResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Group not found" +// @Router /api/v1beta/workloads/upgrade-check [get] +func (s *WorkloadRoutes) upgradeCheckBulk(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + listAll := r.URL.Query().Get("all") == "true" + groupFilter := r.URL.Query().Get("group") + + // Reuse the exact group/authorization scoping of listWorkloads so the batch + // endpoint can never report on workloads the caller cannot otherwise list. + workloadList, err := s.workloadManager.ListWorkloads(ctx, listAll) + if err != nil { + return fmt.Errorf("failed to list workloads: %w", err) + } + if groupFilter != "" { + if err := groupval.ValidateName(groupFilter); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid group name: %w", err), + http.StatusBadRequest, + ) + } + // FilterByGroup silently returns an empty slice for an unknown group, + // so check existence explicitly to honor the documented 404. + exists, existsErr := s.groupManager.Exists(ctx, groupFilter) + if existsErr != nil { + return fmt.Errorf("failed to check group existence: %w", existsErr) + } + if !exists { + return fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupFilter) + } + workloadList, err = workloads.FilterByGroup(workloadList, groupFilter) + if err != nil { + return err + } + } + + // Restrict the set of RunConfigs to those in the scoped workload list. + inScope := make(map[string]struct{}, len(workloadList)) + for _, wl := range workloadList { + inScope[wl.Name] = struct{}{} + } + + configNames, err := s.listRunConfigNames(ctx) + if err != nil { + return fmt.Errorf("failed to list workload configurations: %w", err) + } + + configs := make([]*runner.RunConfig, 0, len(inScope)) + for _, cfgName := range configNames { + if _, ok := inScope[cfgName]; !ok { + continue + } + runConfig, err := s.loadRunConfig(ctx, cfgName) + if err != nil { + // Skip configs we can't load; they may be corrupted or from an older + // version. The workload simply won't appear in the results. + slog.Debug("failed to load run config for upgrade check", "workload", cfgName, "error", err) + continue + } + configs = append(configs, runConfig) + } + + checker, err := s.newUpgradeChecker() + if err != nil { + return err + } + + results := checker.CheckAll(ctx, configs) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(upgradeCheckBulkResponse{Results: results}); err != nil { + return fmt.Errorf("failed to marshal upgrade check results: %w", err) + } + return nil +} + +// newUpgradeChecker builds an upgrade.Checker backed by the registry provider. +func (s *WorkloadRoutes) newUpgradeChecker() (*upgrade.Checker, error) { + provider, err := s.registryProvider() + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("failed to get registry provider: %w", err), + http.StatusServiceUnavailable, + ) + } + checker, err := upgrade.NewChecker(provider) + if err != nil { + return nil, fmt.Errorf("failed to create upgrade checker: %w", err) + } + return checker, nil +} diff --git a/pkg/api/v1/workloads_upgrade_test.go b/pkg/api/v1/workloads_upgrade_test.go new file mode 100644 index 0000000000..29afb77725 --- /dev/null +++ b/pkg/api/v1/workloads_upgrade_test.go @@ -0,0 +1,741 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + apierrors "github.com/stacklok/toolhive/pkg/api/errors" + "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" + groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/registry" + registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/transport/types" + workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" + wt "github.com/stacklok/toolhive/pkg/workloads/types" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +// upgradeTestRoutes builds a WorkloadRoutes wired with the supplied workload +// manager and registry provider plus in-memory loaders, so the upgrade-check +// handlers can run without touching the global state store or a real registry. +func upgradeTestRoutes( + wm *workloadsmocks.MockManager, + provider registry.Provider, + configs map[string]*runner.RunConfig, +) *WorkloadRoutes { + return &WorkloadRoutes{ + workloadManager: wm, + loadRunConfig: func(_ context.Context, name string) (*runner.RunConfig, error) { + cfg, ok := configs[name] + if !ok { + return nil, runtime.ErrWorkloadNotFound + } + return cfg, nil + }, + listRunConfigNames: func(_ context.Context) ([]string, error) { + names := make([]string, 0, len(configs)) + for n := range configs { + names = append(names, n) + } + return names, nil + }, + registryProvider: func() (registry.Provider, error) { + return provider, nil + }, + } +} + +// imageServer is a registry image entry fixture. +func imageServer(image string) *regtypes.ImageMetadata { + return ®types.ImageMetadata{Image: image} +} + +func TestUpgradeCheckSingle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workloadName string + setupMock func(*workloadsmocks.MockManager, *registrymocks.MockProvider) + configs map[string]*runner.RunConfig + expectedStatus int + expectedStatusBody upgrade.UpgradeStatus + expectBody string + }{ + { + name: "up-to-date", + workloadName: "fetch", + setupMock: func(wm *workloadsmocks.MockManager, p *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + p.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + }, + configs: map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + }, + expectedStatus: http.StatusOK, + expectedStatusBody: upgrade.StatusUpToDate, + }, + { + name: "upgrade-available", + workloadName: "fetch", + setupMock: func(wm *workloadsmocks.MockManager, p *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + p.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + }, + configs: map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + }, + expectedStatus: http.StatusOK, + expectedStatusBody: upgrade.StatusUpgradeAvailable, + }, + { + name: "workload not found", + workloadName: "missing", + setupMock: func(wm *workloadsmocks.MockManager, _ *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "missing"). + Return(core.Workload{}, runtime.ErrWorkloadNotFound) + }, + configs: map[string]*runner.RunConfig{}, + expectedStatus: http.StatusNotFound, + expectBody: "workload not found", + }, + { + name: "invalid workload name", + workloadName: "bad-name", + setupMock: func(wm *workloadsmocks.MockManager, _ *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "bad-name"). + Return(core.Workload{}, wt.ErrInvalidWorkloadName) + }, + configs: map[string]*runner.RunConfig{}, + expectedStatus: http.StatusBadRequest, + expectBody: "invalid workload name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + tt.setupMock(wm, provider) + + routes := upgradeTestRoutes(wm, provider, tt.configs) + + req := httptest.NewRequest("GET", "/"+tt.workloadName+"/upgrade-check", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", tt.workloadName) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckSingle).ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + if tt.expectBody != "" { + assert.Contains(t, w.Body.String(), tt.expectBody) + return + } + + var resp upgradeCheckResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Result) + assert.Equal(t, tt.expectedStatusBody, resp.Result.Status) + assert.Equal(t, tt.workloadName, resp.Result.WorkloadName) + }) + } +} + +func TestUpgradeCheckBulk(t *testing.T) { + t.Parallel() + + t.Run("mixed results", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + {Name: "time"}, + {Name: "custom"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + provider.EXPECT().GetServer("io.github/time").Return(imageServer("ghcr.io/example/time:2.0.0"), nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "time": {Name: "time", Image: "ghcr.io/example/time:1.0.0", RegistryServerName: "io.github/time"}, + "custom": {Name: "custom", Image: "ghcr.io/example/custom:1.0.0"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 3) + + byName := map[string]*upgrade.CheckResult{} + for _, r := range resp.Results { + byName[r.WorkloadName] = r + } + assert.Equal(t, upgrade.StatusUpToDate, byName["fetch"].Status) + assert.Equal(t, upgrade.StatusUpgradeAvailable, byName["time"].Status) + assert.Equal(t, upgrade.StatusNotRegistrySourced, byName["custom"].Status) + }) + + t.Run("group filter scopes results", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // Listing returns two workloads in different groups; only the prod one + // survives FilterByGroup, so only its registry lookup occurs. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch", Group: "prod"}, + {Name: "time", Group: "dev"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + + gm := groupsmocks.NewMockManager(ctrl) + gm.EXPECT().Exists(gomock.Any(), "prod").Return(true, nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "time": {Name: "time", Image: "ghcr.io/example/time:1.0.0", RegistryServerName: "io.github/time"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + routes.groupManager = gm + + req := httptest.NewRequest("GET", "/upgrade-check?group=prod", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + assert.Equal(t, upgrade.StatusUpgradeAvailable, resp.Results[0].Status) + }) + + t.Run("unknown group returns 404", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // The group does not exist, so no per-workload registry lookup happens. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch", Group: "prod"}, + }, nil) + + gm := groupsmocks.NewMockManager(ctrl) + gm.EXPECT().Exists(gomock.Any(), "ghost").Return(false, nil) + + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + routes.groupManager = gm + + req := httptest.NewRequest("GET", "/upgrade-check?group=ghost", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("invalid group name", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{}, nil) + + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + + req := httptest.NewRequest("GET", "/upgrade-check?group=Invalid%20Group", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid group name") + }) + + t.Run("stale on-disk config absent from ListWorkloads is excluded", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // ListWorkloads reports only "fetch". "orphan" is a stale on-disk config + // that the manager no longer lists. The inScope intersection must drop it, + // and the provider must never be queried for the orphan's registry server + // (mock strictness fails the test if GetServer("io.github/orphan") fires). + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "orphan": {Name: "orphan", Image: "ghcr.io/example/orphan:1.0.0", RegistryServerName: "io.github/orphan"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + for _, r := range resp.Results { + assert.NotEqual(t, "orphan", r.WorkloadName, "stale on-disk config must not appear in results") + } + }) + + t.Run("unloadable in-scope config is skipped, not fatal", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // Both workloads are in scope, but "broken" fails to load. The batch must + // still succeed and return a result for the loadable workload. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + {Name: "broken"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + + fetchCfg := &runner.RunConfig{Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"} + routes := &WorkloadRoutes{ + workloadManager: wm, + loadRunConfig: func(_ context.Context, name string) (*runner.RunConfig, error) { + if name == "broken" { + return nil, errors.New("corrupted run config") + } + return fetchCfg, nil + }, + listRunConfigNames: func(_ context.Context) ([]string, error) { + return []string{"fetch", "broken"}, nil + }, + registryProvider: func() (registry.Provider, error) { return provider, nil }, + } + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + assert.Equal(t, upgrade.StatusUpgradeAvailable, resp.Results[0].Status) + }) +} + +// TestUpgradeCheckNoSecretLeak asserts the upgrade-check response carries only +// metadata and never any secret values from the workload's RunConfig. +func TestUpgradeCheckNoSecretLeak(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + // Candidate declares a secret env var with a default; the drift report must + // surface the name but never the secret default value. + provider.EXPECT().GetServer("io.github/fetch").Return(®types.ImageMetadata{ + Image: "ghcr.io/example/fetch:1.1.0", + EnvVars: []*regtypes.EnvVar{ + {Name: "API_TOKEN", Secret: true, Required: true, Default: "super-secret-default"}, + }, + }, nil) + + configs := map[string]*runner.RunConfig{ + "fetch": { + Name: "fetch", + Image: "ghcr.io/example/fetch:1.0.0", + RegistryServerName: "io.github/fetch", + Transport: types.TransportTypeStdio, + EnvVars: map[string]string{"PLAIN": "value"}, + Secrets: []string{"my-vault-secret,target=OTHER_TOKEN"}, + }, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/fetch/upgrade-check", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "fetch") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckSingle).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + body := w.Body.String() + assert.NotContains(t, body, "super-secret-default", "secret env var default must not leak") + assert.NotContains(t, body, "my-vault-secret", "secret parameter name must not leak") + assert.NotContains(t, body, "OTHER_TOKEN", "secret target must not leak") + // The drift report should still name the missing candidate env var. + assert.Contains(t, body, "API_TOKEN") +} + +// stubApplier is a workloadUpgradeApplier test double. It records the arguments +// it was called with and returns the configured result/error. +type stubApplier struct { + called bool + gotName string + gotOpts upgrade.ApplyOptions + result *upgrade.CheckResult + err error + // errFromOpts, when set, derives the returned error from the received opts. + // It lets a test feed a secret reference in via the request body and confirm + // the handler does not propagate it to the response even when the underlying + // error mentions it. + errFromOpts func(upgrade.ApplyOptions) error +} + +func (s *stubApplier) Apply(_ context.Context, name string, opts upgrade.ApplyOptions) (*upgrade.CheckResult, error) { + s.called = true + s.gotName = name + s.gotOpts = opts + if s.errFromOpts != nil { + return nil, s.errFromOpts(opts) + } + return s.result, s.err +} + +// upgradeApplyRoutes wires a WorkloadRoutes for the POST upgrade handler with a +// stub applier and a workloadService carrying the verification setting the +// handler reads. +func upgradeApplyRoutes(wm *workloadsmocks.MockManager, applier workloadUpgradeApplier) *WorkloadRoutes { + return &WorkloadRoutes{ + workloadManager: wm, + workloadService: &WorkloadService{imageVerification: "warn"}, + applierFactory: func() (workloadUpgradeApplier, error) { + return applier, nil + }, + } +} + +// doUpgradeRequest issues a POST /{name}/upgrade against the handler with the +// chi route context populated, returning the recorder. +func doUpgradeRequest(routes *WorkloadRoutes, name, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/"+name+"/upgrade", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", name) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeWorkload).ServeHTTP(w, req) + return w +} + +func TestUpgradeWorkload(t *testing.T) { + t.Parallel() + + t.Run("happy path applies upgrade and returns applied result", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + + applier := &stubApplier{ + result: &upgrade.CheckResult{ + WorkloadName: "fetch", + Status: upgrade.StatusUpgradeAvailable, + CurrentImage: "ghcr.io/example/fetch:1.0.0", + CandidateImage: "ghcr.io/example/fetch:1.1.0", + }, + } + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "fetch", `{"env":{"FOO":"bar"},"secrets":["vault,target=TOKEN"]}`) + + require.Equal(t, http.StatusOK, w.Code) + require.True(t, applier.called, "applier must be invoked") + assert.Equal(t, "fetch", applier.gotName) + // The handler must pass through the request env/secrets and pin the + // non-interactive validator + verification setting. + assert.Equal(t, map[string]string{"FOO": "bar"}, applier.gotOpts.EnvVars) + assert.Equal(t, []string{"vault,target=TOKEN"}, applier.gotOpts.Secrets) + assert.IsType(t, &runner.DetachedEnvVarValidator{}, applier.gotOpts.EnvVarValidator) + assert.Equal(t, "warn", applier.gotOpts.VerifySetting) + + var resp upgradeCheckResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Result) + assert.Equal(t, upgrade.StatusUpgradeAvailable, resp.Result.Status) + assert.Equal(t, "ghcr.io/example/fetch:1.1.0", resp.Result.CandidateImage) + // The success response is built solely from the CheckResult; the secret + // reference supplied in the request body must never appear in it. + assert.NotContains(t, w.Body.String(), "vault", "secret reference must not leak into success response") + }) + + t.Run("empty body is tolerated", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + + applier := &stubApplier{ + result: &upgrade.CheckResult{WorkloadName: "fetch", Status: upgrade.StatusUpgradeAvailable}, + } + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "fetch", "") + + require.Equal(t, http.StatusOK, w.Code) + require.True(t, applier.called) + assert.Nil(t, applier.gotOpts.EnvVars) + assert.Nil(t, applier.gotOpts.Secrets) + }) + + t.Run("no-op when not upgrade-available returns result without recreating", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + // Strict mock: UpdateWorkload must never be called on a no-op. The stub + // applier returns up-to-date without touching the manager, so any + // UpdateWorkload expectation is intentionally absent and would fail. + + applier := &stubApplier{ + result: &upgrade.CheckResult{WorkloadName: "fetch", Status: upgrade.StatusUpToDate}, + } + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "fetch", "") + + require.Equal(t, http.StatusOK, w.Code) + require.True(t, applier.called) + + var resp upgradeCheckResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Result) + assert.Equal(t, upgrade.StatusUpToDate, resp.Result.Status) + }) + + t.Run("apply failure maps to 422 without leaking secrets", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + + // Worst case for leakage: the applier's own error embeds the secret + // reference it received from the request body. The handler must still + // produce a 422 whose response body does NOT carry that secret string, + // proving it does not propagate request-derived secret data to clients. + applier := &stubApplier{ + errFromOpts: func(opts upgrade.ApplyOptions) error { + return fmt.Errorf("image verification failed processing %q", opts.Secrets[0]) + }, + } + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "fetch", `{"secrets":["super-secret-value,target=TOKEN"]}`) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + body := w.Body.String() + assert.Contains(t, body, "failed to apply upgrade") + assert.NotContains(t, body, "super-secret-value", "secret reference must not leak into error response") + }) + + t.Run("post-destruction failure maps to 500", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + + // A failure tagged ErrApplyAfterDestroy means the workload was already torn + // down: the handler must report 5xx (state uncertain), not 422 (which would + // imply the workload is intact and the request is safe to retry). + applier := &stubApplier{ + err: fmt.Errorf("completion failed: %w", upgrade.ErrApplyAfterDestroy), + } + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "fetch", "") + + // 500, not 422. httperr sanitizes 5xx bodies to a generic message, so the + // detailed cause stays server-side (asserted by absence below). + require.Equal(t, http.StatusInternalServerError, w.Code) + assert.NotContains(t, w.Body.String(), "uncertain", "5xx body must not echo internal detail") + }) + + t.Run("unknown workload returns 404", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "missing"). + Return(core.Workload{}, runtime.ErrWorkloadNotFound) + + applier := &stubApplier{} + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "missing", "") + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.False(t, applier.called, "applier must not run for an unknown workload") + }) + + t.Run("invalid workload name returns 400", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + wm.EXPECT().GetWorkload(gomock.Any(), "bad-name"). + Return(core.Workload{}, wt.ErrInvalidWorkloadName) + + applier := &stubApplier{} + routes := upgradeApplyRoutes(wm, applier) + + w := doUpgradeRequest(routes, "bad-name", "") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.False(t, applier.called) + }) +} + +// TestUpgradeCheckRouting asserts /upgrade-check resolves to the batch handler +// and is not captured by the /{name} wildcard (chi static-before-wildcard +// ordering), while /{name}/upgrade-check resolves to the single handler and +// POST /{name}/upgrade resolves to the apply handler (not getWorkload). +func TestUpgradeCheckRouting(t *testing.T) { + t.Parallel() + + t.Run("GET /upgrade-check resolves to batch handler", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + // The batch handler lists workloads; getWorkload would instead call + // GetWorkload("upgrade-check"). Asserting ListWorkloads is hit proves the + // literal route won, not the wildcard. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{}, nil) + + provider := registrymocks.NewMockProvider(ctrl) + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + newUpgradeTestRouter(routes).ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("POST /{name}/upgrade resolves to apply handler, not getWorkload", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + // getWorkload never matches a POST. To prove the apply handler ran (and not + // some wildcard fallthrough), the apply handler's GetWorkload existence + // check fires and the injected applier records the call. + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + + applier := &stubApplier{ + result: &upgrade.CheckResult{WorkloadName: "fetch", Status: upgrade.StatusUpToDate}, + } + provider := registrymocks.NewMockProvider(ctrl) + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + routes.workloadService = &WorkloadService{imageVerification: "warn"} + routes.applierFactory = func() (workloadUpgradeApplier, error) { return applier, nil } + + req := httptest.NewRequest("POST", "/fetch/upgrade", strings.NewReader("")) + w := httptest.NewRecorder() + newUpgradeTestRouter(routes).ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, applier.called, "POST /{name}/upgrade must resolve to the apply handler") + }) +} + +// newUpgradeTestRouter mounts only the routes relevant to ordering so the test +// exercises chi's real matching between the literal upgrade routes and the +// /{name} wildcard. +func newUpgradeTestRouter(routes *WorkloadRoutes) chi.Router { + r := chi.NewRouter() + r.Get("/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckBulk)) + r.Get("/{name}/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckSingle)) + r.Post("/{name}/upgrade", apierrors.ErrorHandler(routes.upgradeWorkload)) + r.Get("/{name}", apierrors.ErrorHandler(routes.getWorkload)) + return r +} diff --git a/pkg/audit/auditor.go b/pkg/audit/auditor.go index ec2ecc5700..f117016899 100644 --- a/pkg/audit/auditor.go +++ b/pkg/audit/auditor.go @@ -56,6 +56,18 @@ func NewAuditLogger(w io.Writer) *slog.Logger { handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ Level: LevelAudit, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + // Replace the custom audit level with "AUDIT" string for better + // compatibility with log aggregation systems (Loki, Elasticsearch, etc.) + // that expect standard level names. This prevents audit events from + // appearing as "INFO+2" which breaks level-based filtering. + if a.Key == slog.LevelKey { + if level, ok := a.Value.Any().(slog.Level); ok && level == LevelAudit { + a.Value = slog.StringValue("AUDIT") + } + } + return a + }, }) return slog.New(handler) diff --git a/pkg/audit/auditor_test.go b/pkg/audit/auditor_test.go index 4bd5b3b084..c874729e6a 100644 --- a/pkg/audit/auditor_test.go +++ b/pkg/audit/auditor_test.go @@ -5,6 +5,7 @@ package audit import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -1025,3 +1026,40 @@ func TestMiddlewareDetectsJSONRPCErrors(t *testing.T) { assert.NotContains(t, logOutput, OutcomeApplicationError) }) } + +func TestAuditLoggerLevelFormat(t *testing.T) { + t.Parallel() + + t.Run("renders audit level as AUDIT not INFO+2", func(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + logger := NewAuditLogger(&logBuf) + + // Log an audit event + logger.Log(context.TODO(), LevelAudit, "test audit event", + "audit_id", "test-123", + "type", "test_event") + + logOutput := logBuf.String() + // Should contain "AUDIT" not "INFO+2" + assert.Contains(t, logOutput, `"level":"AUDIT"`) + assert.NotContains(t, logOutput, "INFO+2") + assert.NotContains(t, logOutput, "INFO+") + }) + + t.Run("preserves standard log levels", func(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + logger := NewAuditLogger(&logBuf) + + // NewAuditLogger sets Level: LevelAudit (= slog.Level(2)), so INFO + // (slog.Level(0)) is filtered out before ReplaceAttr runs and can't + // be exercised through the production logger. WARN (slog.Level(4)) + // is above LevelAudit and passes through, so we use it to confirm + // the production ReplaceAttr path doesn't mis-label non-audit events. + logger.Warn("warn message") + logOutput := logBuf.String() + assert.Contains(t, logOutput, `"level":"WARN"`) + assert.NotContains(t, logOutput, `"level":"AUDIT"`) + }) +} diff --git a/pkg/auth/awssts/config.go b/pkg/auth/awssts/config.go index e063364faf..bdc347bb7e 100644 --- a/pkg/auth/awssts/config.go +++ b/pkg/auth/awssts/config.go @@ -35,6 +35,11 @@ type Config struct { // SessionNameClaim is the JWT claim to use for role session name (default: "sub"). SessionNameClaim string `json:"session_name_claim,omitempty" yaml:"session_name_claim,omitempty"` + + // SubjectProviderName identifies which upstream provider's access token to use + // for STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer + // token from the incoming HTTP request is used. + SubjectProviderName string `json:"subject_provider_name,omitempty" yaml:"subject_provider_name,omitempty"` } // defaultSessionDuration is the default session duration in seconds (1 hour). diff --git a/pkg/auth/awssts/middleware.go b/pkg/auth/awssts/middleware.go index 78af7ea633..325499c6f5 100644 --- a/pkg/auth/awssts/middleware.go +++ b/pkg/auth/awssts/middleware.go @@ -141,7 +141,7 @@ func newAWSStsMiddleware(ctx context.Context, cfg *Config, targetURL *url.URL) ( func createAWSStsMiddlewareFunc( exchanger *Exchanger, roleMapper *RoleMapper, - signer *requestSigner, + signer *RequestSigner, sessionNameClaim string, sessionDuration int32, targetURL *url.URL, @@ -186,7 +186,7 @@ func createAWSStsMiddlewareFunc( } // Extract and validate session name from claims - sessionName, err := extractSessionName(claims, sessionNameClaim) + sessionName, err := ExtractSessionName(claims, sessionNameClaim) if err != nil { slog.Warn("Failed to extract session name", "error", err) http.Error(w, "Missing session name claim", http.StatusUnauthorized) @@ -232,7 +232,7 @@ func createAWSStsMiddlewareFunc( // clone is used for signing so that only the SigV4 headers are copied back to // the original request; the reverse proxy's Director is left to handle host // rewriting. When targetURL is nil the request is signed in-place. -func signRequestForTarget(r *http.Request, signer *requestSigner, creds *aws.Credentials, targetURL *url.URL) error { +func signRequestForTarget(r *http.Request, signer *RequestSigner, creds *aws.Credentials, targetURL *url.URL) error { if targetURL == nil { return signer.SignRequest(r.Context(), r, creds) } @@ -301,7 +301,7 @@ func signRequestForTarget(r *http.Request, signer *requestSigner, creds *aws.Cre return nil } -// extractSessionName extracts the session name from JWT claims. +// ExtractSessionName extracts the session name from JWT claims. // Returns an error if the configured claim is missing or empty, since a missing // claim likely indicates a misconfiguration and would produce untraceable // CloudTrail entries. @@ -310,7 +310,7 @@ func signRequestForTarget(r *http.Request, signer *requestSigner, creds *aws.Cre // and returns a clear error if the value doesn't conform. // // See: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html -func extractSessionName(claims map[string]interface{}, claimName string) (string, error) { +func ExtractSessionName(claims map[string]interface{}, claimName string) (string, error) { value, ok := claims[claimName] if !ok { return "", fmt.Errorf("claim %q not found in token", claimName) diff --git a/pkg/auth/awssts/middleware_test.go b/pkg/auth/awssts/middleware_test.go index 31f488421c..b7602af0d5 100644 --- a/pkg/auth/awssts/middleware_test.go +++ b/pkg/auth/awssts/middleware_test.go @@ -470,7 +470,7 @@ func TestExtractSessionName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := extractSessionName(tt.claims, tt.claimName) + got, err := ExtractSessionName(tt.claims, tt.claimName) if tt.wantErr { require.Error(t, err) return diff --git a/pkg/auth/awssts/signer.go b/pkg/auth/awssts/signer.go index f329e50522..d59a6944a9 100644 --- a/pkg/auth/awssts/signer.go +++ b/pkg/auth/awssts/signer.go @@ -37,22 +37,22 @@ const emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785 // See: https://docs.aws.amazon.com/aws-mcp/latest/userguide/getting-started-aws-mcp-server.html const defaultService = "aws-mcp" -// requestSigner signs HTTP requests using AWS Signature Version 4. +// RequestSigner signs HTTP requests using AWS Signature Version 4. // // SigV4 signing should run as close to the backend as possible. // Modifying signed headers or the request body after signing will // invalidate the signature. -type requestSigner struct { +type RequestSigner struct { signer *v4.Signer region string service string } -type signerOption func(*requestSigner) +type signerOption func(*RequestSigner) // withService sets a custom service name for SigV4 signing. func withService(service string) signerOption { - return func(s *requestSigner) { + return func(s *RequestSigner) { s.service = service } } @@ -61,12 +61,12 @@ func withService(service string) signerOption { // // By default, it uses "aws-mcp" as the service name for AWS MCP Server. // Use withService to override for other AWS services. -func newRequestSigner(region string, opts ...signerOption) (*requestSigner, error) { +func newRequestSigner(region string, opts ...signerOption) (*RequestSigner, error) { if region == "" { return nil, ErrMissingRegion } - s := &requestSigner{ + s := &RequestSigner{ signer: v4.NewSigner(), region: region, service: defaultService, @@ -79,6 +79,19 @@ func newRequestSigner(region string, opts ...signerOption) (*requestSigner, erro return s, nil } +// NewRequestSigner creates a new SigV4 request signer for the specified region +// and service name. An empty service string defaults to "aws-mcp". +// +// Exported so that pkg/vmcp/auth/strategies and other packages can sign requests +// outside the HTTP middleware flow. +func NewRequestSigner(region, service string) (*RequestSigner, error) { + opts := []signerOption{} + if service != "" { + opts = append(opts, withService(service)) + } + return newRequestSigner(region, opts...) +} + // SignRequest signs an HTTP request using AWS SigV4. // // This method: @@ -97,7 +110,7 @@ func newRequestSigner(region string, opts ...signerOption) (*requestSigner, erro // Returns an error if: // - The request body cannot be read // - Signing fails -func (s *requestSigner) SignRequest(ctx context.Context, req *http.Request, creds *aws.Credentials) error { +func (s *RequestSigner) SignRequest(ctx context.Context, req *http.Request, creds *aws.Credentials) error { if creds == nil { return fmt.Errorf("credentials are required for signing") } @@ -129,7 +142,7 @@ func (s *requestSigner) SignRequest(ctx context.Context, req *http.Request, cred // - payloadHash: Hex-encoded SHA-256 hash of the body // - bodyBytes: The body content (for replacing the consumed reader) // - error: Any error reading the body -func (*requestSigner) hashPayload(req *http.Request) (string, []byte, error) { +func (*RequestSigner) hashPayload(req *http.Request) (string, []byte, error) { // Handle empty body if req.Body == nil || req.Body == http.NoBody { return emptySHA256, nil, nil diff --git a/pkg/auth/dcr/request.go b/pkg/auth/dcr/request.go new file mode 100644 index 0000000000..055056c160 --- /dev/null +++ b/pkg/auth/dcr/request.go @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dcr + +// Request is the profile-neutral input to ResolveCredentials. Each consumer +// (embedded authserver, CLI OAuth flow) translates its domain types into a +// Request at the call site so the resolver does not import any consumer's +// shapes. +// +// The struct collects exactly the fields the resolver reads: +// +// - Identity: Issuer (the caller's logical scope — see the Issuer field +// doc for what each consumer puts there), Scopes, and RedirectURI. +// - Endpoint discovery: DiscoveryURL or RegistrationEndpoint, with optional +// explicit AuthorizationEndpoint / TokenEndpoint overrides. +// - Registration metadata: InitialAccessToken, ClientName. The caller is +// responsible for resolving any file-or-env reference (e.g. the embedded +// authserver's InitialAccessTokenFile / InitialAccessTokenEnvVar) into +// InitialAccessToken before constructing a Request. +// +// Mutually exclusive constraints are enforced at validation time: +// +// - Exactly one of DiscoveryURL / RegistrationEndpoint must be non-empty. +// - Issuer must be non-empty. +// +// Constructing a Request is the caller's responsibility; the resolver does +// not clone or mutate it. +type Request struct { + // Issuer is the caller's logical scope for cache keying and (when + // RedirectURI is empty) the basis for the default redirect URI + // ({Issuer}/oauth/callback). Required. + // + // What each consumer puts here: + // + // - Embedded authserver: its own issuer identifier. The authserver + // owns a distinct logical identity from the upstream IdP, and + // defaulting RedirectURI to {Issuer}/oauth/callback lands the + // callback on the authserver's origin (correct). + // - CLI OAuth flow: the upstream OAuth provider's issuer URL, + // because the CLI has no separate logical issuer of its own. The + // CLI always supplies an explicit loopback RedirectURI per + // RFC 8252 §7.3, so the redirect-URI defaulting is never + // exercised on this path. + // + // The resolver's cache key is (Issuer, RedirectURI, ScopesHash); the + // two consumers do not collide on the key because the embedded + // authserver's RedirectURI lives on the authserver's origin and the + // CLI's lives on a loopback (RFC 8252 §7.3) — even when Issuer + // happens to match between the two profiles, the RedirectURI + // component keeps the cache key distinct. See the cross-consumer + // caveat on dcrFlight in resolver.go for the wider invariant. + // + // Issuer is *not* used for RFC 8414 §3.3 metadata verification — + // that uses an issuer recovered from DiscoveryURL inside the + // resolver. + Issuer string + + // RedirectURI is the redirect URI to register with the upstream. When + // empty, the resolver derives {Issuer}/oauth/callback. HTTPS is required + // except for loopback hosts (RFC 8252 §7.3 — the CLI flow uses + // http://localhost:{port}/callback). + RedirectURI string + + // Scopes are the OAuth scopes to request in the registration body. + // May be empty; the resolver will fall back to discovered scopes if the + // upstream advertises any. + Scopes []string + + // DiscoveryURL points at the upstream's RFC 8414 / OIDC Discovery + // document. The resolver fetches this URL exactly once and reads + // registration_endpoint, authorization_endpoint, token_endpoint, + // token_endpoint_auth_methods_supported, scopes_supported, and + // code_challenge_methods_supported from it. + // + // Mutually exclusive with RegistrationEndpoint. + DiscoveryURL string + + // RegistrationEndpoint is used directly when set, bypassing discovery. + // On this branch the caller is expected to also supply AuthorizationEndpoint + // and TokenEndpoint explicitly; the resolver's auth-method selection + // defaults to client_secret_basic because no server-capability fields + // are available unless CodeChallengeMethodsSupported is also set. + // + // Mutually exclusive with DiscoveryURL. + RegistrationEndpoint string + + // CodeChallengeMethodsSupported lists the PKCE challenge methods the + // upstream advertises. Only consulted when RegistrationEndpoint is set; + // on the DiscoveryURL branch the resolver reads this value from the + // fetched metadata document. Callers that have already performed + // multi-URL AS metadata discovery (e.g. via + // oauthproto.FetchAuthorizationServerMetadata) should populate this + // field so the S256 PKCE gate fires correctly without a redundant + // round-trip. + CodeChallengeMethodsSupported []string + + // AuthorizationEndpoint, when non-empty, overrides any value discovered + // via DiscoveryURL. Explicit caller configuration always wins. + AuthorizationEndpoint string + + // TokenEndpoint, when non-empty, overrides any value discovered via + // DiscoveryURL. Explicit caller configuration always wins. + TokenEndpoint string + + // InitialAccessToken is the RFC 7591 initial access token presented to + // the registration endpoint as a Bearer header. The caller resolves any + // file-or-env reference before populating this field. + InitialAccessToken string + + // ClientName is sent as the RFC 7591 "client_name" registration + // metadata. When empty, the resolver uses + // oauthproto.ToolHiveMCPClientName. + ClientName string + + // PublicClient, when true, instructs the resolver to register as a + // public PKCE client (token_endpoint_auth_method=none) regardless of + // what other methods the upstream advertises. This matches the CLI + // flow's intent (RFC 8252 §8.4 native public clients) and the + // resolver still enforces the RFC 7636 / OAuth 2.1 S256 PKCE gate: + // when the upstream's discovery metadata does not advertise S256 in + // code_challenge_methods_supported, the registration is refused with + // a clear error rather than silently downgrading. + // + // When false (the embedded-authserver default), the resolver picks + // the strongest auth method the upstream advertises (private_key_jwt + // > client_secret_basic > client_secret_post > none, with the same + // S256 gate on "none"). + // + // Has no effect on the RegistrationEndpoint-direct branch when neither + // DiscoveryURL nor CodeChallengeMethodsSupported is set: without + // code_challenge_methods_supported the S256 gate cannot be evaluated, + // so the resolver refuses to register as a public client. + PublicClient bool +} diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go new file mode 100644 index 0000000000..6ffba819f0 --- /dev/null +++ b/pkg/auth/dcr/resolver.go @@ -0,0 +1,1298 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package dcr is the shared RFC 7591 Dynamic Client Registration client used +// by every consumer in the codebase that needs to register a downstream +// OAuth 2.x client at runtime. The package owns the stateful concerns of the +// flow — credential cache, in-process singleflight deduplication, scope-set +// canonicalisation, token-endpoint auth-method selection (with the RFC 7636 / +// OAuth 2.1 S256 PKCE gate), RFC 7591 §3.2.1 expiry-driven cache invalidation, +// the bearer-token transport with redirect refusal, and panic recovery around +// the registration body. Stateless RFC 7591 wire-shape primitives live in +// pkg/oauthproto. +// +// # API surface +// +// ResolveCredentials takes a profile-neutral Request and a CredentialStore +// and returns a Resolution. The Request carries only the fields the resolver +// actually reads (issuer, redirect URI, scopes, discovery URL or registration +// endpoint, optional explicit endpoint overrides, optional initial access +// token, optional registration metadata); each consumer translates its +// domain types into a Request at the call site so the resolver does not +// import any consumer's shapes. +// +// Two consumers exist today: +// +// - pkg/authserver/runner constructs a Request from +// *authserver.OAuth2UpstreamRunConfig and uses its own adapter helpers +// (needsDCR / consumeResolution / applyResolutionToOAuth2Config in +// runner/dcr_adapter.go) to fold the resolution back into its +// run-config and built upstream.OAuth2Config. +// - pkg/auth/discovery constructs a Request from *OAuthFlowConfig for the +// CLI OAuth flow and copies the returned Resolution onto OAuthFlowResult. +// +// # Concurrency +// +// The package maintains a process-global singleflight keyed on the tuple +// (issuer, redirectURI, scopesHash) so concurrent ResolveCredentials calls +// across all consumers in a single process coalesce when their cache keys +// match. Consumers that share any of those three values will share a flight +// — the deduplication is a feature for the embedded authserver but means +// callers cannot assume per-call-site flight isolation. See the dcrFlight +// doc comment below for the rationale. +// +// See issue #5145 for the design discussion that motivated lifting this out +// of pkg/authserver/runner. +package dcr + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "regexp" + "runtime/debug" + "slices" + "strings" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// dcrFlight coalesces concurrent ResolveCredentials calls that share the +// same Key. Two goroutines hitting the resolver for the same upstream and +// scope set will both miss the cache, so without coalescing they would both +// call RegisterClientDynamically and the loser's registration would become +// orphaned at the upstream IdP — an operator-visible cleanup task and +// possibly a transient startup failure if the upstream rate-limits +// concurrent registrations. Followers wait for the leader's result and +// observe the same Resolution. +// +// Lifetime: process-wide. This intentionally contrasts with the +// CredentialStore the embedded authserver constructs and injects into +// ResolveCredentials, which is per-instance for the memory backend and +// shared across replicas for Redis. The asymmetry is load-bearing: the +// singleflight only deduplicates the in-flight network call, while the +// cache deduplicates the resolution itself across calls. Process-wide +// flight means concurrent EmbeddedAuthServer instances in the same +// process targeting the same upstream still get deduplicated; the +// injected cache decides whether the resolution is fresh enough to +// reuse. A Redis-backed store still wants this in-process gate so a +// single replica does not double-register against itself. +// +// Cross-consumer caveat: because dcrFlight is package-global, two +// consumers that happen to construct identical Keys (same issuer, same +// redirect URI, same scopes hash) will share a single in-flight +// registration even if they semantically want different client profiles. +// The two current call sites do not collide by construction — the embedded +// authserver's redirect URI lives on the AS origin, and the CLI flow's +// redirect URI lives on a loopback (http://localhost:{port}/callback per +// RFC 8252 §7.3), so the disjoint RedirectURI address spaces separate +// the public-client and confidential-client profiles at both the +// singleflight and the persistent-cache layers. A future consumer that +// defaults its redirect URI into either of those spaces would silently +// coalesce; if a third profile is added the flight key (and persisted +// DCRKey) MUST gain a consumer-identifier component so a collision is +// impossible rather than improbable. +var dcrFlight singleflight.Group + +// flightKeyOf canonicalises a Key into the singleflight string used by +// dcrFlight. The "\n" separator is safe because newline is not a valid +// byte in any of the three components: URI reference characters in +// Issuer and RedirectURI (RFC 3986 §2), and hex digits in ScopesHash +// (the form storage.ScopesHash always emits). Exposed as a function so +// tests and future inspection helpers can compute the exact key the +// resolver would route through dcrFlight without re-implementing the +// concatenation. +// +// PublicClient is intentionally NOT part of the flight key — the +// dcrFlight doc above explains why: today's two consumers register on +// disjoint RedirectURI address spaces (AS-origin vs RFC 8252 loopback), +// so the public-client and confidential-client profiles cannot share a +// flight key by construction. The same property protects the persisted +// DCRKey from cross-profile coalescence; the two layers are aligned +// rather than asymmetric. +func flightKeyOf(key Key) string { + return key.Issuer + "\n" + key.RedirectURI + "\n" + key.ScopesHash +} + +// defaultUpstreamRedirectPath is the redirect path derived from the issuer +// origin when the caller's run-config does not supply an explicit RedirectURI. +// Matches the authserver's public callback route. +const defaultUpstreamRedirectPath = "/oauth/callback" + +// authMethodPreference is the preferred order of token_endpoint_auth_methods, +// most preferred first. The resolver intersects this list with the server's +// advertised methods and picks the first match. +// +// Rationale: private_key_jwt is cryptographically strongest (asymmetric, no +// shared secret on the wire). client_secret_basic and client_secret_post are +// equally secure in transit but basic is marginally preferred because the +// credentials do not appear in request-body logs. "none" is the fallback for +// public PKCE clients. +var authMethodPreference = []string{ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "none", +} + +// Resolution captures the full RFC 7591 + RFC 7592 response for a +// successful Dynamic Client Registration, together with the endpoints the +// upstream advertises so the caller need not re-discover them. +// +// The struct is the unit of storage in CredentialStore and the unit of +// application that consumers project back into their own domain types +// (e.g. consumeResolution / applyResolutionToOAuth2Config in +// pkg/authserver/runner/dcr_adapter.go, or direct OAuthFlowResult field +// writes in pkg/auth/discovery). +// +// MUST update both converters (resolutionToCredentials and +// credentialsToResolution in store.go) when adding, renaming, or +// removing a field here. The two converters are the seam between this +// dcr-package type and the persisted *storage.DCRCredentials shape; a +// field added here without a paired converter update will silently fail +// to round-trip across an authserver restart, the exact "parallel types +// drift" failure mode .claude/rules/go-style.md warns about. The +// round-trip behaviour is pinned by TestResolutionCredentialsRoundTrip +// in store_test.go. +type Resolution struct { + // ClientID is the RFC 7591 "client_id" returned by the authorization + // server. + ClientID string + + // ClientSecret is the RFC 7591 "client_secret" returned by the + // authorization server. Empty for public PKCE clients. + ClientSecret string + + // AuthorizationEndpoint is the discovered (or configured) authorization + // endpoint for this upstream. + AuthorizationEndpoint string + + // TokenEndpoint is the discovered (or configured) token endpoint for this + // upstream. + TokenEndpoint string + + // RegistrationAccessToken is the RFC 7592 "registration_access_token" + // required for subsequent registration management operations (update, + // read, delete). + RegistrationAccessToken string + + // RegistrationClientURI is the RFC 7592 "registration_client_uri" for + // registration management operations. + RegistrationClientURI string + + // TokenEndpointAuthMethod is the authentication method negotiated at the + // token endpoint for this client. + TokenEndpointAuthMethod string + + // RedirectURI is the redirect URI presented to the authorization server + // during registration. When the caller's Request did not specify one, + // this holds the defaulted value derived from the issuer + /oauth/callback + // (via resolveUpstreamRedirectURI). Persisting it on the resolution lets + // consumers (pkg/authserver/runner.consumeResolution) write it back onto + // the run-config COPY so that downstream code (buildPureOAuth2Config, + // upstream.OAuth2Config validation) sees a non-empty RedirectURI. + RedirectURI string + + // ClientIDIssuedAt is the RFC 7591 §3.2.1 "client_id_issued_at" value + // converted to a Go time.Time. Zero when the upstream omitted the field + // (the field is OPTIONAL per RFC 7591). Informational; not used to + // invalidate the cache. + ClientIDIssuedAt time.Time + + // ClientSecretExpiresAt is the RFC 7591 §3.2.1 "client_secret_expires_at" + // value converted to a Go time.Time. The wire convention is that 0 means + // "the secret does not expire"; in this struct that is represented by + // the zero time.Time so callers can use IsZero() rather than special- + // casing 0. + // + // When non-zero, this field is the authoritative signal that + // lookupCachedResolution uses to refetch credentials before the upstream + // rejects them at the token endpoint. The 90-day dcrStaleAgeThreshold + // is a heuristic for "consider rotating"; this is a hard expiry asserted + // by the upstream itself. + ClientSecretExpiresAt time.Time + + // CreatedAt is the wall-clock time at which the resolution was completed. + // Used by the staleness observability in lookupCachedResolution to + // compute age against dcrStaleAgeThreshold and emit a warn log when a + // cached registration exceeds the threshold. + CreatedAt time.Time +} + +// Step identifiers for structured error logs emitted by the caller of +// ResolveCredentials. These values flow through the "step" attribute so +// operators can narrow failures to a specific phase without parsing error +// messages. They are reported only at the boundary log — see +// dcrStepError — so a single failure produces a single slog.Error record. +const ( + dcrStepValidate = "validate" + dcrStepResolveRedirect = "resolve_redirect_uri" + dcrStepCacheRead = "cache_read" + dcrStepMetadata = "metadata_discovery" + dcrStepSelectAuthMethod = "select_auth_method" + dcrStepRegister = "dcr_call" + dcrStepCacheWrite = "cache_write" +) + +// dcrStepError annotates a resolver error with the phase it was produced +// in. The boundary caller (buildUpstreamConfigs) emits the single +// slog.Error record for the failure; individual error branches inside +// ResolveCredentials do not log so that each failure surfaces exactly +// once in the combined log stream. +// +// RedirectURI is included when known so that operators can correlate the +// failure with a specific upstream registration without parsing the +// wrapped error string. Stack carries a captured stack trace for the +// dcrStepRegister panic-recovery branch so LogStepError can include +// it in the single boundary record without the in-defer site emitting +// its own duplicate slog.Error. A zero-value dcrStepError is invalid; +// construct via newDCRStepError or the resolver's internal helpers. +type dcrStepError struct { + Step string + Issuer string + RedirectURI string + Stack string + Err error +} + +// Error implements error. The "step" tag mirrors the structured-log +// attribute so command-line log scrapers see the same phase identifier. +func (e *dcrStepError) Error() string { + return fmt.Sprintf("dcr: %s: %s", e.Step, e.Err.Error()) +} + +// Unwrap lets errors.Is / errors.As reach the wrapped cause. +func (e *dcrStepError) Unwrap() error { return e.Err } + +// newDCRStepError builds a dcrStepError. It never returns nil for a +// non-nil cause. +func newDCRStepError(step, issuer, redirectURI string, err error) *dcrStepError { + return &dcrStepError{ + Step: step, + Issuer: issuer, + RedirectURI: redirectURI, + Err: err, + } +} + +// ResolveCredentials performs Dynamic Client Registration for req against +// the upstream authorization server identified by req.DiscoveryURL or +// req.RegistrationEndpoint, caching the resulting credentials in cache. +// On cache hit the resolver returns immediately without any network I/O. +// +// req.Issuer is *this caller's* logical issuer identifier, NOT the upstream's. +// It is used to key the cache and to default the redirect URI to +// {req.Issuer}/oauth/callback when req.RedirectURI is empty. The upstream's +// issuer is recovered separately from req.DiscoveryURL inside the resolver +// and is used solely for RFC 8414 §3.3 metadata verification. Passing the +// upstream's issuer in req.Issuer would produce a wrong-origin default +// redirect and a cache key that does not identify the caller context. +// +// The resolver does not mutate req or the cache on failure. +// +// Observability: this function never calls slog.Error directly — all +// failures are annotated with a *dcrStepError and returned to the caller, +// which is expected to emit the boundary Error record. This avoids the +// double-logging pattern where both the resolver and the outer frame +// report the same failure. Cache-hit Debug / stale-Warn logs and the +// successful-registration Debug log are emitted here because they have no +// outer-frame equivalent. No secret values (client_secret, +// registration_access_token, initial_access_token) are ever logged — only +// public metadata such as client_id and redirect_uri. +func ResolveCredentials( + ctx context.Context, + req *Request, + cache CredentialStore, +) (*Resolution, error) { + if err := validateResolveInputs(req, cache); err != nil { + issuer := "" + if req != nil { + issuer = req.Issuer + } + return nil, newDCRStepError(dcrStepValidate, issuer, "", err) + } + + redirectURI, err := resolveUpstreamRedirectURI(req.RedirectURI, req.Issuer) + if err != nil { + return nil, newDCRStepError(dcrStepResolveRedirect, req.Issuer, "", + fmt.Errorf("resolve redirect uri: %w", err)) + } + + scopes := slices.Clone(req.Scopes) + key := Key{ + Issuer: req.Issuer, + RedirectURI: redirectURI, + ScopesHash: storage.ScopesHash(scopes), + } + + // Cache lookup short-circuits before any network I/O. + if cached, hit, err := lookupCachedResolution(ctx, cache, key, req.Issuer, redirectURI); err != nil { + return nil, newDCRStepError(dcrStepCacheRead, req.Issuer, redirectURI, err) + } else if hit { + return cached, nil + } + + // Coalesce concurrent registrations for the same Key — see dcrFlight + // doc comment. The leader runs the registerOnce closure; followers + // receive the leader's *Resolution result. The flight key embeds the + // Key fields with a separator that cannot appear in any of them + // (newline is not valid in OAuth scope tokens, URLs, or hex digests). + // + // A defer/recover inside the closure converts a panic in registerAndCache + // (or anything it calls) into a normal error. Without this, singleflight + // re-panics the leader's panic in every follower — N concurrent callers + // for the same Key would all crash with the same value. The panic is + // still surfaced: the captured stack trace is attached to the wrapped + // dcrStepError and surfaces in the single boundary log emitted by + // LogStepError, so the failure produces exactly one Error record (no + // in-defer log here) and callers can react to it as a normal failure. + flightKey := flightKeyOf(key) + resolutionAny, err, _ := dcrFlight.Do(flightKey, func() (res any, err error) { + defer func() { + if r := recover(); r != nil { + stepErr := newDCRStepError(dcrStepRegister, req.Issuer, redirectURI, + fmt.Errorf("registration panicked: %v", r)) + stepErr.Stack = string(debug.Stack()) + err = stepErr + res = nil + } + }() + return registerAndCache(ctx, req, redirectURI, scopes, key, cache) + }) + if err != nil { + return nil, err + } + return resolutionAny.(*Resolution), nil +} + +// registerAndCache is the leader-only body of ResolveCredentials wrapped +// by the singleflight. It rechecks the cache before any network I/O so +// followers that arrive after the leader's Put returns immediately see the +// fresh entry on a subsequent call. Endpoint resolution, registration, and +// the durable Put live here. +func registerAndCache( + ctx context.Context, + req *Request, + redirectURI string, + scopes []string, + key Key, + cache CredentialStore, +) (*Resolution, error) { + // Recheck cache: another flight that just finished may have populated + // it between our initial lookup and our singleflight entry. + if cached, hit, err := lookupCachedResolution(ctx, cache, key, req.Issuer, redirectURI); err != nil { + return nil, newDCRStepError(dcrStepCacheRead, req.Issuer, redirectURI, err) + } else if hit { + return cached, nil + } + + // Endpoint resolution: discover metadata when configured, otherwise use + // the caller-supplied RegistrationEndpoint directly. The upstream's + // expected issuer is recovered from req.DiscoveryURL inside the helper. + // req.Issuer here is *this caller's* issuer — correct for cache keying + // and redirect URI defaulting, but it must not be used for RFC 8414 + // §3.3 metadata verification (which is the upstream's concern). + endpoints, err := resolveDCREndpoints(ctx, req) + if err != nil { + return nil, newDCRStepError(dcrStepMetadata, req.Issuer, redirectURI, err) + } + if err := applyExplicitEndpointOverrides(endpoints, req); err != nil { + return nil, newDCRStepError(dcrStepMetadata, req.Issuer, redirectURI, err) + } + + // Token-endpoint auth method: intersect server support with our + // preference order; default to client_secret_basic if the server does + // not advertise the field at all. When the caller declared the + // registration is for a public PKCE client (CLI flow), force + // token_endpoint_auth_method=none while still enforcing the S256 + // PKCE gate. + authMethod, err := selectTokenEndpointAuthMethod( + endpoints.tokenEndpointAuthMethodsSupported, + endpoints.codeChallengeMethodsSupported, + req.PublicClient, + ) + if err != nil { + return nil, newDCRStepError(dcrStepSelectAuthMethod, req.Issuer, redirectURI, err) + } + + registrationScopes := chooseRegistrationScopes(scopes, endpoints.scopesSupported, req.Issuer) + + response, err := performRegistration(ctx, req, endpoints.registrationEndpoint, + redirectURI, authMethod, registrationScopes) + if err != nil { + return nil, newDCRStepError(dcrStepRegister, req.Issuer, redirectURI, err) + } + + resolution := buildResolution(response, endpoints, authMethod, redirectURI) + + // Write to durable storage before returning the resolution so a Put + // failure leaves no in-memory state diverging from the cache: the + // next call simply re-resolves rather than reading a value the cache + // never saw. + if err := cache.Put(ctx, key, resolution); err != nil { + return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI, + fmt.Errorf("cache put: %w", err)) + } + + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("dcr: registered new client", + "local_issuer", req.Issuer, + "redirect_uri", redirectURI, + "client_id", resolution.ClientID, + ) + return resolution, nil +} + +// LogStepError emits the single boundary slog.Error record for a DCR +// resolver failure, carrying the step / issuer / redirect_uri attributes +// extracted from err. If err is not a *dcrStepError, it is logged with a +// generic "unknown" step — ResolveCredentials always wraps its errors, +// so this branch indicates a programming error in a future caller rather +// than a runtime condition. err == nil is a no-op so this function is +// safe to call without an outer guard. +// +// Every wrapped error is passed through SanitizeErrorForLog to strip URL +// query parameters that could plausibly contain sensitive tokens (defense +// in depth — the current DCR flow sends the initial access token as an +// Authorization header, not a query parameter, but nothing in the type +// system prevents a future refactor from doing otherwise). +func LogStepError(upstreamName string, err error) { + if err == nil { + return + } + var stepErr *dcrStepError + if !errors.As(err, &stepErr) { + slog.Error("dcr: resolve failed", + "upstream", upstreamName, + "step", "unknown", + "error", SanitizeErrorForLog(err), + ) + return + } + + attrs := []any{ + "upstream", upstreamName, + "step", stepErr.Step, + "issuer", stepErr.Issuer, + "error", SanitizeErrorForLog(stepErr.Err), + } + if stepErr.RedirectURI != "" { + attrs = append(attrs, "redirect_uri", stepErr.RedirectURI) + } + if stepErr.Stack != "" { + attrs = append(attrs, "stack", stepErr.Stack) + } + slog.Error("dcr: resolve failed", attrs...) +} + +// SanitizeErrorForLog strips secret-bearing components from any URLs +// embedded in err's message. The Go HTTP client, url.Error, and other +// net/* wrappers embed the full request URL — including userinfo, +// query, and fragment — in their error strings. Any of those can carry +// credentials or tokens (e.g. https://user:pass@host, ?token=…, +// implicit-flow callbacks #access_token=…); the current DCR flow does +// not embed any of them today, but stripping them here is defense in +// depth that protects the log regardless of future changes. +// +// Scheme, host, and path are preserved so operators retain enough +// context to correlate with upstream server logs. Trailing sentence +// punctuation adjacent to the URL (e.g. the comma in "reaching +// URL?q=1, retrying") is preserved — see trimURLTrailingPunctuation +// for the list of characters considered terminators. +// +// Scope: the regex matches http://, https://, redis://, and rediss:// +// schemes (case-insensitively per RFC 3986 §3.1). The redis schemes are +// included because the embedded-authserver DCR path persists through +// pkg/authserver/storage/redis.go and a redis-go error chain on the +// Get/Put critical path can embed a sentinel/cluster URL with +// credentials. Other schemes (file://, postgres://, smtp://, raw +// host:port) are NOT sanitised; if a future call site flows error +// strings from one of those into LogStepError, extend the regex here +// rather than re-implementing the sanitiser at the call site. +// +// IMPORTANT — caller responsibility: this function strips credentials +// only from the schemes listed above. Callers that may receive errors +// containing other credential-bearing URL schemes MUST verify those +// URLs are not credential-bearing before logging, or sanitise them +// separately. +func SanitizeErrorForLog(err error) string { + if err == nil { + return "" + } + msg := err.Error() + return queryStrippingPattern.ReplaceAllStringFunc(msg, func(match string) string { + // Split any trailing sentence punctuation off the match before + // handing it to url.Parse. Without this, a period / comma / + // closing bracket at the end of the sentence is absorbed into + // the URL's raw query and dropped along with the rest of the + // query component, mangling the error text. The trimmed + // punctuation is re-appended to the replacement so the + // surrounding prose is preserved verbatim. + core, tail := trimURLTrailingPunctuation(match) + u, parseErr := url.Parse(core) + if parseErr != nil { + return match + } + if u.User == nil && u.RawQuery == "" && u.Fragment == "" { + return match + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() + tail + }) +} + +// trimURLTrailingPunctuation returns (core, tail) where tail is the run of +// trailing ASCII punctuation that commonly terminates a URL inside prose +// but is never a meaningful part of the URL itself. The characters chosen +// here mirror those used by general-purpose URL extractors (e.g., +// Chromium's autolinker): sentence-ending punctuation, closing brackets, +// and a few separators that appear between URLs in freeform text. +// +// Note that ')' and ']' are stripped unconditionally — a URL legitimately +// containing a percent-encoded closing bracket will have it as "%29" or +// "%5D", not as a literal, so this cannot truncate a real URL path or +// query. The reverse case (an unescaped ')' inside a path) is +// non-conforming per RFC 3986 and out of scope for a log sanitiser. +func trimURLTrailingPunctuation(s string) (core, tail string) { + // terminators is intentionally ASCII-only; Unicode terminators (e.g. + // '」') are out of scope for this log sanitiser, so byte indexing is + // safe and avoids the rune-decoding overhead of strings.ContainsRune. + const terminators = ".,;:!?)]}>" + i := len(s) + for i > 0 && strings.IndexByte(terminators, s[i-1]) >= 0 { + i-- + } + return s[:i], s[i:] +} + +// queryStrippingPattern matches URL-shaped substrings inside an error +// message — sufficient to reach the url.Parse path in SanitizeErrorForLog +// and let it decide whether a secret-bearing component exists to strip. +// The regexp covers the schemes that actually flow through the DCR +// resolver's error paths today (http/https from upstream calls, redis/ +// rediss from the persistent CredentialStore on the embedded-authserver +// path). It matches schemes case-insensitively per RFC 3986 §3.1 since +// upstream metadata or user input can carry mixed-case schemes. +// Trailing sentence punctuation that the character class happens to +// include (e.g. '.', ',', ')') is stripped by +// trimURLTrailingPunctuation before the match is parsed. +var queryStrippingPattern = regexp.MustCompile(`(?i)(?:https?|rediss?)://[^\s"']+`) + +// ----------------------------------------------------------------------------- +// Private helpers +// ----------------------------------------------------------------------------- + +// validateResolveInputs performs the defensive re-check of resolver +// preconditions. Each consumer is expected to validate its own domain +// types upstream of the resolver; this is the final-line check for an +// entry point that programmatic callers can reach with partially- +// constructed Requests. +func validateResolveInputs( + req *Request, + cache CredentialStore, +) error { + if req == nil { + return fmt.Errorf("dcr: request is required") + } + if req.Issuer == "" { + return fmt.Errorf("dcr: issuer is required") + } + if req.DiscoveryURL == "" && req.RegistrationEndpoint == "" { + return fmt.Errorf("dcr: request must set either discovery_url or registration_endpoint") + } + if req.DiscoveryURL != "" && req.RegistrationEndpoint != "" { + return fmt.Errorf("dcr: discovery_url and registration_endpoint are mutually exclusive") + } + if cache == nil { + return fmt.Errorf("dcr: credential store is required") + } + return nil +} + +// lookupCachedResolution checks the cache and logs the hit. On hit it +// returns (resolution, true, nil). On miss it returns (nil, false, nil). An +// error is returned only on backend failure. +// +// Two distinct staleness signals shape the hit/miss decision and the log: +// +// - Hard expiry (RFC 7591 §3.2.1 client_secret_expires_at): when the +// cached resolution's ClientSecretExpiresAt is non-zero and in the +// past, the entry is treated as a miss so the singleflight body +// (registerAndCache) re-runs the registration and overwrites the stale +// entry via cache.Put. Without this check the cache would serve an +// expired secret indefinitely; the upstream's token endpoint would 401 +// on every use and the resolver would have no signal to refetch. The +// check is skipped when the field is zero, per the RFC 7591 convention +// "0 means the secret does not expire". This is the authoritative +// signal — the upstream said when its credential expires. +// - Soft staleness (now - CreatedAt vs dcrStaleAgeThreshold): the age in +// days is logged on every hit, and if it exceeds the threshold an +// additional slog.Warn is emitted with a remediation hint so operators +// can act on long-lived registrations that may need rotation or +// re-registration. This is observability only, not a cache-invalidation +// trigger. +func lookupCachedResolution( + ctx context.Context, + cache CredentialStore, + key Key, + localIssuer, redirectURI string, +) (*Resolution, bool, error) { + cached, ok, err := cache.Get(ctx, key) + if err != nil { + return nil, false, fmt.Errorf("dcr: cache lookup: %w", err) + } + if !ok { + return nil, false, nil + } + if !cached.ClientSecretExpiresAt.IsZero() && time.Now().After(cached.ClientSecretExpiresAt) { + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("dcr: cache hit ignored; cached secret expired per upstream client_secret_expires_at", + "local_issuer", localIssuer, + "redirect_uri", redirectURI, + "client_id", cached.ClientID, + "client_secret_expires_at", cached.ClientSecretExpiresAt.UTC().Format(time.RFC3339), + ) + return nil, false, nil + } + + age := time.Since(cached.CreatedAt) + ageDays := int(age / (24 * time.Hour)) + + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("dcr: cache hit", + "local_issuer", localIssuer, + "redirect_uri", redirectURI, + "client_id", cached.ClientID, + "dcr_age_days", ageDays, + ) + + if age > dcrStaleAgeThreshold { + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Warn( + "dcr: cached registration exceeds staleness threshold; "+ + "consider rotating the registration via RFC 7592 deregistration "+ + "and re-registering at next startup", + "local_issuer", localIssuer, + "redirect_uri", redirectURI, + "client_id", cached.ClientID, + "dcr_age_days", ageDays, + "stale_threshold_days", int(dcrStaleAgeThreshold/(24*time.Hour)), + ) + } + + return cached, true, nil +} + +// applyExplicitEndpointOverrides overwrites the discovered +// authorizationEndpoint / tokenEndpoint in endpoints with explicit values +// from req when req specifies them. Explicit caller configuration always +// wins over discovery. +// +// Both overrides run through validateUpstreamEndpointURL even though the +// known consumer call sites only ever populate these fields from +// already-validated sources. validateUpstreamEndpointURL is the documented +// gatekeeper "every point where an endpoint URL enters the resolver from +// outside" — applying it here closes the override path's gap rather than +// relying on the caller-side validation to never drift. +func applyExplicitEndpointOverrides(endpoints *dcrEndpoints, req *Request) error { + if req.AuthorizationEndpoint != "" { + if err := validateUpstreamEndpointURL(req.AuthorizationEndpoint, "authorization_endpoint"); err != nil { + return fmt.Errorf("explicit %w", err) + } + endpoints.authorizationEndpoint = req.AuthorizationEndpoint + } + if req.TokenEndpoint != "" { + if err := validateUpstreamEndpointURL(req.TokenEndpoint, "token_endpoint"); err != nil { + return fmt.Errorf("explicit %w", err) + } + endpoints.tokenEndpoint = req.TokenEndpoint + } + return nil +} + +// chooseRegistrationScopes selects the scopes to send in the registration +// request: explicit caller scopes > discovered scopes_supported > empty. +// Logs a warning when neither source produces any scopes. +func chooseRegistrationScopes(explicit, discovered []string, localIssuer string) []string { + if len(explicit) > 0 { + return explicit + } + if len(discovered) > 0 { + return discovered + } + slog.Warn("dcr: no scopes configured or discovered; registering with empty scope", + "local_issuer", localIssuer, + ) + return nil +} + +// performRegistration executes the HTTP registration request exactly once. +// The initial access token (if configured) is injected as a +// bearer-token Authorization header via a wrapping http.Client. +// +// The caller is responsible for resolving any file-or-env initial access +// token reference into req.InitialAccessToken before calling +// ResolveCredentials; the resolver does not touch the filesystem or +// environment. +func performRegistration( + ctx context.Context, + req *Request, + registrationEndpoint, redirectURI, authMethod string, + scopes []string, +) (*oauthproto.DynamicClientRegistrationResponse, error) { + httpClient := newDCRHTTPClient(req.InitialAccessToken) + + clientName := req.ClientName + if clientName == "" { + clientName = oauthproto.ToolHiveMCPClientName + } + + registrationRequest := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{redirectURI}, + ClientName: clientName, + TokenEndpointAuthMethod: authMethod, + GrantTypes: []string{oauthproto.GrantTypeAuthorizationCode, oauthproto.GrantTypeRefreshToken}, + ResponseTypes: []string{oauthproto.ResponseTypeCode}, + Scopes: scopes, + } + + // Call exactly once — no retry loop. If retry/backoff against + // transient upstream failures becomes useful it belongs at a higher + // layer (above ResolveCredentials), so the singleflight and cache + // semantics here stay simple: one attempt per resolution per process. + response, err := oauthproto.RegisterClientDynamically(ctx, registrationEndpoint, registrationRequest, httpClient) + if err != nil { + return nil, fmt.Errorf("dcr: register client: %w", err) + } + return response, nil +} + +// buildResolution assembles the Resolution from the RFC 7591 response and +// the resolved endpoints. If the server did not echo a +// token_endpoint_auth_method in the response, the method actually sent is +// recorded so downstream consumers see a definite value. redirectURI is the +// value passed to the registration endpoint (caller-supplied or defaulted +// via resolveUpstreamRedirectURI); it is persisted on the resolution so +// consumers (pkg/authserver/runner.consumeResolution) can propagate a +// defaulted value back to the run-config. +// +// RFC 7591 §3.2.1 client_id_issued_at and client_secret_expires_at are +// converted from int64 epoch seconds to time.Time. The wire value 0 means +// "field absent" or "secret does not expire"; both map to the zero time.Time +// so callers can use IsZero() uniformly. +func buildResolution( + response *oauthproto.DynamicClientRegistrationResponse, + endpoints *dcrEndpoints, + sentAuthMethod string, + redirectURI string, +) *Resolution { + authMethod := response.TokenEndpointAuthMethod + if authMethod == "" { + authMethod = sentAuthMethod + } + return &Resolution{ + ClientID: response.ClientID, + ClientSecret: response.ClientSecret, + AuthorizationEndpoint: endpoints.authorizationEndpoint, + TokenEndpoint: endpoints.tokenEndpoint, + RegistrationAccessToken: response.RegistrationAccessToken, + RegistrationClientURI: response.RegistrationClientURI, + TokenEndpointAuthMethod: authMethod, + RedirectURI: redirectURI, + ClientIDIssuedAt: epochSecondsToTime(response.ClientIDIssuedAt), + ClientSecretExpiresAt: epochSecondsToTime(response.ClientSecretExpiresAt), + CreatedAt: time.Now(), + } +} + +// epochSecondsToTime converts the int64 epoch-seconds form used by RFC 7591 +// into a time.Time. Zero passes through to the zero time.Time so callers can +// rely on IsZero() to mean "field absent" / "does not expire". +func epochSecondsToTime(epoch int64) time.Time { + if epoch == 0 { + return time.Time{} + } + return time.Unix(epoch, 0).UTC() +} + +// dcrEndpoints is the internal bundle of endpoints produced by endpoint +// resolution. The separation from Resolution lets the resolver reason +// about discovered vs. overridden values before committing to a resolution. +type dcrEndpoints struct { + authorizationEndpoint string + tokenEndpoint string + registrationEndpoint string + tokenEndpointAuthMethodsSupported []string + scopesSupported []string + // codeChallengeMethodsSupported is consumed by + // selectTokenEndpointAuthMethod to gate the public-client (none) auth + // method on S256 PKCE being advertised. RFC 7636 / OAuth 2.1 require + // PKCE-with-S256 for public clients; registering as none against an + // upstream that advertises only plain (or omits the field) would be a + // compliance gap. + codeChallengeMethodsSupported []string +} + +// resolveDCREndpoints produces the endpoint bundle from req. +// +// Two branches, in priority order: +// +// 1. req.RegistrationEndpoint set — use it directly and skip discovery +// entirely. Server-capability fields (token_endpoint_auth_methods_supported, +// scopes_supported) are unavailable on this branch; the caller is +// expected to also supply AuthorizationEndpoint, TokenEndpoint, and an +// explicit Scopes list. Auth method falls back to the +// selectTokenEndpointAuthMethod default. +// 2. req.DiscoveryURL set — fetch the exact document the operator +// configured (bypassing the well-known path fallback). RFC 8414 §3.3 +// requires the metadata's "issuer" field to match the authorization +// server's issuer identifier; that identifier is the upstream's, not +// this caller's, so it is recovered from the discovery URL via +// deriveExpectedIssuerFromDiscoveryURL rather than reusing +// req.Issuer (which names this caller and is used elsewhere in +// ResolveCredentials for redirect URI defaulting and cache keying). +// +// validateResolveInputs enforces that exactly one of the two is set, so +// this function does not re-check the both-empty case. +// +// When metadata is returned but omits registration_endpoint, the resolver +// synthesises {origin}/register — a convention used by nanobot and Hydra +// for providers that ship DCR without advertising it in discovery. Origin +// is taken from the upstream issuer, not this caller's issuer, so the +// synthesised endpoint lands at the upstream. +func resolveDCREndpoints( + ctx context.Context, + req *Request, +) (*dcrEndpoints, error) { + if req.RegistrationEndpoint != "" { + // Validate locally so a non-HTTPS or malformed URL fails before + // performRegistration constructs a bearer-token transport for it. + if err := validateUpstreamEndpointURL(req.RegistrationEndpoint, "registration_endpoint"); err != nil { + return nil, fmt.Errorf("dcr: %w", err) + } + return &dcrEndpoints{ + registrationEndpoint: req.RegistrationEndpoint, + codeChallengeMethodsSupported: req.CodeChallengeMethodsSupported, + }, nil + } + + upstreamIssuer, err := deriveExpectedIssuerFromDiscoveryURL(req.DiscoveryURL) + if err != nil { + return nil, err + } + + metadata, err := oauthproto.FetchAuthorizationServerMetadataFromURL(ctx, req.DiscoveryURL, upstreamIssuer, nil) + return endpointsFromMetadata(metadata, err, upstreamIssuer) +} + +// deriveExpectedIssuerFromDiscoveryURL recovers the issuer identifier the +// upstream is expected to claim in its RFC 8414 / OIDC Discovery document, +// given an operator-configured DiscoveryURL. +// +// Three recognised conventions: +// +// 1. Well-known suffix: the URL ends with /.well-known/oauth-authorization-server +// or /.well-known/openid-configuration. The suffix is stripped to recover +// the issuer; this covers single-tenant providers (e.g. +// https://mcp.atlassian.com/.well-known/oauth-authorization-server → +// https://mcp.atlassian.com) and the OIDC-style suffix-append shape used +// by some multi-tenant providers (e.g. +// https://idp.example.com/tenants/acme/.well-known/openid-configuration +// → https://idp.example.com/tenants/acme). +// 2. RFC 8414 §3.1 path-insertion: the well-known path is inserted BETWEEN +// the host and the issuer's tenant path. Per RFC 8414 §3 / RFC 8615, +// this is the canonical form for issuers with a path component, e.g. +// issuer https://example.com/v1/mcp → +// discovery URL https://example.com/.well-known/oauth-authorization-server/v1/mcp. +// The tenant suffix that appears AFTER the well-known segment is +// re-attached to the origin to recover the issuer. +// 3. Non-well-known path: the URL points at a custom metadata endpoint that +// contains neither suffix in a recognisable position. Origin +// (scheme://host) is used as a best-effort fallback; this matches the +// common shape where the upstream issuer is the host root. +// +// Case (1) and case (2) are disambiguated by where the well-known segment +// sits in the path: at the end ⇒ suffix-append, immediately after the host +// with more path following ⇒ path-insertion. +func deriveExpectedIssuerFromDiscoveryURL(discoveryURL string) (string, error) { + const ( + oauthSuffix = "/.well-known/oauth-authorization-server" + oidcSuffix = "/.well-known/openid-configuration" + ) + + u, err := url.Parse(discoveryURL) + if err != nil { + return "", fmt.Errorf("parse discovery url %q: %w", discoveryURL, err) + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("discovery url missing scheme or host: %q", discoveryURL) + } + + switch { + // Suffix-append form (case 1): well-known segment at end of path. + case strings.HasSuffix(u.Path, oauthSuffix): + u.Path = strings.TrimSuffix(u.Path, oauthSuffix) + case strings.HasSuffix(u.Path, oidcSuffix): + u.Path = strings.TrimSuffix(u.Path, oidcSuffix) + // RFC 8414 §3.1 path-insertion form (case 2): well-known segment at the + // start of the path with tenant path following. Strip just the well-known + // segment to recover {origin}{tenant-path}. + // + // Two shapes hit this branch: + // 1. A real tenant suffix follows the well-known segment, e.g. + // /.well-known/oauth-authorization-server/v1/mcp → + // issuer https://host/v1/mcp. + // 2. A trailing slash with no tenant, e.g. + // /.well-known/oauth-authorization-server/ — TrimPrefix leaves + // a stray "/", which would yield a spurious issuer + // "https://host/" that fails the §3.3 byte-equality check + // against the upstream's declared "https://host". Normalise + // that stray "/" back to empty so case (2.2) and the bare + // suffix case derive the same origin issuer. + case strings.HasPrefix(u.Path, oauthSuffix+"/"): + u.Path = strings.TrimPrefix(u.Path, oauthSuffix) + if u.Path == "/" { + u.Path = "" + } + case strings.HasPrefix(u.Path, oidcSuffix+"/"): + u.Path = strings.TrimPrefix(u.Path, oidcSuffix) + if u.Path == "/" { + u.Path = "" + } + default: + // Custom (non-well-known) discovery URL — fall back to origin. + u.Path = "" + } + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +// endpointsFromMetadata converts a FetchAuthorizationServerMetadata* result +// into a dcrEndpoints bundle. Handles the ErrRegistrationEndpointMissing +// sentinel by synthesising {origin}/register. +// +// authorization_endpoint and token_endpoint are validated for HTTPS / well- +// formedness before being copied into the bundle. A self-consistent metadata +// document — possible if TLS to the metadata host is compromised, or if the +// upstream is misconfigured — could otherwise plant http:// URLs that flow +// through to the authorization-code and token-exchange call paths. +// +// Contract with oauthproto: FetchAuthorizationServerMetadata* guarantees a +// non-nil *AuthorizationServerMetadata whenever fetchErr is nil OR fetchErr +// is ErrRegistrationEndpointMissing (in the latter case the metadata is +// otherwise valid; only registration_endpoint is missing). The defensive +// nil guard below catches a future cross-package contract regression — e.g., +// a new oauthproto sentinel that returns nil metadata alongside a non-fatal +// error — and converts it into a clean validation error rather than a +// nil-pointer dereference at the field accesses. +func endpointsFromMetadata( + metadata *oauthproto.AuthorizationServerMetadata, + fetchErr error, + upstreamIssuer string, +) (*dcrEndpoints, error) { + if fetchErr != nil && !errors.Is(fetchErr, oauthproto.ErrRegistrationEndpointMissing) { + return nil, fmt.Errorf("discover authorization server metadata: %w", fetchErr) + } + if metadata == nil { + return nil, fmt.Errorf( + "dcr: authorization server metadata is nil (oauthproto contract " + + "violation: nil metadata returned alongside a non-fatal fetch error)") + } + + if err := validateUpstreamEndpointURL(metadata.AuthorizationEndpoint, "authorization_endpoint"); err != nil { + return nil, fmt.Errorf("dcr: discovered %w", err) + } + if err := validateUpstreamEndpointURL(metadata.TokenEndpoint, "token_endpoint"); err != nil { + return nil, fmt.Errorf("dcr: discovered %w", err) + } + + registrationEndpoint := metadata.RegistrationEndpoint + if errors.Is(fetchErr, oauthproto.ErrRegistrationEndpointMissing) { + // Metadata is otherwise valid — synthesise the registration + // endpoint from the upstream issuer's origin. + // FetchAuthorizationServerMetadata* deliberately returns + // ErrRegistrationEndpointMissing alongside a non-nil metadata + // document, so we still use the returned endpoints/scopes. + synth, err := synthesiseRegistrationEndpoint(upstreamIssuer) + if err != nil { + return nil, fmt.Errorf("synthesise registration endpoint: %w", err) + } + registrationEndpoint = synth + } + + return &dcrEndpoints{ + authorizationEndpoint: metadata.AuthorizationEndpoint, + tokenEndpoint: metadata.TokenEndpoint, + registrationEndpoint: registrationEndpoint, + tokenEndpointAuthMethodsSupported: metadata.TokenEndpointAuthMethodsSupported, + scopesSupported: metadata.ScopesSupported, + codeChallengeMethodsSupported: metadata.CodeChallengeMethodsSupported, + }, nil +} + +// synthesiseRegistrationEndpoint builds {upstreamIssuer}/register, used when +// discovery succeeds but omits registration_endpoint. The argument is the +// upstream's issuer (recovered from the discovery URL), not this auth +// server's local issuer. +// +// The issuer's path is preserved so multi-tenant upstreams that ship DCR +// without advertising it (e.g. https://idp.example.com/tenants/acme) keep +// their tenant prefix in the synthesised URL. Stripping the path would land +// the registration request at a global /register that does not match the +// tenant-aware token/authorize URLs already accepted from metadata. +func synthesiseRegistrationEndpoint(upstreamIssuer string) (string, error) { + u, err := url.Parse(upstreamIssuer) + if err != nil { + return "", fmt.Errorf("parse issuer: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("issuer missing scheme or host: %q", upstreamIssuer) + } + synth := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: strings.TrimRight(u.Path, "/") + "/register", + } + return synth.String(), nil +} + +// resolveUpstreamRedirectURI returns the redirect URI to present to the +// upstream. The caller-supplied value wins; otherwise a default is derived +// from {localIssuer}/oauth/callback. HTTPS is required except for loopback +// hosts (development). +// +// localIssuer here is *this* auth server's issuer — the redirect URI is +// where the upstream sends the user back to us, so it must live on our +// origin, not the upstream's. +// +// The issuer's path is preserved when defaulting: an issuer with a tenant +// prefix produces a redirect URI under that prefix, not at the host root. +// url.URL.ResolveReference would replace the path entirely because +// defaultUpstreamRedirectPath starts with "/", so we explicitly concatenate +// instead. +func resolveUpstreamRedirectURI(configured, localIssuer string) (string, error) { + if configured != "" { + u, err := url.Parse(configured) + if err != nil { + return "", fmt.Errorf("invalid redirect uri %q: %w", configured, err) + } + if err := validateRedirectURL(u); err != nil { + return "", err + } + return configured, nil + } + + issuerURL, err := url.Parse(localIssuer) + if err != nil { + return "", fmt.Errorf("invalid issuer %q: %w", localIssuer, err) + } + resolved := &url.URL{ + Scheme: issuerURL.Scheme, + Host: issuerURL.Host, + Path: strings.TrimRight(issuerURL.Path, "/") + defaultUpstreamRedirectPath, + } + if err := validateRedirectURL(resolved); err != nil { + return "", err + } + return resolved.String(), nil +} + +// validateRedirectURL enforces the HTTPS-except-loopback rule shared across +// OAuth URLs. +func validateRedirectURL(u *url.URL) error { + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("redirect uri missing scheme or host: %q", u.String()) + } + if u.Scheme != "https" && !networking.IsLocalhost(u.Host) { + return fmt.Errorf("redirect uri must use https (got %q) unless host is loopback", u.Scheme) + } + return nil +} + +// validateUpstreamEndpointURL enforces well-formedness and the +// HTTPS-except-loopback rule for an upstream-supplied OAuth endpoint URL. +// +// Used at every point where an endpoint URL enters the resolver from outside +// — operator-configured RegistrationEndpoint, or authorization_endpoint / +// token_endpoint copied out of an upstream's metadata document. The +// downstream oauthproto.validateRegistrationEndpoint enforces HTTPS for the +// registration URL too, but only after a bearer-token transport has already +// been constructed, so a local fail-fast check keeps the +// "no bearer-token transport for a non-HTTPS endpoint" invariant local. +// +// label is included in the error message ("registration_endpoint", +// "authorization_endpoint", "token_endpoint", …) so failures can be tied +// back to the specific field without an additional wrapper. +func validateUpstreamEndpointURL(rawURL, label string) error { + if rawURL == "" { + return fmt.Errorf("%s is required", label) + } + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("%s %q is not a valid URL: %w", label, rawURL, err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("%s %q missing scheme or host", label, rawURL) + } + if u.Scheme != "https" && !networking.IsLocalhost(u.Host) { + return fmt.Errorf("%s %q must use https unless host is loopback (got scheme %q)", + label, rawURL, u.Scheme) + } + return nil +} + +// selectTokenEndpointAuthMethod returns the preferred token endpoint auth +// method given the server's advertised set, intersected with our preference +// order. When the server does not advertise any methods the caller's default +// of client_secret_basic is used (RFC 6749 §2.3.1 baseline) — unless +// publicClient is true, in which case the absence of advertised methods is +// not enough to safely register as a public client and the function returns +// an error (the S256 PKCE gate cannot be evaluated without +// code_challenge_methods_supported). +// +// PKCE coupling for "none": the public-client method "none" is selected only +// when the upstream also advertises S256 in code_challenge_methods_supported. +// RFC 7636 §4.2 / OAuth 2.1 require S256 PKCE for public clients; registering +// as none against an upstream that advertises only "plain" — or omits the +// field entirely — would be a compliance gap. When S256 is missing, "none" +// is skipped (the iteration continues to the next less-preferred method), +// and if no other method is mutually supported the function returns an error +// so the operator sees a clear failure at boot rather than a silent +// downgrade at runtime. +// +// publicClient: when true (CLI flow), the function returns "none" if and +// only if S256 PKCE is advertised. Any other outcome is an error — the +// caller has declared intent and the resolver must not silently switch to +// a confidential-client method. +func selectTokenEndpointAuthMethod(serverSupported, codeChallengeMethodsSupported []string, publicClient bool) (string, error) { + pkceS256Advertised := slices.Contains(codeChallengeMethodsSupported, oauthproto.PKCEMethodS256) + + if publicClient { + if !pkceS256Advertised { + return "", fmt.Errorf( + "public client requested but upstream does not advertise S256 in "+ + "code_challenge_methods_supported (got %v); refusing to register a "+ + "public client without S256 PKCE per RFC 7636 / OAuth 2.1", + codeChallengeMethodsSupported) + } + // When serverSupported is non-empty, require that it includes "none" + // so we don't claim a method the AS would reject. + if len(serverSupported) > 0 { + if !slices.Contains(serverSupported, "none") { + return "", fmt.Errorf( + "public client requested but upstream does not advertise "+ + "token_endpoint_auth_method=none (got %v); the CLI flow is a "+ + "public PKCE client and cannot register with a confidential method", + serverSupported) + } + } + return "none", nil + } + + if len(serverSupported) == 0 { + return "client_secret_basic", nil + } + + supported := make(map[string]struct{}, len(serverSupported)) + for _, m := range serverSupported { + supported[m] = struct{}{} + } + + for _, m := range authMethodPreference { + if _, ok := supported[m]; !ok { + continue + } + if m == "none" && !pkceS256Advertised { + // Public-client registration without S256 PKCE is non-compliant + // per RFC 7636 / OAuth 2.1. Try the next less-preferred method. + continue + } + return m, nil + } + if _, noneOnly := supported["none"]; noneOnly && !pkceS256Advertised { + return "", fmt.Errorf( + "upstream advertises only token_endpoint_auth_method=none but does not advertise "+ + "S256 in code_challenge_methods_supported (got %v); refusing to register a public "+ + "client without S256 PKCE per RFC 7636 / OAuth 2.1", codeChallengeMethodsSupported) + } + return "", fmt.Errorf( + "no supported token_endpoint_auth_method in server advertisement %v; "+ + "client supports %v", serverSupported, authMethodPreference) +} + +// bearerTokenTransport is an http.RoundTripper that adds +// Authorization: Bearer {token} to each outgoing request. Used to supply the +// RFC 7591 initial access token to oauthproto.RegisterClientDynamically +// without leaking the abstraction up into that package. +// +// The wrapping http.Client (see newDCRHTTPClient) refuses to follow HTTP +// redirects via CheckRedirect, so this transport is only ever invoked for +// the original registration request — never for a redirected request whose +// URL the upstream chose. That is what prevents this token from being +// forwarded to a foreign origin. +type bearerTokenTransport struct { + token string + next http.RoundTripper +} + +// RoundTrip implements http.RoundTripper. +func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone per http.RoundTripper contract: implementations must not modify + // the input request's headers. + cp := req.Clone(req.Context()) + cp.Header.Set("Authorization", "Bearer "+t.token) + return t.next.RoundTrip(cp) +} + +// errDCRRedirectRefused is returned when a DCR registration endpoint +// responds with a 30x. Net/http surfaces it via *url.Error so callers +// observe a clear failure mode instead of a confusing JSON decode error. +var errDCRRedirectRefused = errors.New( + "dcr: registration endpoint returned a redirect; refusing to follow " + + "to avoid forwarding the RFC 7591 initial access token to a foreign origin") + +// newDCRHTTPClient returns the http.Client to pass to +// oauthproto.RegisterClientDynamically. The client always blocks HTTP +// redirects so that an upstream cannot use a 30x to coerce us into +// re-issuing the registration request (and any attached +// Authorization: Bearer header) against a different origin. RFC 7591 §3 +// does not require redirect support, so refusing them is safe. +// +// When initialAccessToken is non-empty the client also wraps the canonical +// DCR client's transport with a bearerTokenTransport that injects the +// Authorization header. The combination of the bearer transport plus the +// redirect block is what prevents the token-leak class of bug. +// +// The timeout policy is sourced from oauthproto.NewDefaultDCRClient so +// future tightening of those bounds propagates automatically. +func newDCRHTTPClient(initialAccessToken string) *http.Client { + client := oauthproto.NewDefaultDCRClient() + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return errDCRRedirectRefused + } + + if initialAccessToken == "" { + return client + } + + next := client.Transport + if next == nil { + next = http.DefaultTransport + } + client.Transport = &bearerTokenTransport{ + token: initialAccessToken, + next: next, + } + return client +} diff --git a/pkg/auth/dcr/resolver_test.go b/pkg/auth/dcr/resolver_test.go new file mode 100644 index 0000000000..2c163e110d --- /dev/null +++ b/pkg/auth/dcr/resolver_test.go @@ -0,0 +1,1800 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dcr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// dcrTestHandlerConfig controls the behaviour of newDCRTestServer. +type dcrTestHandlerConfig struct { + // omitRegistrationEndpoint causes discovery metadata to omit the + // registration_endpoint field, triggering the synthesised /register + // path. + omitRegistrationEndpoint bool + + // registrationEndpointPath overrides the path served as + // registration_endpoint. Defaults to "/register". + registrationEndpointPath string + + // tokenEndpointAuthMethodsSupported is advertised in metadata. + tokenEndpointAuthMethodsSupported []string + + // scopesSupported is advertised in metadata. + scopesSupported []string + + // codeChallengeMethodsSupported is advertised in metadata. Tests that + // exercise public-client (none) registration must include "S256" here, + // since selectTokenEndpointAuthMethod refuses to select none without + // it (RFC 7636 / OAuth 2.1). + codeChallengeMethodsSupported []string + + // observeRegistration is called for each request hitting the + // registration endpoint. Safe for concurrent use. + observeRegistration func(r *http.Request, body []byte) + + // clientIDIssuedAt and clientSecretExpiresAt are echoed back in the + // RFC 7591 §3.2.1 response. Both are int64 epoch seconds; 0 is the wire + // convention for "field absent" and (for ClientSecretExpiresAt) "secret + // does not expire". + clientIDIssuedAt int64 + clientSecretExpiresAt int64 +} + +// newDCRTestServer mounts RFC 8414 metadata and a DCR endpoint on a single +// httptest.NewServer. The returned server's URL is the issuer; callers must +// t.Cleanup(server.Close) (not defer, when using t.Parallel()). +func newDCRTestServer(t *testing.T, cfg dcrTestHandlerConfig) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + var server *httptest.Server + + registrationPath := cfg.registrationEndpointPath + if registrationPath == "" { + registrationPath = "/register" + } + + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + JWKSURI: server.URL + "/jwks", + TokenEndpointAuthMethodsSupported: cfg.tokenEndpointAuthMethodsSupported, + ScopesSupported: cfg.scopesSupported, + CodeChallengeMethodsSupported: cfg.codeChallengeMethodsSupported, + } + if !cfg.omitRegistrationEndpoint { + md.RegistrationEndpoint = server.URL + registrationPath + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + mux.HandleFunc(registrationPath, func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = r.Body.Close() + if cfg.observeRegistration != nil { + cfg.observeRegistration(r, body) + } + // Decode the request to echo the auth method back in the response. + var req oauthproto.DynamicClientRegistrationRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + RegistrationAccessToken: "test-reg-token", + RegistrationClientURI: server.URL + "/register/test-client-id", + TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, + ClientIDIssuedAt: cfg.clientIDIssuedAt, + ClientSecretExpiresAt: cfg.clientSecretExpiresAt, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func TestResolveDCRCredentials_CacheHitShortCircuits(t *testing.T) { + t.Parallel() + + // Count every request to every path — discovery, registration, + // anything. The acceptance criterion is that a cache hit issues zero + // network I/O, so the cache-hit path must never reach this server. + var totalRequests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&totalRequests, 1) + w.WriteHeader(http.StatusTeapot) + })) + t.Cleanup(server.Close) + + cache := newMemoryDCRStore(t) + issuer := server.URL + + // Pre-populate the cache with a resolution matching the key we will + // look up. + redirectURI := issuer + "/oauth/callback" + key := Key{ + Issuer: issuer, + RedirectURI: redirectURI, + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + preloaded := &Resolution{ + ClientID: "preloaded-id", + ClientSecret: "preloaded-secret", + AuthorizationEndpoint: "https://preloaded/authorize", + TokenEndpoint: "https://preloaded/token", + } + require.NoError(t, cache.Put(context.Background(), key, preloaded)) + + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid", "profile"}, + DiscoveryURL: issuer + "/.well-known/openid-configuration", + } + + got, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "preloaded-id", got.ClientID) + assert.Equal(t, "preloaded-secret", got.ClientSecret) + assert.Equal(t, int32(0), atomic.LoadInt32(&totalRequests), + "cache hit must not issue any network I/O (discovery or registration)") +} + +func TestResolveDCRCredentials_RegistersOnCacheMiss(t *testing.T) { + t.Parallel() + + var gotAuthHeader string + var gotBody []byte + server := newDCRTestServer(t, dcrTestHandlerConfig{ + tokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, + scopesSupported: []string{"openid", "profile"}, + observeRegistration: func(r *http.Request, body []byte) { + gotAuthHeader = r.Header.Get("Authorization") + gotBody = body + }, + }) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid", "profile"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "test-client-id", res.ClientID) + assert.Equal(t, "test-client-secret", res.ClientSecret) + assert.Equal(t, "test-reg-token", res.RegistrationAccessToken) + assert.Equal(t, issuer+"/register/test-client-id", res.RegistrationClientURI) + assert.Equal(t, issuer+"/authorize", res.AuthorizationEndpoint) + assert.Equal(t, issuer+"/token", res.TokenEndpoint) + assert.Equal(t, "client_secret_basic", res.TokenEndpointAuthMethod) + assert.False(t, res.CreatedAt.IsZero(), "CreatedAt should be populated") + // No initial access token configured -> no Authorization header. + assert.Empty(t, gotAuthHeader) + + // Verify the request body carried the expected fields. + var dcrReq oauthproto.DynamicClientRegistrationRequest + require.NoError(t, json.Unmarshal(gotBody, &dcrReq)) + assert.Equal(t, []string{issuer + "/oauth/callback"}, dcrReq.RedirectURIs) + assert.ElementsMatch(t, []string{"openid", "profile"}, []string(dcrReq.Scopes)) + + // Cache was populated. + cached, ok, err := cache.Get(context.Background(), + Key{Issuer: issuer, RedirectURI: issuer + "/oauth/callback", ScopesHash: storage.ScopesHash([]string{"openid", "profile"})}) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "test-client-id", cached.ClientID) +} + +func TestResolveDCRCredentials_ExplicitEndpointsOverride(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{}) + cache := newMemoryDCRStore(t) + issuer := server.URL + + req := &Request{ + Issuer: issuer, + AuthorizationEndpoint: "https://explicit.example.com/authorize", + TokenEndpoint: "https://explicit.example.com/token", + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "https://explicit.example.com/authorize", res.AuthorizationEndpoint) + assert.Equal(t, "https://explicit.example.com/token", res.TokenEndpoint) +} + +func TestResolveDCRCredentials_InitialAccessTokenAsBearer(t *testing.T) { + t.Parallel() + + var gotAuthHeader string + server := newDCRTestServer(t, dcrTestHandlerConfig{ + observeRegistration: func(r *http.Request, _ []byte) { + gotAuthHeader = r.Header.Get("Authorization") + }, + }) + + // Use a file-based initial access token so the test can remain parallel + // (t.Setenv and t.Parallel are mutually exclusive). tokenPath is scoped + // to t.TempDir(), so concurrent subtests cannot clobber each other's + // token values even if the test is later subdivided. + tokenPath := filepath.Join(t.TempDir(), "iat") + require.NoError(t, os.WriteFile(tokenPath, []byte("iat-secret-value\n"), 0o600)) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + InitialAccessToken: readTokenFile(t, tokenPath), + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "Bearer iat-secret-value", gotAuthHeader) +} + +// TestResolveDCRCredentials_DoesNotForwardBearerOnRedirect pins the +// security property that an upstream cannot use a 30x redirect from the +// registration endpoint to coerce the resolver into re-issuing the +// registration request — and the attached RFC 7591 initial access token — +// against a different origin. The resolver must refuse the redirect; the +// foreign origin must observe zero traffic. +func TestResolveDCRCredentials_DoesNotForwardBearerOnRedirect(t *testing.T) { + t.Parallel() + + // Foreign origin: a separate httptest server that records every request + // it receives. After the test we assert it received exactly zero + // requests, which proves the bearer token never crossed origins. + var foreignHits int32 + var foreignAuthHeaders []string + var foreignMu sync.Mutex + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignMu.Lock() + atomic.AddInt32(&foreignHits, 1) + foreignAuthHeaders = append(foreignAuthHeaders, r.Header.Get("Authorization")) + foreignMu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(foreign.Close) + + // Upstream: serves discovery normally, but its /register handler 302s + // to the foreign origin. A non-defended client would re-issue the + // registration request (with the Authorization header) against + // foreign.URL/stolen. + mux := http.NewServeMux() + var upstream *httptest.Server + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(oauthproto.AuthorizationServerMetadata{ + Issuer: upstream.URL, + AuthorizationEndpoint: upstream.URL + "/authorize", + TokenEndpoint: upstream.URL + "/token", + JWKSURI: upstream.URL + "/jwks", + RegistrationEndpoint: upstream.URL + "/register", + }) + }) + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, foreign.URL+"/stolen", http.StatusFound) + }) + upstream = httptest.NewServer(mux) + t.Cleanup(upstream.Close) + + tokenPath := filepath.Join(t.TempDir(), "iat") + require.NoError(t, os.WriteFile(tokenPath, []byte("iat-secret-value\n"), 0o600)) + + cache := newMemoryDCRStore(t) + issuer := upstream.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + InitialAccessToken: readTokenFile(t, tokenPath), + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.Error(t, err, "registration must fail when the upstream returns a redirect") + assert.ErrorIs(t, err, errDCRRedirectRefused, + "the resolver must refuse to follow registration-endpoint redirects") + + foreignMu.Lock() + defer foreignMu.Unlock() + assert.EqualValues(t, 0, atomic.LoadInt32(&foreignHits), + "foreign origin must receive zero requests; got %v Authorization headers: %v", + atomic.LoadInt32(&foreignHits), foreignAuthHeaders) +} + +func TestResolveDCRCredentials_AuthMethodPreference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + supported []string + // codeChallenge is the upstream's advertised + // code_challenge_methods_supported. Required by the gating in + // selectTokenEndpointAuthMethod whenever the test expects "none". + codeChallenge []string + expected string + }{ + { + name: "prefers client_secret_basic over none", + supported: []string{"none", "client_secret_basic"}, + expected: "client_secret_basic", + }, + { + name: "prefers private_key_jwt over others", + supported: []string{"client_secret_basic", "private_key_jwt", "none"}, + expected: "private_key_jwt", + }, + { + name: "falls back to none when only none supported and S256 advertised", + supported: []string{"none"}, + codeChallenge: []string{"S256"}, + expected: "none", + }, + { + name: "defaults to client_secret_basic when metadata omits the field", + supported: nil, + expected: "client_secret_basic", + }, + { + name: "prefers client_secret_basic over client_secret_post", + supported: []string{"client_secret_post", "client_secret_basic"}, + expected: "client_secret_basic", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{ + tokenEndpointAuthMethodsSupported: tc.supported, + codeChallengeMethodsSupported: tc.codeChallenge, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, tc.expected, res.TokenEndpointAuthMethod) + }) + } +} + +// TestResolveDCRCredentials_RefusesNoneWithoutS256 pins the compliance gate +// added for the "none" auth method: an upstream that advertises only "none" +// for token_endpoint_auth_methods but does not advertise S256 in +// code_challenge_methods_supported must be rejected at boot rather than +// quietly registering a public client without RFC 7636 / OAuth 2.1 PKCE. +func TestResolveDCRCredentials_RefusesNoneWithoutS256(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + codeChallenge []string + }{ + {name: "code_challenge_methods_supported omitted", codeChallenge: nil}, + {name: "code_challenge_methods_supported lists only plain", codeChallenge: []string{"plain"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{ + tokenEndpointAuthMethodsSupported: []string{"none"}, + codeChallengeMethodsSupported: tc.codeChallenge, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.Error(t, err) + assert.Contains(t, err.Error(), "S256", + "error must mention the missing S256 advertisement so operators can correlate") + assert.Contains(t, err.Error(), "RFC 7636", + "error must cite the spec being enforced") + }) + } +} + +func TestResolveDCRCredentials_EmptyAuthMethodIntersectionErrors(t *testing.T) { + t.Parallel() + + // Configure the server to advertise an unknown method so intersection is empty. + server := newDCRTestServer(t, dcrTestHandlerConfig{ + tokenEndpointAuthMethodsSupported: []string{"tls_client_auth"}, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + _, err := ResolveCredentials(context.Background(), req, cache) + require.Error(t, err) + assert.Contains(t, err.Error(), "no supported token_endpoint_auth_method") +} + +func TestResolveDCRCredentials_SynthesisedRegistrationEndpoint(t *testing.T) { + t.Parallel() + + // registrationEndpointPath="/register" is the synthesised path the + // resolver will construct when metadata omits registration_endpoint. + var gotPath string + server := newDCRTestServer(t, dcrTestHandlerConfig{ + omitRegistrationEndpoint: true, + registrationEndpointPath: "/register", + observeRegistration: func(r *http.Request, _ []byte) { + gotPath = r.URL.Path + }, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "test-client-id", res.ClientID) + assert.Equal(t, "/register", gotPath) +} + +// TestResolveDCRCredentials_CodeChallengeMethodsSupportedFieldEnablesPublicClient +// pins the dcr.Request.CodeChallengeMethodsSupported field on the +// RegistrationEndpoint-direct branch. Without a DiscoveryURL the resolver +// cannot reach the S256 PKCE gate via a metadata fetch; the caller must +// supply code_challenge_methods_supported so the gate has an input. This is +// the path exercised when resolveDCRCredentials pre-fetches AS metadata and +// forwards the field to the resolver (see resolveDCRCredentials in +// pkg/auth/discovery, introduced to fix #5356). +func TestResolveDCRCredentials_CodeChallengeMethodsSupportedFieldEnablesPublicClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + codeChallengeMethodsSupported []string + publicClient bool + wantErr bool + wantErrContains string + }{ + { + name: "S256 in field allows public client registration", + codeChallengeMethodsSupported: []string{"S256"}, + publicClient: true, + }, + { + name: "plain-only in field rejects public client", + codeChallengeMethodsSupported: []string{"plain"}, + publicClient: true, + wantErr: true, + wantErrContains: "S256", + }, + { + name: "field absent rejects public client", + codeChallengeMethodsSupported: nil, + publicClient: true, + wantErr: true, + wantErrContains: "S256", + }, + { + name: "field absent ok for confidential client", + codeChallengeMethodsSupported: nil, + publicClient: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var registrationHits int32 + mux := http.NewServeMux() + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + _ = body + atomic.AddInt32(®istrationHits, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id":"field-supplied-client"}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + cache := newMemoryDCRStore(t) + req := &Request{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid"}, + RegistrationEndpoint: server.URL + "/register", + CodeChallengeMethodsSupported: tc.codeChallengeMethodsSupported, + PublicClient: tc.publicClient, + } + + _, err := ResolveCredentials(context.Background(), req, cache) + if tc.wantErr { + require.Error(t, err) + if tc.wantErrContains != "" { + assert.Contains(t, err.Error(), tc.wantErrContains) + } + assert.EqualValues(t, 0, atomic.LoadInt32(®istrationHits), + "registration endpoint must not be contacted when S256 gate fails") + return + } + require.NoError(t, err) + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationHits)) + }) + } +} + +func TestResolveDCRCredentials_RegistrationEndpointDirectBypassesDiscovery(t *testing.T) { + t.Parallel() + + var registrationHits int32 + var discoveryHits int32 + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(_ http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&discoveryHits, 1) + }) + mux.HandleFunc("/custom/register", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(®istrationHits, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id":"direct-id"}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + Scopes: []string{"openid"}, + RegistrationEndpoint: issuer + "/custom/register", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "direct-id", res.ClientID) + assert.Equal(t, int32(0), atomic.LoadInt32(&discoveryHits), + "discovery endpoint must not be contacted when RegistrationEndpoint is set") + assert.Equal(t, int32(1), atomic.LoadInt32(®istrationHits)) +} + +// TestResolveDCRCredentials_RejectsInvalidInputs covers every branch of +// validateResolveInputs in one place: nil request, empty issuer, neither +// discovery_url nor registration_endpoint set, both set, and nil +// credential store. +func TestResolveDCRCredentials_RejectsInvalidInputs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req *Request + cache CredentialStore + wantErrSub string + }{ + { + name: "nil request", + req: nil, + cache: newMemoryDCRStore(t), + wantErrSub: "request is required", + }, + { + name: "empty issuer", + req: &Request{ + RegistrationEndpoint: "https://example.com/register", + }, + cache: newMemoryDCRStore(t), + wantErrSub: "issuer is required", + }, + { + name: "neither discovery_url nor registration_endpoint set", + req: &Request{ + Issuer: "https://example.com", + }, + cache: newMemoryDCRStore(t), + wantErrSub: "must set either discovery_url or registration_endpoint", + }, + { + name: "both discovery_url and registration_endpoint set", + req: &Request{ + Issuer: "https://example.com", + DiscoveryURL: "https://example.com/.well-known/oauth-authorization-server", + RegistrationEndpoint: "https://example.com/register", + }, + cache: newMemoryDCRStore(t), + wantErrSub: "mutually exclusive", + }, + { + name: "nil cache", + req: &Request{ + Issuer: "https://example.com", + RegistrationEndpoint: "https://example.com/register", + }, + cache: nil, + wantErrSub: "credential store is required", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := ResolveCredentials(context.Background(), tc.req, tc.cache) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSub) + }) + } +} + +func TestResolveUpstreamRedirectURI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configured string + issuer string + expect string + wantErr bool + }{ + { + name: "defaults from issuer", + configured: "", + issuer: "https://idp.example.com", + expect: "https://idp.example.com/oauth/callback", + }, + { + name: "explicit https accepted", + configured: "https://app.example.com/cb", + issuer: "https://idp.example.com", + expect: "https://app.example.com/cb", + }, + { + name: "explicit loopback http accepted", + configured: "http://localhost:8080/cb", + issuer: "https://idp.example.com", + expect: "http://localhost:8080/cb", + }, + { + name: "explicit http non-loopback rejected", + configured: "http://evil.example.com/cb", + issuer: "https://idp.example.com", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := resolveUpstreamRedirectURI(tc.configured, tc.issuer) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expect, got) + }) + } +} + +// TestResolveDCRCredentials_DiscoveryURLHonoured verifies that the resolver +// fetches the operator-configured discovery URL exactly, rather than +// deriving well-known paths from the issuer. This is the behaviour that +// matters for multi-tenant IdPs where the configured URL and the +// issuer-derived paths disagree. +func TestResolveDCRCredentials_DiscoveryURLHonoured(t *testing.T) { + t.Parallel() + + var discoveryPath string + var discoveryHits int32 + var wellKnownHits int32 + mux := http.NewServeMux() + // Mount well-known endpoints as tripwires — they must NOT be contacted + // when DiscoveryURL points elsewhere. + mux.HandleFunc("/.well-known/oauth-authorization-server", func(_ http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&wellKnownHits, 1) + }) + mux.HandleFunc("/.well-known/openid-configuration", func(_ http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&wellKnownHits, 1) + }) + // Mount the operator-configured discovery URL at a tenant-aware path + // that the well-known fallback would never derive from the issuer. + var server *httptest.Server + mux.HandleFunc("/tenants/acme/metadata", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&discoveryHits, 1) + discoveryPath = r.URL.Path + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + JWKSURI: server.URL + "/jwks", + RegistrationEndpoint: server.URL + "/register", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + mux.HandleFunc("/register", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"client_id":"tenant-client"}`)) + }) + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/tenants/acme/metadata", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "tenant-client", res.ClientID) + assert.Equal(t, int32(1), atomic.LoadInt32(&discoveryHits), + "DiscoveryURL must be fetched exactly once") + assert.Equal(t, "/tenants/acme/metadata", discoveryPath, + "resolver must fetch the operator-configured DiscoveryURL, not a derived well-known path") + assert.Equal(t, int32(0), atomic.LoadInt32(&wellKnownHits), + "well-known discovery fallback must NOT be contacted when DiscoveryURL is set") +} + +// TestResolveDCRCredentials_DiscoveryURLIssuerMismatchRejected verifies that +// the resolver enforces RFC 8414 §3.3 issuer equality even when the caller +// pins the discovery URL — a document that advertises a different issuer is +// rejected. +func TestResolveDCRCredentials_DiscoveryURLIssuerMismatchRejected(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/metadata", func(w http.ResponseWriter, _ *http.Request) { + // Advertise a different issuer than the caller's. + md := oauthproto.AuthorizationServerMetadata{ + Issuer: "https://different.example.com", + TokenEndpoint: "https://different.example.com/token", + RegistrationEndpoint: "https://different.example.com/register", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/metadata", + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer mismatch") +} + +// TestResolveDCRCredentials_DiscoveredScopesFallback verifies that when the +// caller leaves rc.Scopes empty, the resolver sends the scopes advertised +// by the upstream in scopes_supported. +func TestResolveDCRCredentials_DiscoveredScopesFallback(t *testing.T) { + t.Parallel() + + var gotBody []byte + server := newDCRTestServer(t, dcrTestHandlerConfig{ + scopesSupported: []string{"openid", "profile", "email"}, + observeRegistration: func(_ *http.Request, body []byte) { + gotBody = body + }, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + // Scopes intentionally left empty so the resolver falls back to + // the discovered scopes_supported. + Issuer: issuer, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + + var dcrReq oauthproto.DynamicClientRegistrationRequest + require.NoError(t, json.Unmarshal(gotBody, &dcrReq)) + assert.ElementsMatch(t, []string{"openid", "profile", "email"}, []string(dcrReq.Scopes), + "registration request must carry the discovered scopes_supported") +} + +// TestResolveDCRCredentials_EmptyScopesOmitted verifies that when neither +// rc.Scopes nor metadata.ScopesSupported provides any scopes, the +// registration succeeds and the request body omits the scope field. +func TestResolveDCRCredentials_EmptyScopesOmitted(t *testing.T) { + t.Parallel() + + var gotBody []byte + server := newDCRTestServer(t, dcrTestHandlerConfig{ + // Neither scopesSupported nor rc.Scopes — the "empty scope" branch. + observeRegistration: func(_ *http.Request, body []byte) { + gotBody = body + }, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + assert.Equal(t, "test-client-id", res.ClientID) + + // The scope field must be omitted (omitempty) rather than sent as an + // empty string — an empty string would violate RFC 7591 §2, and + // ScopeList's MarshalJSON correctly relies on omitempty. + var raw map[string]any + require.NoError(t, json.Unmarshal(gotBody, &raw)) + _, present := raw["scope"] + assert.False(t, present, "registration request must omit the scope field when no scopes are configured") +} + +// TestResolveDCRCredentials_UpstreamIssuerDerivedFromDiscoveryURL verifies +// the production case: the function-param `issuer` (this auth server's +// issuer) differs from the upstream's issuer, and the resolver still +// completes DCR by deriving the upstream's expected issuer from the +// DiscoveryURL itself rather than reusing the caller-supplied issuer for +// RFC 8414 §3.3 verification. +// +// Pre-fix this test would have failed with `issuer mismatch (RFC 8414 §3.3): +// expected "https://our-auth.example", got ""`, because the +// resolver used the caller's issuer as expectedIssuer. +func TestResolveDCRCredentials_UpstreamIssuerDerivedFromDiscoveryURL(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{ + tokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, + }) + cache := newMemoryDCRStore(t) + + // Caller-supplied issuer names this auth server, NOT the upstream. + // Production wiring always passes its own issuer here (see + // embeddedauthserver.go: buildUpstreamConfigs(... cfg.Issuer ...)). + ourIssuer := "https://our-auth.example.com" + + req := &Request{ + Issuer: ourIssuer, + // Explicit redirect URI so the resolver does not try to default + // it from ourIssuer (which would still work, but isolating the + // concern under test keeps the failure mode crisp). + RedirectURI: "https://our-auth.example.com/oauth/callback", + Scopes: []string{"openid"}, + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err, + "resolver must derive expectedIssuer from DiscoveryURL, not from the caller's issuer") + assert.Equal(t, "test-client-id", res.ClientID) + assert.Equal(t, server.URL+"/authorize", res.AuthorizationEndpoint) + assert.Equal(t, server.URL+"/token", res.TokenEndpoint) +} + +func TestDeriveExpectedIssuerFromDiscoveryURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + discoveryURL string + want string + wantErr bool + }{ + { + name: "oauth well-known suffix at host root", + discoveryURL: "https://mcp.atlassian.com/.well-known/oauth-authorization-server", + want: "https://mcp.atlassian.com", + }, + { + name: "oidc well-known suffix at host root", + discoveryURL: "https://accounts.example.com/.well-known/openid-configuration", + want: "https://accounts.example.com", + }, + { + name: "oauth well-known suffix with tenant path prefix", + discoveryURL: "https://idp.example.com/tenants/acme/.well-known/oauth-authorization-server", + want: "https://idp.example.com/tenants/acme", + }, + { + name: "oidc well-known suffix with tenant path prefix", + discoveryURL: "https://idp.example.com/tenants/acme/.well-known/openid-configuration", + want: "https://idp.example.com/tenants/acme", + }, + { + // RFC 8414 §3.1 path-insertion: well-known segment between host + // and tenant path. Issuer reconstructed as origin + tenant path. + // Matches the shape served by Datadog's MCP authorization server + // (mcp.us5.datadoghq.com) and any other provider with a path- + // component issuer that follows the RFC literally. + name: "oauth well-known path-insertion (RFC 8414 §3.1)", + discoveryURL: "https://mcp.us5.datadoghq.com/.well-known/oauth-authorization-server/v1/mcp", + want: "https://mcp.us5.datadoghq.com/v1/mcp", + }, + { + name: "oauth well-known path-insertion with multi-segment tenant", + discoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server/tenants/acme", + want: "https://idp.example.com/tenants/acme", + }, + { + name: "oidc well-known path-insertion", + discoveryURL: "https://idp.example.com/.well-known/openid-configuration/tenants/acme", + want: "https://idp.example.com/tenants/acme", + }, + { + // Trailing-slash edge case: hits the HasPrefix arm (path doesn't end + // at the bare suffix) but has no tenant after it. Without the empty- + // path normalisation, TrimPrefix would leave a stray "/" and produce + // a spurious "https://host/" that fails the RFC 8414 §3.3 byte + // equality check. + name: "oauth well-known with trailing slash normalises to origin", + discoveryURL: "https://mcp.example.com/.well-known/oauth-authorization-server/", + want: "https://mcp.example.com", + }, + { + name: "oidc well-known with trailing slash normalises to origin", + discoveryURL: "https://idp.example.com/.well-known/openid-configuration/", + want: "https://idp.example.com", + }, + { + name: "non-well-known path falls back to origin", + discoveryURL: "https://idp.example.com/tenants/acme/metadata", + want: "https://idp.example.com", + }, + { + name: "query and fragment are stripped", + discoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server?x=1#frag", + want: "https://idp.example.com", + }, + { + name: "empty url is rejected", + discoveryURL: "", + wantErr: true, + }, + { + name: "missing scheme is rejected", + discoveryURL: "idp.example.com/.well-known/oauth-authorization-server", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := deriveExpectedIssuerFromDiscoveryURL(tc.discoveryURL) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// countingStore is a CredentialStore decorator that counts the number of +// Get calls that returned a hit. The singleflight coalescing test uses it +// to assert that no concurrent caller observed a cache hit during the run: +// a hit during the test would mean a goroutine raced past the gate, took +// the cache-lookup short-circuit instead of joining the singleflight, and +// silently weakened the test's coverage. +type countingStore struct { + inner CredentialStore + hits atomic.Int32 +} + +func (c *countingStore) Get(ctx context.Context, key Key) (*Resolution, bool, error) { + res, ok, err := c.inner.Get(ctx, key) + if ok { + c.hits.Add(1) + } + return res, ok, err +} + +func (c *countingStore) Put(ctx context.Context, key Key, res *Resolution) error { + return c.inner.Put(ctx, key, res) +} + +// TestResolveDCRCredentials_SingleflightCoalescesConcurrentCallers pins the +// behaviour that N concurrent callers for the same Key result in exactly +// one RegisterClientDynamically call against the upstream — preventing the +// orphaned-registration class of bug raised in PR #5042 review. +// +// "Exactly one registration" is necessary but not sufficient to prove the +// singleflight coalescing path actually fired: a late-arriving goroutine +// that reached ResolveCredentials after the leader's cache.Put would +// short-circuit through lookupCachedResolution, take the cache hit, and +// still leave registrationCalls == 1. A countingStore wrapper makes that +// regression loud — we assert no caller observed a cache hit, so any timing +// slip fails the test instead of silently weakening coverage. +func TestResolveDCRCredentials_SingleflightCoalescesConcurrentCallers(t *testing.T) { + t.Parallel() + + // gate blocks the registration handler until the test releases it, + // guaranteeing all goroutines pile up at the singleflight before any + // has a chance to finish and populate the cache. + gate := make(chan struct{}) + + var registrationCalls int32 + server := newDCRTestServer(t, dcrTestHandlerConfig{ + observeRegistration: func(_ *http.Request, _ []byte) { + <-gate + atomic.AddInt32(®istrationCalls, 1) + }, + }) + + cache := &countingStore{inner: newMemoryDCRStore(t)} + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid", "profile"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + const N = 8 + results := make([]*Resolution, N) + errs := make([]error, N) + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func(idx int) { + defer wg.Done() + res, err := ResolveCredentials(context.Background(), req, cache) + results[idx] = res + errs[idx] = err + }(i) + } + + // Release the gate so every blocked handler can proceed. Even if Go + // scheduled the leader's handler concurrently with the followers' + // arrival, only the leader actually invokes the handler — the followers + // wait inside singleflight.Do. + // + // 250 ms gives every goroutine slack to reach singleflight.Do under CI + // load before the gate releases. If this still races, the countingStore + // assertion below fails loudly rather than silently weakening coverage. + time.Sleep(250 * time.Millisecond) + close(gate) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent ResolveCredentials goroutines") + } + + for i := 0; i < N; i++ { + require.NoError(t, errs[i], "goroutine %d errored", i) + require.NotNil(t, results[i], "goroutine %d got nil resolution", i) + assert.Equal(t, "test-client-id", results[i].ClientID) + } + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationCalls), + "expected exactly one registration despite %d concurrent callers; got %d", + N, atomic.LoadInt32(®istrationCalls)) + assert.EqualValues(t, 0, cache.hits.Load(), + "no goroutine should have observed a cache hit; if any did, the gate window "+ + "was too short and a late-arriver took the lookupCachedResolution "+ + "short-circuit instead of exercising the singleflight coalescing path") +} + +// TestSynthesiseRegistrationEndpoint_PreservesIssuerPath guards the fix for +// PR #5042 review comment #2: an issuer with a tenant prefix must surface +// in the synthesised registration URL so DCR-on-multi-tenant providers +// register at the correct tenant-aware path. +func TestSynthesiseRegistrationEndpoint_PreservesIssuerPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + issuer string + want string + }{ + { + name: "host-only issuer", + issuer: "https://idp.example.com", + want: "https://idp.example.com/register", + }, + { + name: "trailing slash on host-only issuer is normalised", + issuer: "https://idp.example.com/", + want: "https://idp.example.com/register", + }, + { + name: "tenant prefix preserved", + issuer: "https://idp.example.com/tenants/acme", + want: "https://idp.example.com/tenants/acme/register", + }, + { + name: "tenant prefix with trailing slash normalised", + issuer: "https://idp.example.com/tenants/acme/", + want: "https://idp.example.com/tenants/acme/register", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := synthesiseRegistrationEndpoint(tc.issuer) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestResolveUpstreamRedirectURI_PreservesIssuerPath is the companion to +// TestSynthesiseRegistrationEndpoint_PreservesIssuerPath for the redirect +// URI defaulting path: a tenant-prefixed issuer must not get its path +// stripped when /oauth/callback is appended. +func TestResolveUpstreamRedirectURI_PreservesIssuerPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + issuer string + want string + }{ + { + name: "host-only issuer", + issuer: "https://thv.example.com", + want: "https://thv.example.com/oauth/callback", + }, + { + name: "tenant prefix preserved", + issuer: "https://thv.example.com/tenants/acme", + want: "https://thv.example.com/tenants/acme/oauth/callback", + }, + { + name: "trailing slash normalised", + issuer: "https://thv.example.com/tenants/acme/", + want: "https://thv.example.com/tenants/acme/oauth/callback", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := resolveUpstreamRedirectURI("", tc.issuer) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestResolveDCREndpoints_DirectRegistrationEndpointValidated covers +// PR #5042 review comment #10: the cfg.RegistrationEndpoint short-circuit +// branch validates the URL locally before performRegistration constructs a +// bearer-token transport for it. Non-HTTPS or malformed values must be +// rejected up front, not deep inside oauthproto. +func TestResolveDCREndpoints_DirectRegistrationEndpointValidated(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registrationEndpoint string + wantErrSub string + }{ + { + name: "http non-loopback rejected", + registrationEndpoint: "http://idp.example.com/register", + wantErrSub: "must use https", + }, + { + name: "missing scheme rejected", + registrationEndpoint: "idp.example.com/register", + wantErrSub: "missing scheme or host", + }, + { + name: "loopback http accepted", + registrationEndpoint: "http://127.0.0.1:8080/register", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := &Request{RegistrationEndpoint: tc.registrationEndpoint} + _, err := resolveDCREndpoints(context.Background(), req) + if tc.wantErrSub == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSub) + }) + } +} + +// TestEndpointsFromMetadata_RejectsInsecureDiscoveredEndpoints covers +// PR #5042 review comment #13: a self-consistent metadata document that +// advertises an http:// authorization or token endpoint must be rejected +// rather than silently flowing through to the auth-code/token-exchange +// path. A compromised TLS connection to the metadata host is the threat +// model. +func TestEndpointsFromMetadata_RejectsInsecureDiscoveredEndpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata *oauthproto.AuthorizationServerMetadata + wantErrSub string + }{ + { + name: "http authorization_endpoint rejected", + metadata: &oauthproto.AuthorizationServerMetadata{ + Issuer: "https://idp.example.com", + AuthorizationEndpoint: "http://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + RegistrationEndpoint: "https://idp.example.com/register", + }, + wantErrSub: "authorization_endpoint", + }, + { + name: "http token_endpoint rejected", + metadata: &oauthproto.AuthorizationServerMetadata{ + Issuer: "https://idp.example.com", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "http://idp.example.com/token", + RegistrationEndpoint: "https://idp.example.com/register", + }, + wantErrSub: "token_endpoint", + }, + { + name: "missing authorization_endpoint rejected", + metadata: &oauthproto.AuthorizationServerMetadata{ + Issuer: "https://idp.example.com", + TokenEndpoint: "https://idp.example.com/token", + RegistrationEndpoint: "https://idp.example.com/register", + }, + wantErrSub: "authorization_endpoint is required", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := endpointsFromMetadata(tc.metadata, nil, "https://idp.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSub) + }) + } +} + +// failingDCRStore is a test double whose Get and Put always fail. Used by +// TestResolveDCRCredentials_CacheFailureWraps* below to exercise the wrap +// messages that operators see when the store backend errors at runtime. +type failingDCRStore struct { + getErr error + putErr error +} + +func (f failingDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, error) { + if f.getErr != nil { + return nil, false, f.getErr + } + return nil, false, nil +} + +func (f failingDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error { + return f.putErr +} + +// TestResolveDCRCredentials_CacheGetFailureWrapped covers PR #5042 review +// comment #12 for the cache.Get error path. When the store backend fails +// (e.g. a Redis network error in Phase 3), the resolver wraps the error +// with the operator-debugging contract message "dcr: cache lookup". +func TestResolveDCRCredentials_CacheGetFailureWrapped(t *testing.T) { + t.Parallel() + + storeErr := errors.New("simulated backend failure") + store := failingDCRStore{getErr: storeErr} + + req := &Request{ + Issuer: "https://idp.example.com", + RegistrationEndpoint: "https://idp.example.com/register", + } + + _, err := ResolveCredentials(context.Background(), req, store) + require.Error(t, err) + assert.ErrorIs(t, err, storeErr, + "cache.Get error must be wrapped with %%w so callers can inspect the cause") + assert.Contains(t, err.Error(), dcrStepCacheRead, + "step identifier is part of the operator-debugging contract") + assert.Contains(t, err.Error(), "cache lookup", + "the wrap message is part of the operator-debugging contract") +} + +// TestResolveDCRCredentials_CachePutFailureWrapped covers PR #5042 review +// comment #12 for the cache.Put error path. The path runs after a +// successful registration, so we route the test through a real upstream +// httptest server and only make Put fail. +func TestResolveDCRCredentials_CachePutFailureWrapped(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{}) + + storeErr := errors.New("simulated put backend failure") + store := failingDCRStore{putErr: storeErr} + + req := &Request{ + Issuer: server.URL, + Scopes: []string{"openid"}, + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + } + + _, err := ResolveCredentials(context.Background(), req, store) + require.Error(t, err) + assert.ErrorIs(t, err, storeErr, + "cache.Put error must be wrapped with %%w so callers can inspect the cause") + assert.Contains(t, err.Error(), dcrStepCacheWrite, + "step identifier is part of the operator-debugging contract") + assert.Contains(t, err.Error(), "cache put", + "the wrap message is part of the operator-debugging contract") +} + +// TestBuildResolution_PopulatesRFC7591ExpiryFields covers the conversion of +// the int64 epoch fields client_id_issued_at and client_secret_expires_at +// into time.Time on Resolution. The wire convention "0 means absent / +// does not expire" is preserved as the zero time.Time. +func TestBuildResolution_PopulatesRFC7591ExpiryFields(t *testing.T) { + t.Parallel() + + const ( + issuedEpoch int64 = 1_700_000_000 // 2023-11-14T22:13:20Z + expiresEpoch int64 = 1_800_000_000 // 2027-01-15T08:00:00Z + ) + + tests := []struct { + name string + issuedAt int64 + expiresAt int64 + wantIssuedAt time.Time + wantExpiresAt time.Time + }{ + { + name: "both fields populated", + issuedAt: issuedEpoch, + expiresAt: expiresEpoch, + wantIssuedAt: time.Unix(issuedEpoch, 0).UTC(), + wantExpiresAt: time.Unix(expiresEpoch, 0).UTC(), + }, + { + name: "client_secret_expires_at zero means does-not-expire", + issuedAt: issuedEpoch, + expiresAt: 0, + wantIssuedAt: time.Unix(issuedEpoch, 0).UTC(), + wantExpiresAt: time.Time{}, + }, + { + name: "both fields omitted by upstream", + issuedAt: 0, + expiresAt: 0, + wantIssuedAt: time.Time{}, + wantExpiresAt: time.Time{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + resolution := buildResolution( + &oauthproto.DynamicClientRegistrationResponse{ + ClientID: "id", + ClientSecret: "secret", + ClientIDIssuedAt: tc.issuedAt, + ClientSecretExpiresAt: tc.expiresAt, + }, + &dcrEndpoints{ + authorizationEndpoint: "https://idp.example.com/authorize", + tokenEndpoint: "https://idp.example.com/token", + }, + "client_secret_basic", + "https://idp.example.com/oauth/callback", + ) + assert.Equal(t, tc.wantIssuedAt, resolution.ClientIDIssuedAt) + assert.Equal(t, tc.wantExpiresAt, resolution.ClientSecretExpiresAt) + }) + } +} + +// TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret pins the fix for +// the cache-serves-expired-secrets bug: when an entry's +// ClientSecretExpiresAt has passed, lookupCachedResolution treats it as a +// miss so registerAndCache re-runs and overwrites the stale entry. Without +// this, the cached secret would be served indefinitely past the upstream- +// asserted expiry and every token-endpoint call would 401 with no signal +// back to the resolver. +func TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret(t *testing.T) { + t.Parallel() + + var registrationCalls int32 + server := newDCRTestServer(t, dcrTestHandlerConfig{ + // Issue a secret that expired one minute ago. Every fresh + // registration call will produce an already-expired entry; the + // resolver will refetch on every Resolve as a result. + clientSecretExpiresAt: time.Now().Add(-time.Minute).Unix(), + observeRegistration: func(_ *http.Request, _ []byte) { + atomic.AddInt32(®istrationCalls, 1) + }, + }) + + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + // First call: registers, populates cache with already-expired entry. + res1, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + require.NotNil(t, res1) + require.False(t, res1.ClientSecretExpiresAt.IsZero(), + "upstream advertised an expiry — the resolution must echo it") + require.True(t, time.Now().After(res1.ClientSecretExpiresAt), + "test setup should have produced an already-expired secret") + require.EqualValues(t, 1, atomic.LoadInt32(®istrationCalls)) + + // Second call: the cached entry is expired, so the resolver must refetch. + res2, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + require.NotNil(t, res2) + assert.EqualValues(t, 2, atomic.LoadInt32(®istrationCalls), + "expired cache entry must trigger a re-registration; got %d total calls", + atomic.LoadInt32(®istrationCalls)) +} + +// TestResolveDCRCredentials_HonoursFutureExpiryAndZero pins that +// lookupCachedResolution does NOT refetch when the cached secret is still +// valid — either because the upstream-asserted expiry is in the future, or +// because the upstream omitted client_secret_expires_at (zero ⇒ "does not +// expire" per RFC 7591 §3.2.1). The cache hit path is the hot path and a +// regression here would silently increase upstream load. +func TestResolveDCRCredentials_HonoursFutureExpiryAndZero(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expiresAt int64 + }{ + {name: "future expiry served from cache", expiresAt: time.Now().Add(time.Hour).Unix()}, + {name: "zero (does not expire) served from cache", expiresAt: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var registrationCalls int32 + server := newDCRTestServer(t, dcrTestHandlerConfig{ + clientSecretExpiresAt: tc.expiresAt, + observeRegistration: func(_ *http.Request, _ []byte) { + atomic.AddInt32(®istrationCalls, 1) + }, + }) + cache := newMemoryDCRStore(t) + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + _, err := ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + _, err = ResolveCredentials(context.Background(), req, cache) + require.NoError(t, err) + + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationCalls), + "second call must hit the cache; got %d total registrations", + atomic.LoadInt32(®istrationCalls)) + }) + } +} + +// panickingPutDCRStore is a test double whose Put panics with a fixed +// value. Get is a normal cache miss so callers reach the singleflight +// closure and trigger the panic via cache.Put inside registerAndCache. +type panickingPutDCRStore struct { + panicValue any +} + +func (panickingPutDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, error) { + return nil, false, nil +} + +func (s panickingPutDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error { + panic(s.panicValue) +} + +// TestResolveDCRCredentials_RecoversPanicInsideSingleflight pins the +// behaviour that a panic inside the singleflight closure does not propagate +// up as a panic to either the leader goroutine or any of the followers. +// singleflight.Group re-panics the leader's panic in every follower, so +// without the recover N concurrent callers for the same Key would all +// crash with the same value. The defer/recover converts the panic to a +// *dcrStepError(dcrStepRegister, ..., Stack: ); the boundary +// caller's LogStepError emits the single Error record and every caller +// gets the same wrapped error. +func TestResolveDCRCredentials_RecoversPanicInsideSingleflight(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{}) + store := panickingPutDCRStore{panicValue: "boom"} + + issuer := server.URL + req := &Request{ + Issuer: issuer, + Scopes: []string{"openid"}, + DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", + } + + const N = 6 + var wg sync.WaitGroup + wg.Add(N) + errs := make([]error, N) + panicked := make([]bool, N) + + for i := 0; i < N; i++ { + go func(idx int) { + defer wg.Done() + defer func() { + // If the recover inside the singleflight closure is + // missing, the panic re-propagates here. Capture it so + // the assertion below produces a clear failure message + // rather than a runtime crash that taints other tests. + if r := recover(); r != nil { + panicked[idx] = true + } + }() + _, errs[idx] = ResolveCredentials(context.Background(), req, store) + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent callers") + } + + for i := 0; i < N; i++ { + require.False(t, panicked[i], + "goroutine %d observed an un-recovered panic from the singleflight closure", i) + require.Error(t, errs[i], + "goroutine %d should have received an error converted from the panic", i) + assert.Contains(t, errs[i].Error(), "panicked", + "goroutine %d's error must mention the panic so operators can correlate; got %q", + i, errs[i].Error()) + assert.Contains(t, errs[i].Error(), "boom", + "goroutine %d's error must include the panic value so the cause is recoverable", i) + + // The captured stack and dcrStepRegister tag must travel with the + // returned error so the boundary log (LogStepError) emits a + // single Error record without a duplicate in-defer log. + var stepErr *dcrStepError + require.True(t, errors.As(errs[i], &stepErr), + "goroutine %d's error must wrap a *dcrStepError; got %T", i, errs[i]) + assert.Equal(t, dcrStepRegister, stepErr.Step, + "goroutine %d's wrapped error must carry the dcrStepRegister step", i) + assert.NotEmpty(t, stepErr.Stack, + "goroutine %d's wrapped error must include the captured panic stack", i) + } +} + +func TestDcrStepError(t *testing.T) { + t.Parallel() + + t.Run("Error formats step and cause", func(t *testing.T) { + t.Parallel() + + cause := fmt.Errorf("boom") + e := newDCRStepError(dcrStepRegister, "https://as", "https://app/cb", cause) + assert.Equal(t, "dcr: dcr_call: boom", e.Error()) + }) + + t.Run("Unwrap returns cause", func(t *testing.T) { + t.Parallel() + + cause := fmt.Errorf("inner") + e := newDCRStepError(dcrStepValidate, "", "", cause) + assert.Same(t, cause, errors.Unwrap(e)) + }) + + t.Run("errors.As extracts the typed error", func(t *testing.T) { + t.Parallel() + + cause := fmt.Errorf("inner") + e := newDCRStepError(dcrStepCacheRead, "https://as", "https://app/cb", cause) + wrapped := fmt.Errorf("outer: %w", e) + + var got *dcrStepError + require.True(t, errors.As(wrapped, &got)) + assert.Equal(t, dcrStepCacheRead, got.Step) + assert.Equal(t, "https://as", got.Issuer) + assert.Equal(t, "https://app/cb", got.RedirectURI) + }) + + t.Run("ResolveCredentials wraps every failure in a dcrStepError", func(t *testing.T) { + t.Parallel() + + // Precondition failure → dcrStepValidate. + _, err := ResolveCredentials(context.Background(), nil, newMemoryDCRStore(t)) + require.Error(t, err) + var stepErr *dcrStepError + require.True(t, errors.As(err, &stepErr)) + assert.Equal(t, dcrStepValidate, stepErr.Step) + }) +} + +func TestSanitizeErrorForLog(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in error + expected string + }{ + { + name: "nil error returns empty string", + in: nil, + expected: "", + }, + { + name: "no URL passes through unchanged", + in: fmt.Errorf("something went wrong"), + expected: "something went wrong", + }, + { + name: "URL without query is preserved", + in: fmt.Errorf("GET https://as.example.com/register failed"), + expected: "GET https://as.example.com/register failed", + }, + { + name: "URL query is stripped", + in: fmt.Errorf("GET https://as.example.com/register?token=leak&foo=bar failed"), + expected: "GET https://as.example.com/register failed", + }, + { + name: "multiple URLs — only queries are stripped, hosts and paths remain", + in: fmt.Errorf("from https://a.example/p1?x=1 to https://b.example/p2?y=2"), + expected: "from https://a.example/p1 to https://b.example/p2", + }, + // Trailing punctuation regression cases: the query is stripped + // but the sentence punctuation adjacent to the URL must be + // preserved verbatim. Without trimURLTrailingPunctuation the + // regex's greedy character class would absorb these into the + // raw query and drop them along with it. + { + name: "trailing comma is preserved when query is stripped", + in: fmt.Errorf("error reaching https://as.example.com/register?x=1, retrying."), + expected: "error reaching https://as.example.com/register, retrying.", + }, + { + name: "trailing period is preserved when query is stripped", + in: fmt.Errorf("failed: https://a.example/p?q=1."), + expected: "failed: https://a.example/p.", + }, + { + name: "trailing closing paren is preserved when query is stripped", + in: fmt.Errorf("(see https://ex.com/a?b=1)"), + expected: "(see https://ex.com/a)", + }, + { + name: "mixed trailing punctuation is preserved when query is stripped", + in: fmt.Errorf("at https://ex.com/a?b=1]!"), + expected: "at https://ex.com/a]!", + }, + { + name: "trailing punctuation on URL without query is still preserved", + in: fmt.Errorf("see https://ex.com/a."), + expected: "see https://ex.com/a.", + }, + { + name: "go http client style quoted URL is sanitised", + in: fmt.Errorf(`Get "https://as.example.com/register?token=abc": EOF`), + expected: `Get "https://as.example.com/register": EOF`, + }, + { + name: "embedded userinfo is stripped", + in: fmt.Errorf("connect https://user:pass@host.example/path?q=1 failed"), + expected: "connect https://host.example/path failed", + }, + { + name: "embedded userinfo without query is stripped", + in: fmt.Errorf("connect https://user:pass@host.example/path failed"), + expected: "connect https://host.example/path failed", + }, + { + name: "fragment is stripped", + in: fmt.Errorf("callback https://app.example/cb#access_token=abc failed"), + expected: "callback https://app.example/cb failed", + }, + { + // url.Parse normalises the scheme to lowercase, so the + // post-sanitisation form matches the canonical scheme casing. + name: "uppercase scheme is sanitised", + in: fmt.Errorf("GET HTTPS://as.example.com/register?token=leak failed"), + expected: "GET https://as.example.com/register failed", + }, + // Redis scheme coverage — the embedded-authserver DCR path + // persists through pkg/authserver/storage/redis.go, and a + // redis-go error chain on the Get/Put critical path can embed a + // sentinel/cluster URL with credentials. Without these the + // sanitiser would leave the password in the slog.Error attribute. + { + name: "redis URL userinfo is stripped", + in: fmt.Errorf("dial redis://user:secret@redis.internal:6379/0 failed"), + expected: "dial redis://redis.internal:6379/0 failed", + }, + { + name: "rediss URL userinfo is stripped", + in: fmt.Errorf("dial rediss://user:secret@redis.internal:6379/0 failed"), + expected: "dial rediss://redis.internal:6379/0 failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, SanitizeErrorForLog(tc.in)) + }) + } +} diff --git a/pkg/auth/dcr/store.go b/pkg/auth/dcr/store.go new file mode 100644 index 0000000000..8694e6bbd6 --- /dev/null +++ b/pkg/auth/dcr/store.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dcr + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +// dcrStaleAgeThreshold is the age beyond which a cached DCR resolution is +// considered stale and logged as such by higher-level wiring. The store itself +// does not expire or evict entries — RFC 7591 client registrations are +// long-lived and are only purged by explicit RFC 7592 deregistration. +const dcrStaleAgeThreshold = 90 * 24 * time.Hour + +// Key is a re-export of storage.DCRKey, kept as a package-local alias so +// callers in this package can reference the canonical cache key without an +// explicit storage. qualifier on every call site, while the canonical +// definition lives in pkg/authserver/storage. The canonical form (and its +// ScopesHash constructor) MUST live in a single place so any future Redis +// backend hashes keys identically to the in-memory backend; see +// storage.DCRKey for the field documentation. +type Key = storage.DCRKey + +// CredentialStore caches RFC 7591 Dynamic Client Registration resolutions +// keyed by the (Issuer, RedirectURI, ScopesHash) tuple. Implementations must +// be safe for concurrent use. +// +// This is the runner-facing interface used by the DCR resolver. It is a +// narrow re-projection of storage.DCRCredentialStore that exchanges +// *Resolution values (the resolver's working type) instead of +// *storage.DCRCredentials so the resolver internals stay agnostic to the +// persistence layer's exact field shape. +// +// Implementations in this package are thin adapters around a +// storage.DCRCredentialStore — the durable map / Redis hash lives over +// there, and this interface adds a per-call Resolution <-> DCRCredentials +// translation. There is exactly one persistence implementation per backend: +// storage.MemoryStorage and storage.RedisStorage. See NewStorageBackedStore +// for the adapter. +type CredentialStore interface { + // Get returns the cached resolution for key, or (nil, false, nil) if the + // key is not present. An error is returned only on backend failure. + Get(ctx context.Context, key Key) (*Resolution, bool, error) + + // Put stores the resolution for key, overwriting any existing entry. + // Implementations must reject a nil resolution with an error rather + // than silently succeeding — a no-op would leave callers with no + // debug trail for the subsequent Get miss. + Put(ctx context.Context, key Key, resolution *Resolution) error +} + +// NewStorageBackedStore returns a CredentialStore that delegates to a +// storage.DCRCredentialStore for durable persistence and translates +// Resolution values into DCRCredentials at the boundary. The returned +// store is safe for concurrent use because the underlying +// storage.DCRCredentialStore must be (per its interface contract). +// +// Panics if backend is nil — a nil backend is unambiguously a programming +// error and silent acceptance would only delay the eventual nil-pointer +// dereference to the first Get/Put call, far from the constructor site. +func NewStorageBackedStore(backend storage.DCRCredentialStore) CredentialStore { + if backend == nil { + panic("dcr: NewStorageBackedStore: backend must not be nil") + } + return &storageBackedStore{backend: backend} +} + +// CloseableCredentialStore is a CredentialStore that also releases an +// underlying resource (typically a background goroutine) when closed. +// NewInMemoryStore returns this interface so callers can call Close() +// directly without a runtime type assertion — Close() being part of the +// returned type at compile time prevents the silent-no-op failure mode +// that .claude/rules/go-style.md warns about ("Never define a local +// anonymous interface inside an option and type-assert against it to +// check capability — a silent no-op results if the target doesn't +// implement it."). +// +// NewStorageBackedStore returns the base CredentialStore because its +// backends (storage.MemoryStorage shared across the authserver, +// storage.RedisStorage) are owned by the authserver runner and closed +// through that lifecycle, not by the dcr package. +type CloseableCredentialStore interface { + CredentialStore + io.Closer +} + +// NewInMemoryStore returns a CloseableCredentialStore whose entries live +// only for the lifetime of the returned value. This is the constructor +// consumers reach for when they have no shared durable backend — most +// notably the CLI OAuth flow, which manages cross-invocation credential +// persistence outside the resolver (in pkg/auth/remote/handler.go's +// CachedClientID / CachedClientSecretRef fields) and only needs the +// resolver's intra-call singleflight + S256 PKCE / expiry-refetch +// behaviour for one PerformOAuthFlow call. +// +// The implementation delegates to storage.NewMemoryStorage to share the +// same Get/Put/scope-hash semantics as the durable backends, including +// the background cleanup goroutine. Callers that need to release that +// goroutine before process exit MUST call Close on the returned value +// when finished — the return type is CloseableCredentialStore precisely +// so the call site can `defer store.Close()` without a runtime +// type-assertion. (CLI flows that complete in a single invocation can +// also rely on process exit.) +func NewInMemoryStore() CloseableCredentialStore { + return &inMemoryStore{mem: storage.NewMemoryStorage()} +} + +// inMemoryStore is the CloseableCredentialStore returned by +// NewInMemoryStore. It holds exactly one *storage.MemoryStorage handle, +// which serves Get / Put / Close. No embedding of storageBackedStore — +// embedding would introduce a second handle to the same backend (typed +// as the broad storage.DCRCredentialStore interface) and require a +// manual "keep these two in sync" invariant the compiler could not +// enforce. The single-field shape gives Get / Put / Close one +// authoritative source. +// +// Get / Put logic is intentionally duplicated with storageBackedStore +// rather than delegated through it: the two methods are three lines +// each, and avoiding the embedding is a strictly stronger structural +// guarantee than DRY here. credentialsToResolution / +// resolutionToCredentials remain shared. +// +// closeOnce makes Close() idempotent at the dcr-package boundary even +// though storage.MemoryStorage.Close() is not — calling +// MemoryStorage.Close() twice panics on `close of closed channel`. The +// CLI's `defer store.Close()` is single-call today, but guarding here +// means future call sites that close-on-shortcircuit-then-defer (or +// test helpers that close-then-reuse) cannot panic the process. +type inMemoryStore struct { + mem *storage.MemoryStorage + closeOnce sync.Once + closeErr error +} + +// Get implements CredentialStore by delegating to the embedded +// *storage.MemoryStorage. The ErrNotFound translation matches +// storageBackedStore.Get; see that method for the rationale. +func (s *inMemoryStore) Get(ctx context.Context, key Key) (*Resolution, bool, error) { + creds, err := s.mem.GetDCRCredentials(ctx, key) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, false, nil + } + return nil, false, err + } + return credentialsToResolution(creds), true, nil +} + +// Put implements CredentialStore by delegating to the embedded +// *storage.MemoryStorage. The nil-resolution rejection matches +// storageBackedStore.Put; see that method for the rationale. +func (s *inMemoryStore) Put(ctx context.Context, key Key, resolution *Resolution) error { + if resolution == nil { + return fmt.Errorf("dcr: resolution must not be nil") + } + creds := resolutionToCredentials(key, resolution) + return s.mem.StoreDCRCredentials(ctx, creds) +} + +// Close releases the embedded MemoryStorage cleanup goroutine. Safe to +// call multiple times: the underlying storage.MemoryStorage.Close +// closes a channel and is NOT itself idempotent (a second call panics +// on `close of closed channel`); the closeOnce here makes the +// dcr-package boundary contract idempotent regardless. Subsequent +// calls return the same error (if any) the first call produced. +func (s *inMemoryStore) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.mem.Close() + }) + return s.closeErr +} + +// storageBackedStore is the dcr-package CredentialStore wrapping a +// storage.DCRCredentialStore. Its methods are the only place that converts +// between the resolver's *Resolution and the persisted +// *storage.DCRCredentials shapes. +type storageBackedStore struct { + backend storage.DCRCredentialStore +} + +// Get implements CredentialStore. +// +// A storage-level ErrNotFound is translated into the (nil, false, nil) +// miss-tuple advertised by the interface. Other errors propagate as-is. +func (s *storageBackedStore) Get(ctx context.Context, key Key) (*Resolution, bool, error) { + creds, err := s.backend.GetDCRCredentials(ctx, key) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, false, nil + } + return nil, false, err + } + return credentialsToResolution(creds), true, nil +} + +// Put implements CredentialStore. +// +// A nil resolution is rejected rather than silently no-oped: a caller +// passing nil would otherwise get a successful return, observe a miss on +// the next Get, and have no error trail to debug from. Failing loudly at +// the boundary makes such bugs visible at the first call. +func (s *storageBackedStore) Put(ctx context.Context, key Key, resolution *Resolution) error { + if resolution == nil { + return fmt.Errorf("dcr: resolution must not be nil") + } + creds := resolutionToCredentials(key, resolution) + return s.backend.StoreDCRCredentials(ctx, creds) +} + +// resolutionToCredentials converts a resolver-side *Resolution into the +// persisted *storage.DCRCredentials shape. The Key is supplied separately +// because storage.DCRCredentials carries the key as a struct field rather +// than implicitly via a map key, so the persistence layer can round-trip it +// across processes and backends. +// +// Fields that exist on Resolution but not on DCRCredentials are dropped: +// - ClientIDIssuedAt: informational only per RFC 7591 §3.2.1; the resolver +// does not consult it for cache invalidation, so it does not need to +// survive a process restart. +// - RedirectURI: already encoded into key.RedirectURI; storing it twice +// would risk drift between the canonical key and the persisted value. +// +// CreatedAt and ClientSecretExpiresAt are preserved so cache observers +// (e.g. lookupCachedResolution's staleness Warn) and TTL-aware backends +// (Redis) keep their existing behaviour after a restart. +func resolutionToCredentials(key Key, res *Resolution) *storage.DCRCredentials { + if res == nil { + return nil + } + return &storage.DCRCredentials{ + Key: key, + ClientID: res.ClientID, + ClientSecret: res.ClientSecret, + TokenEndpointAuthMethod: res.TokenEndpointAuthMethod, + RegistrationAccessToken: res.RegistrationAccessToken, + RegistrationClientURI: res.RegistrationClientURI, + AuthorizationEndpoint: res.AuthorizationEndpoint, + TokenEndpoint: res.TokenEndpoint, + CreatedAt: res.CreatedAt, + ClientSecretExpiresAt: res.ClientSecretExpiresAt, + } +} + +// credentialsToResolution is the inverse of resolutionToCredentials. The +// RedirectURI is recovered from the persisted Key so consumers that read it +// off the resolution (e.g. pkg/authserver/runner.consumeResolution, which +// writes it back onto a run-config copy when the caller left it empty) +// see the canonical value. +// +// ClientIDIssuedAt is left zero because it is not persisted. Callers that +// care about it (none today) must read it directly from the live RFC 7591 +// response, not from a cached resolution. +func credentialsToResolution(creds *storage.DCRCredentials) *Resolution { + if creds == nil { + return nil + } + return &Resolution{ + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecret, + AuthorizationEndpoint: creds.AuthorizationEndpoint, + TokenEndpoint: creds.TokenEndpoint, + RegistrationAccessToken: creds.RegistrationAccessToken, + RegistrationClientURI: creds.RegistrationClientURI, + TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod, + RedirectURI: creds.Key.RedirectURI, + ClientSecretExpiresAt: creds.ClientSecretExpiresAt, + CreatedAt: creds.CreatedAt, + } +} diff --git a/pkg/auth/dcr/store_test.go b/pkg/auth/dcr/store_test.go new file mode 100644 index 0000000000..51e7980ea6 --- /dev/null +++ b/pkg/auth/dcr/store_test.go @@ -0,0 +1,528 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dcr + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +func TestStorageBackedStore_PutGet_RoundTrip(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + + key := Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://toolhive.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + resolution := &Resolution{ + ClientID: "client-abc", + ClientSecret: "secret-xyz", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + RegistrationAccessToken: "reg-tok", + RegistrationClientURI: "https://idp.example.com/register/client-abc", + TokenEndpointAuthMethod: "client_secret_basic", + CreatedAt: time.Now(), + } + + require.NoError(t, store.Put(ctx, key, resolution)) + + got, ok, err := store.Get(ctx, key) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, resolution.ClientID, got.ClientID) + assert.Equal(t, resolution.ClientSecret, got.ClientSecret) + assert.Equal(t, resolution.AuthorizationEndpoint, got.AuthorizationEndpoint) + assert.Equal(t, resolution.TokenEndpoint, got.TokenEndpoint) + assert.Equal(t, resolution.RegistrationAccessToken, got.RegistrationAccessToken) + assert.Equal(t, resolution.RegistrationClientURI, got.RegistrationClientURI) + assert.Equal(t, resolution.TokenEndpointAuthMethod, got.TokenEndpointAuthMethod) +} + +func TestStorageBackedStore_Get_MissingKey(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + + got, ok, err := store.Get(ctx, Key{Issuer: "https://unknown.example.com"}) + require.NoError(t, err) + assert.False(t, ok) + assert.Nil(t, got) +} + +func TestStorageBackedStore_DistinctKeysDoNotCollide(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + + keyA := Key{ + Issuer: "https://idp-a.example.com", + RedirectURI: "https://toolhive.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + keyB := Key{ + Issuer: "https://idp-b.example.com", + RedirectURI: "https://toolhive.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + keyC := Key{ + Issuer: "https://idp-a.example.com", + RedirectURI: "https://other.example.com/callback", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + keyD := Key{ + Issuer: "https://idp-a.example.com", + RedirectURI: "https://toolhive.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid", "email"}), + } + + // The persisted *storage.DCRCredentials shape requires non-empty + // AuthorizationEndpoint / TokenEndpoint per validateDCRCredentialsForStore; + // supply a minimal valid resolution so the storage-backed adapter accepts + // the Put, since the test asserts key-distinctness and not field shape. + resolution := func(clientID string) *Resolution { + return &Resolution{ + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + } + } + + require.NoError(t, store.Put(ctx, keyA, resolution("a"))) + require.NoError(t, store.Put(ctx, keyB, resolution("b"))) + require.NoError(t, store.Put(ctx, keyC, resolution("c"))) + require.NoError(t, store.Put(ctx, keyD, resolution("d"))) + + for _, tc := range []struct { + key Key + expected string + }{ + {keyA, "a"}, + {keyB, "b"}, + {keyC, "c"}, + {keyD, "d"}, + } { + got, ok, err := store.Get(ctx, tc.key) + require.NoError(t, err) + require.True(t, ok, "key %+v should be present", tc.key) + assert.Equal(t, tc.expected, got.ClientID) + } +} + +func TestStorageBackedStore_Put_OverwritesExisting(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + + key := Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x.example.com/cb", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + endpoints := struct { + Authorization string + Token string + }{ + Authorization: "https://idp.example.com/authorize", + Token: "https://idp.example.com/token", + } + require.NoError(t, store.Put(ctx, key, &Resolution{ + ClientID: "first", + AuthorizationEndpoint: endpoints.Authorization, + TokenEndpoint: endpoints.Token, + })) + require.NoError(t, store.Put(ctx, key, &Resolution{ + ClientID: "second", + AuthorizationEndpoint: endpoints.Authorization, + TokenEndpoint: endpoints.Token, + })) + + got, ok, err := store.Get(ctx, key) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "second", got.ClientID) +} + +// TestStorageBackedStore_Put_RejectsNilResolution pins the +// fail-loud-on-invalid-input contract: passing nil must error rather than +// silently no-op. A silent no-op would leave the caller with a successful +// Put followed by a Get miss and no debug trail to explain it. +func TestStorageBackedStore_Put_RejectsNilResolution(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + key := Key{Issuer: "https://idp.example.com", RedirectURI: "https://x.example.com/cb"} + + err := store.Put(ctx, key, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be nil") + + // And confirm the rejection did not partially populate the store. + _, ok, getErr := store.Get(ctx, key) + require.NoError(t, getErr) + assert.False(t, ok, "rejected Put must not leave any entry behind") +} + +func TestStorageBackedStore_GetReturnsDefensiveCopy(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + ctx := context.Background() + + key := Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x.example.com/cb", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + require.NoError(t, store.Put(ctx, key, &Resolution{ + ClientID: "orig", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + })) + + got, ok, err := store.Get(ctx, key) + require.NoError(t, err) + require.True(t, ok) + got.ClientID = "mutated" + + refetched, ok, err := store.Get(ctx, key) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "orig", refetched.ClientID) +} + +// Tests for the canonical scopes-hash form live next to the canonical +// implementation in pkg/authserver/storage/memory_test.go (TestScopesHash_*). +// Duplicating the suite here would re-exercise the same code, which is +// redundant per .claude/rules/testing.md. + +// TestStorageBackedStore_ConcurrentAccess fans out N goroutines +// performing alternating Put / Get against overlapping and disjoint keys, +// exercising the safe-for-concurrent-use contract advertised on the +// CredentialStore interface. The contract is satisfied by the underlying +// storage.DCRCredentialStore (which holds the lock); this test runs under +// `go test -race` so any future regression that drops the storage backend's +// guarantee, or an adapter change that races on its own state, fails loudly. +// +// The test is bounded by a fail-fast deadline so a regression that +// deadlocks fails loudly with a clear message rather than hanging until +// the global Go test timeout. +func TestStorageBackedStore_ConcurrentAccess(t *testing.T) { + t.Parallel() + + store := newMemoryDCRStore(t) + + const ( + workers = 16 + opsPerWorker = 200 + ) + + // Two key spaces: overlapping (every worker writes the same keys, so the + // lock must serialise their writes) and disjoint (each worker has its own + // key space, so reads never see another worker's writes). + overlappingKey := func(i int) Key { + return Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: fmt.Sprintf("overlap-%d", i%4), + } + } + disjointKey := func(worker, i int) Key { + return Key{ + Issuer: fmt.Sprintf("https://idp-%d.example.com", worker), + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: fmt.Sprintf("disjoint-%d", i), + } + } + + var errCount int32 + var wg sync.WaitGroup + wg.Add(workers) + for w := 0; w < workers; w++ { + go func(worker int) { + defer wg.Done() + ctx := context.Background() + for i := 0; i < opsPerWorker; i++ { + resolution := &Resolution{ + ClientID: fmt.Sprintf("worker-%d-op-%d", worker, i), + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + CreatedAt: time.Now(), + } + if i%2 == 0 { + if err := store.Put(ctx, overlappingKey(i), resolution); err != nil { + atomic.AddInt32(&errCount, 1) + } + if _, _, err := store.Get(ctx, overlappingKey(i)); err != nil { + atomic.AddInt32(&errCount, 1) + } + } else { + if err := store.Put(ctx, disjointKey(worker, i), resolution); err != nil { + atomic.AddInt32(&errCount, 1) + } + if _, _, err := store.Get(ctx, disjointKey(worker, i)); err != nil { + atomic.AddInt32(&errCount, 1) + } + } + } + }(w) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent store operations to finish; possible deadlock") + } + + assert.Zero(t, atomic.LoadInt32(&errCount), + "no Get/Put should have errored under concurrent access") +} + +// TestResolutionCredentialsRoundTrip pins the field-by-field contract +// between resolutionToCredentials and credentialsToResolution: which +// fields survive a round-trip, which are intentionally dropped, and +// which are recovered from the persisted Key. The test exists +// because the two converters are the seam where a field added to either +// Resolution or storage.DCRCredentials must be paired with an update +// here; without coverage, a future field addition would silently fail +// to persist across an authserver restart. +// +// The "preserved" group asserts equality on round-tripped values. The +// "dropped" group asserts that the post-round-trip value is the type's +// zero value (ClientIDIssuedAt is informational per RFC 7591 §3.2.1 and +// is not persisted). RedirectURI is in its own group because it is +// dropped from DCRCredentials and recovered via Key.RedirectURI on +// read. +// +// ProviderName is the one DCRCredentials field with no Resolution +// counterpart. It is documented in storage.DCRCredentials as "debug / +// audit only — never used as a primary key" and no current consumer +// reads it. The decision to leave it unpopulated by the runner is +// recorded here so a future contributor adding ProviderName threading +// has a single grep target. +func TestResolutionCredentialsRoundTrip(t *testing.T) { + t.Parallel() + + now := time.Now().UTC().Round(time.Second) + expiry := now.Add(30 * 24 * time.Hour) + + key := Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + + original := &Resolution{ + ClientID: "round-trip-client-id", + ClientSecret: "round-trip-secret", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + RegistrationAccessToken: "rfc7592-token", + RegistrationClientURI: "https://idp.example.com/register/round-trip-client-id", + TokenEndpointAuthMethod: "client_secret_basic", + // RedirectURI is recovered from Key on read; pre-populate it on + // the input to confirm it survives via the key, not via a + // dedicated field on DCRCredentials. + RedirectURI: key.RedirectURI, + ClientIDIssuedAt: now, // intentionally dropped on persist + ClientSecretExpiresAt: expiry, + CreatedAt: now, + } + + creds := resolutionToCredentials(key, original) + require.NotNil(t, creds) + + // Persisted-side assertions: which fields the converter writes to + // DCRCredentials, which it leaves zero (the asymmetry F7 documents). + assert.Equal(t, key, creds.Key, + "Key must round-trip via the explicit parameter") + assert.Empty(t, creds.ProviderName, + "ProviderName has no Resolution counterpart and is intentionally not populated; "+ + "a future contributor threading it through must update this assertion and the converters together") + + roundTripped := credentialsToResolution(creds) + require.NotNil(t, roundTripped) + + t.Run("preserved fields survive round-trip", func(t *testing.T) { + t.Parallel() + assert.Equal(t, original.ClientID, roundTripped.ClientID) + assert.Equal(t, original.ClientSecret, roundTripped.ClientSecret) + assert.Equal(t, original.AuthorizationEndpoint, roundTripped.AuthorizationEndpoint) + assert.Equal(t, original.TokenEndpoint, roundTripped.TokenEndpoint) + assert.Equal(t, original.RegistrationAccessToken, roundTripped.RegistrationAccessToken) + assert.Equal(t, original.RegistrationClientURI, roundTripped.RegistrationClientURI) + assert.Equal(t, original.TokenEndpointAuthMethod, roundTripped.TokenEndpointAuthMethod) + assert.Equal(t, original.ClientSecretExpiresAt, roundTripped.ClientSecretExpiresAt) + assert.Equal(t, original.CreatedAt, roundTripped.CreatedAt) + }) + + t.Run("RedirectURI is recovered from Key on read", func(t *testing.T) { + t.Parallel() + assert.Equal(t, key.RedirectURI, roundTripped.RedirectURI, + "RedirectURI on the round-tripped resolution must match Key.RedirectURI; "+ + "the converter does not store it twice") + }) + + t.Run("dropped fields zero on read", func(t *testing.T) { + t.Parallel() + // ClientIDIssuedAt is informational (RFC 7591 §3.2.1) and not + // persisted; round-tripping must reset it to the zero value + // rather than silently re-deriving it from CreatedAt. + assert.True(t, roundTripped.ClientIDIssuedAt.IsZero(), + "ClientIDIssuedAt must be zero after round-trip; the field is intentionally dropped from DCRCredentials") + }) + + t.Run("nil inputs short-circuit", func(t *testing.T) { + t.Parallel() + assert.Nil(t, resolutionToCredentials(key, nil)) + assert.Nil(t, credentialsToResolution(nil)) + }) +} + +// TestInMemoryStore_CloseIsIdempotent pins the contract that calling +// Close on the returned CloseableCredentialStore more than once is safe. +// The underlying storage.MemoryStorage.Close is NOT idempotent — it +// closes a channel that a second call would re-close, panicking with +// "close of closed channel" — so the dcr-package wrapper MUST guard +// against the second call. Without the sync.Once on inMemoryStore, the +// CLI's `defer store.Close()` plus any future close-on-shortcircuit +// would crash the process; this test makes the guard load-bearing. +func TestInMemoryStore_CloseIsIdempotent(t *testing.T) { + t.Parallel() + + store := NewInMemoryStore() + + // First Close must succeed and release the cleanup goroutine. + require.NoError(t, store.Close()) + + // Second Close must NOT panic and must return the same error + // (nil here, since the first call succeeded). + require.NoError(t, store.Close(), + "Close() must be idempotent at the dcr-package boundary; "+ + "the underlying MemoryStorage.Close is not idempotent and a "+ + "second call without the guard would panic") +} + +// TestInMemoryStore_CloseIsIdempotentUnderRace verifies the sync.Once +// guard holds under concurrent callers. A regression that replaced +// sync.Once with a non-atomic guard (e.g. a plain bool) would surface +// here as a panic from a racing channel close. +func TestInMemoryStore_CloseIsIdempotentUnderRace(t *testing.T) { + t.Parallel() + + store := NewInMemoryStore() + + const N = 8 + var wg sync.WaitGroup + wg.Add(N) + errs := make([]error, N) + for i := range N { + go func(idx int) { + defer wg.Done() + errs[idx] = store.Close() + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent Close() callers") + } + + for i := range N { + require.NoError(t, errs[i], "goroutine %d's Close() must not error", i) + } +} + +// TestInMemoryStore_PutGetCloseShareBackend pins the contract that Put, +// Get, and Close all operate against the same underlying +// *storage.MemoryStorage instance. The previous shape held two handles +// (one in an embedded storageBackedStore.backend, one in a concrete +// mem field); a constructor regression assigning distinct handles to +// the two fields would have surfaced as a Close that leaks the +// cleanup goroutine while Get still serves entries. Today only one +// handle exists, so this test asserts the observable consequence: +// after Close, the goroutine is gone (verified by t.Cleanup running +// without leaking via go test -race goroutine leak checks). +func TestInMemoryStore_PutGetCloseShareBackend(t *testing.T) { + t.Parallel() + + store := NewInMemoryStore() + t.Cleanup(func() { _ = store.Close() }) + + ctx := context.Background() + key := Key{ + Issuer: "https://idp.example.com", + RedirectURI: "https://toolhive.example.com/oauth/callback", + ScopesHash: storage.ScopesHash([]string{"openid"}), + } + resolution := &Resolution{ + ClientID: "client-abc", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + } + + require.NoError(t, store.Put(ctx, key, resolution)) + + got, ok, err := store.Get(ctx, key) + require.NoError(t, err) + require.True(t, ok, "Get must see the value Put just wrote — confirms Put and Get share a backend") + assert.Equal(t, "client-abc", got.ClientID) +} + +// TestInMemoryStore_PutRejectsNilResolution mirrors the contract pinned +// for storageBackedStore.Put: a nil resolution is rejected at the +// adapter boundary rather than silently no-oped, so the next Get miss +// surfaces with a debug trail. inMemoryStore implements Put directly +// (not via embedding) — this test guards against a delegation +// regression that omitted the nil check. +func TestInMemoryStore_PutRejectsNilResolution(t *testing.T) { + t.Parallel() + + store := NewInMemoryStore() + t.Cleanup(func() { _ = store.Close() }) + + err := store.Put(context.Background(), Key{Issuer: "https://idp.example.com"}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolution must not be nil") +} + +// TestInMemoryStore_GetMissingKeyReturnsMissTuple pins the ErrNotFound +// translation contract: a missing key surfaces as (nil, false, nil) +// rather than as a wrapped error. inMemoryStore implements Get directly +// (not via embedding) — this test guards against a delegation +// regression that surfaced ErrNotFound as a hard error. +func TestInMemoryStore_GetMissingKeyReturnsMissTuple(t *testing.T) { + t.Parallel() + + store := NewInMemoryStore() + t.Cleanup(func() { _ = store.Close() }) + + got, ok, err := store.Get(context.Background(), Key{Issuer: "https://unknown.example.com"}) + require.NoError(t, err) + assert.False(t, ok) + assert.Nil(t, got) +} diff --git a/pkg/auth/dcr/testhelpers_test.go b/pkg/auth/dcr/testhelpers_test.go new file mode 100644 index 0000000000..21608260a2 --- /dev/null +++ b/pkg/auth/dcr/testhelpers_test.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dcr + +import ( + "io" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +// newMemoryDCRStore is a test-only convenience constructor wrapping +// storage.NewMemoryStorage in the runner-side adapter. Production deployments +// do NOT reach this constructor — NewEmbeddedAuthServer type-asserts the +// shared authserver storage to storage.DCRCredentialStore and passes it to +// NewStorageBackedStore directly. +// +// The caller's *testing.T is required because storage.NewMemoryStorage +// launches a background cleanup goroutine on construction; the helper +// registers t.Cleanup(stor.Close) so each test releases the goroutine when +// it finishes. Without this every test that built a fresh store would leak a +// cleanupLoop goroutine for the duration of the test process. +func newMemoryDCRStore(t *testing.T) CredentialStore { + t.Helper() + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + return NewStorageBackedStore(stor) +} + +// readTokenFile reads a secret from disk and returns its trimmed contents, +// mirroring the production embeddedauthserver.resolveSecret semantics so +// resolver tests can populate Request.InitialAccessToken from a file path. +func readTokenFile(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) //nolint:gosec // G304: test-only helper, paths are constructed under t.TempDir() + require.NoError(t, err) + defer f.Close() + data, err := io.ReadAll(f) + require.NoError(t, err) + // Trim trailing newline conventionally appended by Kubernetes mounts / + // shell heredocs — same trim production code performs. + for len(data) > 0 && (data[len(data)-1] == '\n' || data[len(data)-1] == ' ' || data[len(data)-1] == '\t' || data[len(data)-1] == '\r') { + data = data[:len(data)-1] + } + return string(data) +} diff --git a/pkg/auth/discovery/dcr_resolver_test.go b/pkg/auth/discovery/dcr_resolver_test.go new file mode 100644 index 0000000000..2886d78ac2 --- /dev/null +++ b/pkg/auth/discovery/dcr_resolver_test.go @@ -0,0 +1,502 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package discovery + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// Tests in this file pin the behavioural properties the CLI flow inherits +// from migrating its DCR call through pkg/auth/dcr.ResolveCredentials +// (issue #5219 sub-issue 4b). Each test mounts an httptest server that +// simulates a specific upstream behaviour and asserts that +// handleDynamicRegistration surfaces the resolver's reaction unchanged. +// +// The properties under test: +// +// - S256 PKCE gating: an upstream advertising only "plain" PKCE causes +// the CLI to surface a resolver error rather than register a public +// client without S256 (RFC 7636 / OAuth 2.1 compliance). +// - Bearer-token transport redirect refusal: the registration POST does +// not follow 30x redirects, so a token-leak class of attack is +// impossible. +// - Singleflight deduplication: concurrent handleDynamicRegistration +// calls for the same (issuer, scopes, redirectURI) coalesce into +// exactly one upstream /register request. +// +// Expiry-driven refetch and panic recovery are pinned by the resolver's +// own test suite in pkg/auth/dcr; this file does not duplicate them +// because under the option (b) persistence model the CLI flow does not +// exercise either path in a CLI-specific way. Specifically, the +// resolver's RFC 7591 §3.2.1 expiry refetch is in the loop only within +// a single PerformOAuthFlow call (which the CLI never makes repeat +// queries inside). Cross-invocation expiry is handled by the remote +// handler at pkg/auth/remote/handler.go via its HasCachedClientCredentials +// gate, which runs BEFORE this code path and short-circuits to a +// refresh-token flow when a usable cached client exists. The gap between +// "the resolver supports expiry refetch" and "the CLI's cross-invocation +// persistence loop uses it" is the option (b) trade-off documented on +// handleDynamicRegistration; option (a) would close that gap and is the +// natural follow-up. + +// dcrTestServerConfig controls the mock upstream's behaviour for the CLI +// inherited-property tests below. +type dcrTestServerConfig struct { + // codeChallengeMethodsSupported is the upstream's advertised + // code_challenge_methods_supported value. Set to []string{"S256"} for + // happy-path tests; set to []string{"plain"} or nil to exercise the + // S256 gate. + codeChallengeMethodsSupported []string + + // registrationStatusCode overrides the registration endpoint's + // response status. When zero, the endpoint returns 201 Created with a + // well-formed RFC 7591 response. + registrationStatusCode int + + // registrationRedirectsTo, when non-empty, causes the registration + // endpoint to issue a 302 to this URL — used to verify the bearer + // transport's redirect refusal. + registrationRedirectsTo string + + // onRegistration is called once per registration attempt. Safe for + // concurrent use. + onRegistration func(r *http.Request, body []byte) + + // registrationDelay introduces a delay inside the registration + // handler so concurrent goroutines can pile up at the singleflight + // before any can finish. + registrationDelay time.Duration +} + +// newDCRDiscoveryServer mounts /.well-known/openid-configuration and a +// /register endpoint on a single httptest server. The metadata advertises +// the server's URL as the issuer (so RFC 8414 §3.3 issuer match passes +// against any DiscoveryURL derived from the issuer). +func newDCRDiscoveryServer(t *testing.T, cfg dcrTestServerConfig) *httptest.Server { + t.Helper() + var server *httptest.Server + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + md := oauthproto.OIDCDiscoveryDocument{ + AuthorizationServerMetadata: oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + JWKSURI: server.URL + "/jwks", + RegistrationEndpoint: server.URL + "/register", + CodeChallengeMethodsSupported: cfg.codeChallengeMethodsSupported, + TokenEndpointAuthMethodsSupported: []string{"none"}, + ResponseTypesSupported: []string{"code"}, + }, + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + if cfg.onRegistration != nil { + cfg.onRegistration(r, body) + } + if cfg.registrationDelay > 0 { + time.Sleep(cfg.registrationDelay) + } + if cfg.registrationRedirectsTo != "" { + http.Redirect(w, r, cfg.registrationRedirectsTo, http.StatusFound) + return + } + if cfg.registrationStatusCode != 0 { + w.WriteHeader(cfg.registrationStatusCode) + return + } + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "cli-registered-client", + TokenEndpointAuthMethod: "none", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +// TestHandleDynamicRegistration_InheritsS256Gating verifies the CLI flow +// surfaces the resolver's S256 PKCE error when the upstream advertises +// only "plain" (or omits code_challenge_methods_supported entirely). The +// pre-migration CLI would have registered as a public client regardless, +// silently violating RFC 7636 / OAuth 2.1; the migrated CLI MUST surface +// a clear error. +func TestHandleDynamicRegistration_InheritsS256Gating(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + codeChallengeMethodsSupported []string + }{ + {name: "upstream omits code_challenge_methods_supported", codeChallengeMethodsSupported: nil}, + {name: "upstream advertises only plain", codeChallengeMethodsSupported: []string{"plain"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var registrationHits int32 + server := newDCRDiscoveryServer(t, dcrTestServerConfig{ + codeChallengeMethodsSupported: tc.codeChallengeMethodsSupported, + onRegistration: func(_ *http.Request, _ []byte) { + atomic.AddInt32(®istrationHits, 1) + }, + }) + + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + } + + err := handleDynamicRegistration(context.Background(), server.URL, config) + require.Error(t, err, "S256 gating must fail registration") + assert.Contains(t, err.Error(), "S256", + "resolver error must mention the missing S256 advertisement") + assert.EqualValues(t, 0, atomic.LoadInt32(®istrationHits), + "the upstream /register endpoint must NOT be contacted when the S256 gate fails") + }) + } +} + +// TestHandleDynamicRegistration_InheritsRedirectRefusal verifies that the +// registration POST does not follow a redirect. A non-defended client +// would re-issue the registration request (with any attached bearer) +// against the redirect target; the resolver's bearerTokenTransport refuses, +// and the CLI surfaces the resulting *url.Error. +func TestHandleDynamicRegistration_InheritsRedirectRefusal(t *testing.T) { + t.Parallel() + + // Foreign origin: a separate server that records every request. + var foreignHits int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&foreignHits, 1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(foreign.Close) + + server := newDCRDiscoveryServer(t, dcrTestServerConfig{ + codeChallengeMethodsSupported: []string{"S256"}, + registrationRedirectsTo: foreign.URL + "/stolen", + }) + + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + } + + err := handleDynamicRegistration(context.Background(), server.URL, config) + require.Error(t, err, "registration must fail when the upstream returns a redirect") + assert.Contains(t, err.Error(), "redirect", + "resolver error must mention the refused redirect so operators can correlate") + assert.EqualValues(t, 0, atomic.LoadInt32(&foreignHits), + "foreign origin must receive zero requests; the redirect refusal prevents the leak") +} + +// TestHandleDynamicRegistration_InheritsSingleflightDedup verifies that +// N concurrent handleDynamicRegistration calls for the same (issuer, +// scopes, redirectURI) tuple coalesce into exactly one upstream /register +// request. The pre-migration CLI would have made N parallel registrations +// and produced N orphaned client_ids at the upstream — a real production +// hazard for rate-limited DCR endpoints and a cleanup task for operators. +// +// "Exactly one /register" is the property under test. The CLI's +// per-invocation in-memory store means cross-invocation reuse is NOT +// observable here (that lives in pkg/auth/remote/handler.go); this test +// strictly pins the intra-call singleflight. +func TestHandleDynamicRegistration_InheritsSingleflightDedup(t *testing.T) { + t.Parallel() + + // Each invocation of handleDynamicRegistration constructs a fresh + // dcr.InMemoryStore, so cross-invocation cache hits are NOT + // possible. The singleflight at the resolver layer is package-global, + // which is what coalesces concurrent goroutines hitting the same + // (issuer, redirectURI, scopesHash) key. + var registrationHits int32 + server := newDCRDiscoveryServer(t, dcrTestServerConfig{ + codeChallengeMethodsSupported: []string{"S256"}, + registrationDelay: 250 * time.Millisecond, + onRegistration: func(_ *http.Request, _ []byte) { + atomic.AddInt32(®istrationHits, 1) + }, + }) + + const N = 6 + var wg sync.WaitGroup + wg.Add(N) + results := make([]*OAuthFlowConfig, N) + errs := make([]error, N) + + for i := 0; i < N; i++ { + // Each goroutine gets its own OAuthFlowConfig so we can assert + // the resolution was applied to every caller's config. + cfg := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + } + results[i] = cfg + go func(idx int) { + defer wg.Done() + errs[idx] = handleDynamicRegistration(context.Background(), server.URL, cfg) + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent handleDynamicRegistration goroutines") + } + + for i := 0; i < N; i++ { + require.NoError(t, errs[i], "goroutine %d errored", i) + assert.Equal(t, "cli-registered-client", results[i].ClientID, + "every concurrent caller must observe the same resolved client_id") + } + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationHits), + "expected exactly one /register call despite %d concurrent goroutines; got %d", + N, atomic.LoadInt32(®istrationHits)) +} + +// TestHandleDynamicRegistration_NonRootIssuerRFC8414PathInsertion is a +// regression test for #5356. Authorization servers with a non-root issuer +// path (e.g. https://example.com/oauth) may serve their RFC 8414 metadata +// exclusively at the path-insertion URL +// (scheme://host/.well-known/oauth-authorization-server/path), not at the +// OIDC issuer-suffix URL ({issuer}/.well-known/openid-configuration). +// Prior to the fix, resolveDCRCredentials only constructed the OIDC URL and +// failed for those servers. The fix routes through +// oauthproto.FetchAuthorizationServerMetadata which tries all well-known +// URL forms in priority order. +func TestHandleDynamicRegistration_NonRootIssuerRFC8414PathInsertion(t *testing.T) { + t.Parallel() + + var registrationHits int32 + var server *httptest.Server + mux := http.NewServeMux() + + // Serve metadata ONLY at the RFC 8414 §3.1 path-insertion URL. + // The OIDC issuer-suffix URL (/oauth/.well-known/openid-configuration) + // is intentionally not mounted — it returns 404, simulating the Gleean + // AS behaviour that triggered #5356. + mux.HandleFunc("/.well-known/oauth-authorization-server/oauth", func(w http.ResponseWriter, _ *http.Request) { + // Issuer must carry the non-root path to pass RFC 8414 §3.3 validation. + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL + "/oauth", + AuthorizationEndpoint: server.URL + "/oauth/authorize", + TokenEndpoint: server.URL + "/oauth/token", + RegistrationEndpoint: server.URL + "/oauth/register", + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + mux.HandleFunc("/oauth/register", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + atomic.AddInt32(®istrationHits, 1) + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "non-root-client", + TokenEndpointAuthMethod: "none", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + } + + err := handleDynamicRegistration(context.Background(), server.URL+"/oauth", config) + require.NoError(t, err, "DCR must succeed when metadata is served only at RFC 8414 path-insertion URL") + assert.Equal(t, "non-root-client", config.ClientID) + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationHits), + "expected exactly one /oauth/register call") +} + +// TestHandleDynamicRegistration_PreDiscoveredPathNonRootIssuer tests the path +// where the CLI already has OAuth endpoints from a prior discovery +// (config.RegistrationEndpoint/AuthorizeURL/TokenURL are all populated) and +// the issuer has a non-root path. getDiscoveryDocument short-circuits on this +// path and returns a synthesised document with empty +// code_challenge_methods_supported; resolveDCRCredentials must then re-fetch +// via multi-URL fallback to populate the S256 PKCE gate. This is the more +// common production scenario vs the fresh-discovery path tested by +// TestHandleDynamicRegistration_NonRootIssuerRFC8414PathInsertion. +func TestHandleDynamicRegistration_PreDiscoveredPathNonRootIssuer(t *testing.T) { + t.Parallel() + + var registrationHits int32 + var server *httptest.Server + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/oauth-authorization-server/oauth", func(w http.ResponseWriter, _ *http.Request) { + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL + "/oauth", + AuthorizationEndpoint: server.URL + "/oauth/authorize", + TokenEndpoint: server.URL + "/oauth/token", + RegistrationEndpoint: server.URL + "/oauth/register", + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + mux.HandleFunc("/oauth/register", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + atomic.AddInt32(®istrationHits, 1) + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "pre-discovered-client", + TokenEndpointAuthMethod: "none", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + // Simulate the pre-discovered path: all three endpoints are already set so + // getDiscoveryDocument short-circuits without a network call and returns a + // synthesised document with empty code_challenge_methods_supported. + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + RegistrationEndpoint: server.URL + "/oauth/register", + AuthorizeURL: server.URL + "/oauth/authorize", + TokenURL: server.URL + "/oauth/token", + } + + err := handleDynamicRegistration(context.Background(), server.URL+"/oauth", config) + require.NoError(t, err, "DCR must succeed on pre-discovered path with non-root issuer") + assert.Equal(t, "pre-discovered-client", config.ClientID) + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationHits)) +} + +// TestHandleDynamicRegistration_SynthesisesEndpointWhenMetadataOmitsIt +// verifies that when the re-discovery call inside resolveDCRCredentials returns +// ErrRegistrationEndpointMissing (valid metadata but no registration_endpoint), +// the endpoint is synthesised as {issuer}/register and the registration is +// routed there — mirroring the nanobot/Hydra convention that the resolver's +// DiscoveryURL branch applies. The pre-discovered path is used to bypass the +// handleDynamicRegistration guard that otherwise exits early when +// registration_endpoint is absent from the initial discovery document. +func TestHandleDynamicRegistration_SynthesisesEndpointWhenMetadataOmitsIt(t *testing.T) { + t.Parallel() + + var registrationHits int32 + var server *httptest.Server + mux := http.NewServeMux() + + // Metadata has all required fields but deliberately omits + // registration_endpoint, causing FetchAuthorizationServerMetadata to + // return (partialMeta, ErrRegistrationEndpointMissing). + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + // registration_endpoint intentionally absent + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + // resolveDCRCredentials synthesises {issuer}/register for a root issuer. + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + atomic.AddInt32(®istrationHits, 1) + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "synthesised-client", + TokenEndpointAuthMethod: "none", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + // The pre-discovered path sets RegistrationEndpoint so + // handleDynamicRegistration's guard passes; resolveDCRCredentials then + // re-fetches and hits the ErrRegistrationEndpointMissing synthesis branch. + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + RegistrationEndpoint: server.URL + "/register", + AuthorizeURL: server.URL + "/authorize", + TokenURL: server.URL + "/token", + } + + err := handleDynamicRegistration(context.Background(), server.URL, config) + require.NoError(t, err, "DCR must succeed when registration_endpoint is absent from re-fetched metadata") + assert.Equal(t, "synthesised-client", config.ClientID) + assert.EqualValues(t, 1, atomic.LoadInt32(®istrationHits), + "synthesised endpoint must be contacted exactly once") +} + +// TestHandleDynamicRegistration_PopulatesEndpoints verifies the contract +// the rest of the CLI flow depends on: handleDynamicRegistration writes +// the resolved AuthorizeURL / TokenURL onto OAuthFlowConfig so +// createOAuthConfig can construct the OAuth2 flow without re-discovery. +func TestHandleDynamicRegistration_PopulatesEndpoints(t *testing.T) { + t.Parallel() + + server := newDCRDiscoveryServer(t, dcrTestServerConfig{ + codeChallengeMethodsSupported: []string{"S256"}, + }) + + config := &OAuthFlowConfig{ + Scopes: []string{"openid", "profile"}, + CallbackPort: 8765, + } + + err := handleDynamicRegistration(context.Background(), server.URL, config) + require.NoError(t, err) + assert.Equal(t, "cli-registered-client", config.ClientID, + "handleDynamicRegistration must populate OAuthFlowConfig.ClientID") + assert.Equal(t, server.URL+"/authorize", config.AuthorizeURL, + "handleDynamicRegistration must populate OAuthFlowConfig.AuthorizeURL") + assert.Equal(t, server.URL+"/token", config.TokenURL, + "handleDynamicRegistration must populate OAuthFlowConfig.TokenURL") +} diff --git a/pkg/auth/discovery/discovery.go b/pkg/auth/discovery/discovery.go index b3ef713b73..ee28036c5d 100644 --- a/pkg/auth/discovery/discovery.go +++ b/pkg/auth/discovery/discovery.go @@ -27,9 +27,10 @@ import ( "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/auth/dcr" "github.com/stacklok/toolhive/pkg/auth/oauth" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // Default timeout constants for authentication operations @@ -53,10 +54,11 @@ type AuthInfo struct { // AuthServerInfo contains information about a validated authorization server type AuthServerInfo struct { - Issuer string - AuthorizationURL string - TokenURL string - RegistrationEndpoint string + Issuer string + AuthorizationURL string + TokenURL string + RegistrationEndpoint string + ClientIDMetadataDocumentSupported bool } // Config holds configuration for authentication discovery @@ -539,9 +541,10 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi return nil, fmt.Errorf("OAuth flow config cannot be nil") } - // Resolve port availability BEFORE dynamic registration - // This ensures we register the OAuth client with the same port we'll actually use - + // Resolve port availability before registration. DCR clients allow port fallback + // because the actual port is registered after selection. Pre-registered and CIMD + // clients require the configured port to be available as-is — it is already + // published in their IdP application or metadata document redirect URI. if shouldDynamicallyRegisterClient(config) { // For dynamic registration, we can allow fallback to alternative ports // since we can register the client with the actual port we'll use @@ -555,8 +558,9 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi } config.CallbackPort = port } else { - // For pre-registered clients, use strict port checking - // The user likely configured this port in their IdP/app + // For pre-registered clients and CIMD, use strict port checking. + // The port is either configured in the IdP app or baked into the + // redirect URI in the hosted metadata document. if !networking.IsAvailable(config.CallbackPort) { return nil, fmt.Errorf( "specified auth callback port %d is not available - please choose a different port or ensure it's not in use", @@ -582,31 +586,205 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi return newOAuthFlow(ctx, oauthConfig, config) } -// handleDynamicRegistration handles the dynamic client registration process +// handleDynamicRegistration handles the dynamic client registration process. +// +// Persistence model (option (b) from #5219): the resolver runs against an +// in-memory dcr.CredentialStore for the duration of a single CLI +// invocation, so within one PerformOAuthFlow call concurrent goroutines +// share the singleflight (proving the inherited "singleflight +// deduplication" property). Cross-invocation persistence is handled +// outside the resolver by pkg/auth/remote/handler.go, which reads +// CachedClientID / CachedClientSecretRef before this code path runs and +// short-circuits to a refresh-token flow when a usable cached client +// exists. +// +// One consequence of option (b) is that the resolver's RFC 7591 §3.2.1 +// expiry-driven refetch does NOT participate in the CLI's cross- +// invocation persistence loop: each PerformOAuthFlow call builds a fresh +// in-memory store, so a "cached but expired" entry from the previous +// invocation never reaches the resolver. Cross-invocation expiry is also +// NOT enforced by the remote handler's gate today — +// HasCachedClientCredentials only checks CachedClientID != "" and does +// not consult CachedSecretExpiry, so an expired-but-still-cached client +// gets reused on the next invocation and surfaces as a token-endpoint +// failure rather than a clean DCR re-registration. Tightening the gate +// to also check CachedSecretExpiry is open follow-up work; the +// behaviour today is "cross-invocation expiry is unhandled". Within a +// single invocation, the resolver's expiry check is still in the loop +// and would fire if the same call site somehow registered, persisted, +// and re-queried the in-memory store — but the CLI never does this today. +// +// Wrapping the remote handler's secretProvider into a dcr.CredentialStore +// adapter (option (a)) would close that loop and is the natural follow-up; +// it was rejected here as out-of-scope churn for sub-issue 4b. func handleDynamicRegistration(ctx context.Context, issuer string, config *OAuthFlowConfig) error { discoveredDoc, err := getDiscoveryDocument(ctx, issuer, config) if err != nil { return fmt.Errorf("failed to discover registration endpoint: %w", err) } - registrationResponse, err := registerDynamicClient(ctx, config, discoveredDoc) + // Check if the provider supports Dynamic Client Registration before + // invoking the resolver. The CLI-flag hint below is intentional: this + // function is CLI-facing (pkg/auth/discovery is not a protocol-level + // package) and the flags named here are the correct fallback for + // operators who need to supply credentials manually. The + // protocol-neutral version of this message lives in + // pkg/oauthproto.handleHTTPResponse for the HTTP 404/405/501 paths. + if discoveredDoc.RegistrationEndpoint == "" { + return fmt.Errorf("this provider does not support Dynamic Client Registration (DCR). " + + "Please configure OAuth client credentials using --remote-auth-client-id and --remote-auth-client-secret flags, " + + "or register a client manually with the provider") + } + + resolution, err := resolveDCRCredentials(ctx, issuer, config, discoveredDoc) if err != nil { return err } - // Update config with registered client credentials - config.ClientID = registrationResponse.ClientID - config.ClientSecret = registrationResponse.ClientSecret + // Update config with registered client credentials. The remote handler + // at pkg/auth/remote/handler.go reads ClientID / ClientSecret off + // OAuthFlowResult and persists them into CachedClientID / + // CachedClientSecretRef for the next invocation. + config.ClientID = resolution.ClientID + config.ClientSecret = resolution.ClientSecret - if discoveredDoc.RegistrationEndpoint != "" { - config.AuthorizeURL = discoveredDoc.AuthorizationEndpoint - config.TokenURL = discoveredDoc.TokenEndpoint + // Surface the resolved authorization / token endpoints to the OAuth + // flow. The resolver returns the endpoints it used for registration + // (caller-supplied if specified, discovered otherwise), so + // downstream OAuth-config construction can rely on these fields + // being populated. + if resolution.AuthorizationEndpoint != "" { + config.AuthorizeURL = resolution.AuthorizationEndpoint + } + if resolution.TokenEndpoint != "" { + config.TokenURL = resolution.TokenEndpoint } return nil } -// getDiscoveryDocument retrieves the OIDC discovery document +// resolveDCRCredentials routes the CLI-flow DCR registration through the +// shared pkg/auth/dcr resolver, inheriting its singleflight deduplication, +// S256 PKCE gating, RFC 7591 §3.2.1 expiry-driven refetch, bearer-token +// transport with redirect refusal, and panic recovery. +// +// The redirect URI is a loopback per RFC 8252 §7.3 — this is the CLI's +// existing public-client model, and PublicClient=true tells the resolver +// to register with token_endpoint_auth_method=none and to refuse when +// S256 PKCE is not advertised (rather than silently downgrading). +// +// Discovery fetch — multi-URL fallback. oauthproto.FetchAuthorizationServerMetadata +// tries the three well-known URL forms defined by RFC 8414 §3.1 and OIDC +// Discovery in priority order: RFC 8414 path-insertion +// (scheme://host/.well-known/oauth-authorization-server/{path}), OIDC +// issuer-suffix ({issuer}/.well-known/openid-configuration), and bare +// RFC 8414 (scheme://host/.well-known/oauth-authorization-server). Using +// this function rather than constructing a single OIDC URL ensures that +// authorization servers with non-root issuer paths whose metadata lives +// at the path-insertion URL are correctly reached (fixes #5356). +// The fetched code_challenge_methods_supported is forwarded to the +// resolver via dcr.Request so the S256 PKCE gate fires without a +// second discovery round-trip inside the resolver. +// +// Threading code_challenge_methods_supported through AuthServerInfo (and +// updating every caller of ValidateAndDiscoverAuthServer) would eliminate +// this fetch on the pre-discovered path and is a natural follow-up. +func resolveDCRCredentials( + ctx context.Context, + issuer string, + config *OAuthFlowConfig, + discoveredDoc *oauthproto.OIDCDiscoveryDocument, +) (*dcr.Resolution, error) { + redirectURI := fmt.Sprintf("http://localhost:%d/callback", config.CallbackPort) + + // dcr.Request.Issuer carries the caller's logical scope for cache + // keying. The CLI flow has no separate logical issuer of its own, so + // it deliberately reuses the upstream's issuer URL here. This is + // safe because the resolver's cache key is (Issuer, RedirectURI, + // ScopesHash) and the CLI always supplies an explicit loopback + // RedirectURI per RFC 8252 §7.3 — even if a future embedded-authserver + // upstream happened to share Issuer with a CLI invocation, the + // distinct RedirectURI keeps the cache keys apart. See the Issuer + // field doc on dcr.Request for the wider semantics. + req := &dcr.Request{ + Issuer: issuer, + RedirectURI: redirectURI, + Scopes: config.Scopes, + AuthorizationEndpoint: discoveredDoc.AuthorizationEndpoint, + TokenEndpoint: discoveredDoc.TokenEndpoint, + PublicClient: true, + } + + // Fetch AS metadata using the multi-URL fallback so non-root issuers + // are correctly reached (see function-level doc). discoveredDoc.Issuer + // is non-empty for every reachable caller; the else branch is + // defence-in-depth for a future refactor that produces an empty-issuer + // doc and preserves the pre-existing RegistrationEndpoint-direct path. + if discoveredDoc.Issuer != "" { + fullMeta, metaErr := oauthproto.FetchAuthorizationServerMetadata(ctx, discoveredDoc.Issuer, nil) + if metaErr != nil && !errors.Is(metaErr, oauthproto.ErrRegistrationEndpointMissing) { + return nil, fmt.Errorf("dynamic client registration failed: discover authorization server metadata: %w", metaErr) + } + if fullMeta == nil { + // Contract violation guard — FetchAuthorizationServerMetadata + // guarantees non-nil metadata alongside ErrRegistrationEndpointMissing. + return nil, fmt.Errorf("dynamic client registration failed: authorization server metadata unexpectedly nil") + } + regEndpoint := fullMeta.RegistrationEndpoint + if regEndpoint == "" { + // Synthesise per nanobot/Hydra convention, mirroring the resolver's + // own synthesiseRegistrationEndpoint for the DiscoveryURL branch. + u, parseErr := url.Parse(discoveredDoc.Issuer) + if parseErr != nil { + return nil, fmt.Errorf("dynamic client registration failed: parse issuer for endpoint synthesis: %w", parseErr) + } + regEndpoint = (&url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: strings.TrimRight(u.Path, "/") + "/register", + }).String() + } + req.RegistrationEndpoint = regEndpoint + req.CodeChallengeMethodsSupported = fullMeta.CodeChallengeMethodsSupported + } else { + req.RegistrationEndpoint = discoveredDoc.RegistrationEndpoint + } + + store := dcr.NewInMemoryStore() + defer func() { + // Close returns nil today (inMemoryStore.Close is sync.Once-guarded + // over an in-memory map), but a future change to the underlying + // MemoryStorage.Close — e.g., timeout-aware cleanup goroutine + // teardown — could surface a real error. Log it at debug rather + // than dropping it so a regression is visible without elevating + // every CLI-flow shutdown to WARN. + if err := store.Close(); err != nil { + slog.Debug("dcr: in-memory store close failed", "error", err) + } + }() + + resolution, err := dcr.ResolveCredentials(ctx, req, store) + if err != nil { + // Surface the structured error to the boundary log so operators + // see one slog.Error record with the step / issuer / redirect_uri + // attributes, then wrap with the CLI-facing prefix. + dcr.LogStepError(issuer, err) + return nil, fmt.Errorf("dynamic client registration failed: %w", err) + } + return resolution, nil +} + +// getDiscoveryDocument retrieves the OIDC discovery document. +// +// The "pre-discovered" short-circuit synthesises a document from +// OAuthFlowConfig fields populated by earlier discovery in the remote +// handler (pkg/auth/remote/handler.go via ValidateAndDiscoverAuthServer). +// On that path the synthesised document carries only the endpoint URLs, +// NOT the server-capability fields (code_challenge_methods_supported, +// token_endpoint_auth_methods_supported, scopes_supported) — those +// remain at their zero values. resolveDCRCredentials handles this by +// re-issuing a discovery fetch through the resolver (see its doc for +// the rationale and trade-off). func getDiscoveryDocument( ctx context.Context, issuer string, @@ -717,31 +895,6 @@ func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthF }, nil } -func registerDynamicClient( - ctx context.Context, - config *OAuthFlowConfig, - discoveredDoc *oauthproto.OIDCDiscoveryDocument, -) (*oauth.DynamicClientRegistrationResponse, error) { - - // Check if the provider supports Dynamic Client Registration - if discoveredDoc.RegistrationEndpoint == "" { - return nil, fmt.Errorf("this provider does not support Dynamic Client Registration (DCR). " + - "Please configure OAuth client credentials using --remote-auth-client-id and --remote-auth-client-secret flags, " + - "or register a client manually with the provider") - } - - // Use default client name if not provided - registrationRequest := oauth.NewDynamicClientRegistrationRequest(config.Scopes, config.CallbackPort) - - // Perform dynamic client registration - registrationResponse, err := oauth.RegisterClientDynamically(ctx, discoveredDoc.RegistrationEndpoint, registrationRequest) - if err != nil { - return nil, fmt.Errorf("dynamic client registration failed: %w", err) - } - - return registrationResponse, nil -} - // FetchResourceMetadata as specified in RFC 9728 func FetchResourceMetadata(ctx context.Context, metadataURL string) (*auth.RFC9728AuthInfo, error) { if metadataURL == "" { @@ -828,10 +981,11 @@ func ValidateAndDiscoverAuthServer(ctx context.Context, potentialIssuer string) } return &AuthServerInfo{ - Issuer: doc.Issuer, - AuthorizationURL: doc.AuthorizationEndpoint, - TokenURL: doc.TokenEndpoint, - RegistrationEndpoint: doc.RegistrationEndpoint, + Issuer: doc.Issuer, + AuthorizationURL: doc.AuthorizationEndpoint, + TokenURL: doc.TokenEndpoint, + RegistrationEndpoint: doc.RegistrationEndpoint, + ClientIDMetadataDocumentSupported: doc.ClientIDMetadataDocumentSupported, }, nil } diff --git a/pkg/auth/discovery/discovery_test.go b/pkg/auth/discovery/discovery_test.go index a494e7eaa0..70f9c03305 100644 --- a/pkg/auth/discovery/discovery_test.go +++ b/pkg/auth/discovery/discovery_test.go @@ -18,7 +18,6 @@ import ( "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" ) const wellKnownOAuthPath = "/.well-known/oauth-protected-resource" @@ -1286,43 +1285,57 @@ func TestTryWellKnownDiscovery_ErrorPaths(t *testing.T) { }) } -// TestRegisterDynamicClient_MissingRegistrationEndpoint tests that registerDynamicClient -// returns a clear error message when the OIDC discovery document doesn't include -// a registration_endpoint (provider doesn't support DCR). -func TestRegisterDynamicClient_MissingRegistrationEndpoint(t *testing.T) { +// TestHandleDynamicRegistration_MissingRegistrationEndpoint pins the CLI's +// behaviour when the OIDC discovery document does not advertise a +// registration_endpoint (provider does not support DCR). The CLI surfaces +// a clear error directing the operator to the manual --remote-auth-client-* +// fallback flags. +// +// The check runs before the resolver is invoked: with no +// registration_endpoint there is no upstream URL to register against, so +// the CLI cannot proceed regardless of the resolver's S256 PKCE / synthesis +// behaviour. This keeps the error message stable across both code paths +// (this branch and any future resolver-side synthesis disable). +func TestHandleDynamicRegistration_MissingRegistrationEndpoint(t *testing.T) { t.Parallel() - ctx := context.Background() - - // Create a discovery document without registration_endpoint - discoveredDoc := &oauthproto.OIDCDiscoveryDocument{ - AuthorizationServerMetadata: oauthproto.AuthorizationServerMetadata{ - Issuer: "https://auth.example.com", - AuthorizationEndpoint: "https://auth.example.com/oauth/authorize", - TokenEndpoint: "https://auth.example.com/oauth/token", - JWKSURI: "https://auth.example.com/oauth/jwks", - // Note: RegistrationEndpoint is intentionally omitted (empty string) - RegistrationEndpoint: "", - }, - } + // Mount a metadata document that intentionally omits registration_endpoint. + // The CLI flow's getDiscoveryDocument short-circuit reads AuthorizeURL / + // TokenURL off OAuthFlowConfig and skips network discovery — we set + // AuthorizeURL and TokenURL but leave RegistrationEndpoint empty, which + // falls through to oauth.DiscoverOIDCEndpoints against the httptest + // server below. + var server *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // registration_endpoint intentionally omitted. + _, _ = w.Write([]byte(`{ + "issuer": "` + server.URL + `", + "authorization_endpoint": "` + server.URL + `/authorize", + "token_endpoint": "` + server.URL + `/token", + "jwks_uri": "` + server.URL + `/jwks", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + }`)) + }) + server = httptest.NewServer(mux) + t.Cleanup(server.Close) config := &OAuthFlowConfig{ Scopes: []string{"openid", "profile"}, CallbackPort: 8765, } - // Call registerDynamicClient with a discovery document missing registration_endpoint - result, err := registerDynamicClient(ctx, config, discoveredDoc) - - // Should return an error + err := handleDynamicRegistration(context.Background(), server.URL, config) require.Error(t, err) - assert.Nil(t, result) - // Error message should clearly indicate DCR is not supported + // The CLI-flag fallback hint MUST be preserved verbatim — it is the + // load-bearing operator guidance for upstreams that do not advertise + // registration_endpoint. assert.Contains(t, err.Error(), "does not support Dynamic Client Registration") assert.Contains(t, err.Error(), "DCR") - - // Error message should provide actionable guidance assert.Contains(t, err.Error(), "--remote-auth-client-id") assert.Contains(t, err.Error(), "--remote-auth-client-secret") } diff --git a/pkg/auth/github_provider.go b/pkg/auth/github_provider.go index a3056b37cc..aa46a69558 100644 --- a/pkg/auth/github_provider.go +++ b/pkg/auth/github_provider.go @@ -19,8 +19,8 @@ import ( "github.com/golang-jwt/jwt/v5" "golang.org/x/time/rate" - "github.com/stacklok/toolhive/pkg/auth/oauth" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // GitHubTokenCheckURL is the base URL pattern for GitHub OAuth token validation @@ -152,7 +152,7 @@ func (g *GitHubProvider) IntrospectToken(ctx context.Context, token string) (jwt // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", oauth.UserAgent) + req.Header.Set("User-Agent", oauthproto.UserAgent) // GitHub requires Basic Auth with OAuth App credentials req.SetBasicAuth(g.clientID, g.clientSecret) diff --git a/pkg/auth/monitored_token_source.go b/pkg/auth/monitored_token_source.go index 75e0d0f395..a3f204a489 100644 --- a/pkg/auth/monitored_token_source.go +++ b/pkg/auth/monitored_token_source.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "strconv" "strings" @@ -129,28 +130,25 @@ type transientRefresher struct { source oauth2.TokenSource workload string - // newBackOff is a factory for the backoff used during retries. - // Nil in production; overridable in tests for fast execution. + // upstream identifies the upstream authorization server that issued the + // token, and clientID is the OAuth 2.0 client_id used by this workload. + // Both are optional and are only surfaced in structured logs (notably the + // DCR/CIMD remediation warning emitted from MonitoredTokenSource on the + // transition to Unauthenticated). Empty strings are acceptable and are + // omitted from log output by the slog handler. + upstream string + clientID string + + // newBackOff constructs the exponential backoff applied during transient-error + // retries. Always non-nil; set by the constructor. newBackOff func() backoff.BackOff - - // beforeEntry and afterEntry are nil in production. Tests set them to - // synchronise goroutines so that the singleflight group is fully formed - // before the leader's retry returns. - beforeEntry func() - afterEntry func() } // Refresh deduplicates concurrent callers via singleflight and retries the // underlying token source with exponential backoff until the context is // cancelled or a non-transient error is returned. func (r *transientRefresher) Refresh(ctx context.Context, origErr error) (*oauth2.Token, error) { - if r.beforeEntry != nil { - r.beforeEntry() - } v, err, _ := r.group.Do("token-refresh", func() (interface{}, error) { - if r.afterEntry != nil { - r.afterEntry() - } return r.retry(ctx, origErr) }) if err != nil { @@ -159,14 +157,34 @@ func (r *transientRefresher) Refresh(ctx context.Context, origErr error) (*oauth return v.(*oauth2.Token), nil } +func newTransientRefresher( + source oauth2.TokenSource, + workload, upstream, clientID string, + newBackOff func() backoff.BackOff, +) *transientRefresher { + return &transientRefresher{ + source: source, + workload: workload, + upstream: upstream, + clientID: clientID, + newBackOff: newBackOff, + } +} + +func defaultBackOff() backoff.BackOff { + eb := backoff.NewExponentialBackOff() + eb.InitialInterval = resolveTokenRefreshInitialRetryInterval() + eb.MaxInterval = resolveTokenRefreshMaxRetryInterval() + eb.Reset() + return eb +} + func (r *transientRefresher) retry(ctx context.Context, origErr error) (*oauth2.Token, error) { slog.Warn("token refresh failed due to transient network error, retrying with backoff", "workload", r.workload, "error", origErr, ) - b := r.getBackOff() - return backoff.Retry(ctx, func() (*oauth2.Token, error) { t, tokenErr := r.source.Token() if tokenErr == nil { @@ -177,7 +195,7 @@ func (r *transientRefresher) retry(ctx context.Context, origErr error) (*oauth2. } return nil, tokenErr }, - backoff.WithBackOff(b), + backoff.WithBackOff(r.newBackOff()), backoff.WithNotify(func(retryErr error, d time.Duration) { slog.Warn("token refresh retry failed", "workload", r.workload, @@ -190,17 +208,6 @@ func (r *transientRefresher) retry(ctx context.Context, origErr error) (*oauth2. ) } -func (r *transientRefresher) getBackOff() backoff.BackOff { - if r.newBackOff != nil { - return r.newBackOff() - } - eb := backoff.NewExponentialBackOff() - eb.InitialInterval = resolveTokenRefreshInitialRetryInterval() - eb.MaxInterval = resolveTokenRefreshMaxRetryInterval() - eb.Reset() - return eb -} - // MonitoredTokenSource is a wrapper around an oauth2.TokenSource that monitors authentication // failures and automatically marks workloads as unauthenticated when tokens expire or fail. // It provides both per-request token retrieval and background monitoring. @@ -212,6 +219,8 @@ func (r *transientRefresher) getBackOff() backoff.BackOff { type MonitoredTokenSource struct { tokenSource oauth2.TokenSource workloadName string + upstream string + clientID string statusUpdater StatusUpdater monitoringCtx context.Context stopMonitoring chan struct{} @@ -226,20 +235,49 @@ type MonitoredTokenSource struct { // NewMonitoredTokenSource creates a new MonitoredTokenSource that wraps the provided // oauth2.TokenSource and monitors it for authentication failures. +// +// upstream and clientID annotate structured logs emitted by the token source, +// most importantly the DCR/CIMD remediation warning fired on the transition +// to Unauthenticated when the token endpoint returns a permanent 4xx (which +// frequently indicates stale cached credentials). Pass empty strings when +// the workload does not use DCR/CIMD or the upstream issuer is unknown; the +// remediation log will be suppressed when clientID is empty since its +// operator-correlation field would be blank. +// +// The fields are fixed at construction time rather than exposed via a setter +// so there is no data race between a late writer and the readers in Token() +// / transientRefresher.retry() — both of which may run on the background +// monitor goroutine started by StartBackgroundMonitoring. func NewMonitoredTokenSource( ctx context.Context, tokenSource oauth2.TokenSource, workloadName string, + upstream string, + clientID string, + statusUpdater StatusUpdater, +) *MonitoredTokenSource { + return newMonitoredTokenSourceWithBackOff(ctx, tokenSource, workloadName, upstream, clientID, statusUpdater, defaultBackOff) +} + +func newMonitoredTokenSourceWithBackOff( + ctx context.Context, + tokenSource oauth2.TokenSource, + workloadName string, + upstream string, + clientID string, statusUpdater StatusUpdater, + newBackOff func() backoff.BackOff, ) *MonitoredTokenSource { return &MonitoredTokenSource{ tokenSource: tokenSource, workloadName: workloadName, + upstream: upstream, + clientID: clientID, statusUpdater: statusUpdater, monitoringCtx: ctx, stopMonitoring: make(chan struct{}), stopped: make(chan struct{}), - refresher: &transientRefresher{source: tokenSource, workload: workloadName}, + refresher: newTransientRefresher(tokenSource, workloadName, upstream, clientID, newBackOff), } } @@ -249,11 +287,11 @@ func (mts *MonitoredTokenSource) Stopped() <-chan struct{} { return mts.stopped } -// Token retrieves a token, retrying with exponential backoff on transient errors -// (see isTransientNetworkError for the full list). On non-transient errors -// (OAuth 4xx, TLS failures) it marks the workload as unauthenticated and returns -// immediately. Context cancellation (workload removal) stops the retry without -// marking the workload as unauthenticated. +// Token retrieves a token, retrying with exponential backoff on transient +// errors and marking the workload as unauthenticated on non-transient errors. +// See isTransientNetworkError for the classification rule. Context +// cancellation (workload removal) stops the retry without marking the +// workload as unauthenticated. // // Concurrent callers are deduplicated via singleflight so that only one retry // loop runs at a time during transient failures. @@ -264,7 +302,10 @@ func (mts *MonitoredTokenSource) Token() (*oauth2.Token, error) { } if !isTransientNetworkError(err) { - mts.markAsUnauthenticated(fmt.Sprintf("Token retrieval failed: %v", err)) + mts.markAsUnauthenticated( + fmt.Sprintf("Token retrieval failed: %v", err), + isPermanentTokenEndpointError(err), + ) return nil, err } @@ -273,7 +314,10 @@ func (mts *MonitoredTokenSource) Token() (*oauth2.Token, error) { tok, err = mts.refresher.Refresh(mts.monitoringCtx, err) if err != nil { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - mts.markAsUnauthenticated(fmt.Sprintf("Token refresh failed after retries: %v", err)) + mts.markAsUnauthenticated( + fmt.Sprintf("Token refresh failed after retries: %v", err), + isPermanentTokenEndpointError(err), + ) } return nil, err } @@ -342,30 +386,33 @@ func (mts *MonitoredTokenSource) onTick() (bool, time.Duration) { return false, wait } -// isTransientNetworkError reports whether err represents a transient condition -// (DNS failure, TCP transport error, timeout, OAuth server 5xx, unparsable -// token response) that is likely to resolve on its own. +// isTransientNetworkError reports whether err represents a transient +// condition that is likely to resolve on its own. The categories are: +// +// - Network-level failures: DNS lookup errors, TCP transport errors, +// timeouts. +// - OAuth token-endpoint responses classified as transient by +// isTransientRetrieveError. +// - Unparsable token responses on a 2xx status (typically an HTML page +// from a load balancer or CDN). // -// OAuth2 client-level auth failures (invalid_grant, 401, 400) and TLS errors -// (certificate verification, handshake failure) are NOT considered transient and -// return false so the workload is marked unauthenticated immediately. +// All other errors return false, causing the workload to be marked +// unauthenticated. TLS failures (certificate verification, handshake +// failure) are intentionally non-transient even though they surface +// through net.OpError like transport-level errors. +// +// The function is side-effect free; callers that want to emit a DCR +// remediation hint on a permanent 4xx must do so themselves at the +// state-transition boundary using isPermanentTokenEndpointError to +// classify, so a tight Token() loop does not spam the same record. func isTransientNetworkError(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } - // OAuth HTTP-level errors: 5xx (Bad Gateway, Service Unavailable, Gateway - // Timeout) are transient server-side issues that typically resolve on their - // own. 4xx errors (invalid_grant, invalid_client) are permanent auth failures. if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { - if retrieveErr.Response != nil && retrieveErr.Response.StatusCode >= 500 { - slog.Debug("treating OAuth server error as transient", - "status_code", retrieveErr.Response.StatusCode, - ) - return true - } - return false + return isTransientRetrieveError(retrieveErr) } // Non-JSON responses from the OAuth server (e.g. load balancer HTML pages). @@ -399,6 +446,79 @@ func isTransientNetworkError(err error) bool { return false } +// isPermanentTokenEndpointError reports whether err is an *oauth2.RetrieveError +// whose response carries a structured RFC 6749 'error' code, implying the +// OAuth server itself rendered a verdict on the cached credentials +// (invalid_grant, invalid_client, etc.). Used at state-transition +// boundaries to decide whether to emit a DCR/CIMD remediation hint +// alongside the unauthentication. +// +// This is the strict inverse of isTransientRetrieveError on the +// *oauth2.RetrieveError branch: a response is "permanent" iff the +// classifier would NOT call it transient. Concretely, the Warn fires +// only when ErrorCode is populated. 4xx responses without an OAuth +// error code (HTML pages from a WAF, CDN, or reverse proxy) — like +// 5xx, 429, 408, and nil-Response shapes — are treated as +// non-permanent because we have no OAuth-protocol verdict to act on. +// Recommending the user delete cached credentials based on a non- +// spec-compliant response would frequently mislead operators whose +// real problem is upstream of the OAuth server. +func isPermanentTokenEndpointError(err error) bool { + retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err) + if !ok || retrieveErr.Response == nil { + return false + } + return !isTransientRetrieveError(retrieveErr) +} + +// isTransientRetrieveError reports whether an *oauth2.RetrieveError should +// be treated as transient. The classification rules are: +// +// - nil Response: non-transient. There is no signal to act on, so we fall +// through to the unauthenticated path rather than retry blindly. +// - 5xx status: transient (server-side issue, likely to resolve). +// - 429 Too Many Requests: transient regardless of body (HTTP standard). +// - 4xx with an empty ErrorCode: transient. The oauth2 library populates +// ErrorCode from the RFC 6749 'error' field in a JSON response body. An +// empty ErrorCode means the response was not a parseable OAuth error — +// typically an HTML page from a WAF, CDN, or reverse proxy that +// intercepted the request before it reached the OAuth server. These +// infrastructure errors (Cloudflare blocks, residential-IP allowlist +// misses, transient bad-config deploys) commonly resolve on their own. +// - 4xx with a populated ErrorCode: permanent. The OAuth server returned +// a structured error code (invalid_grant, invalid_client, etc.) telling +// us specifically what's wrong; retrying won't help. +func isTransientRetrieveError(retrieveErr *oauth2.RetrieveError) bool { + if retrieveErr.Response == nil { + return false + } + statusCode := retrieveErr.Response.StatusCode + + if statusCode >= 500 { + slog.Debug("treating OAuth server error as transient", + "status_code", statusCode, + ) + return true + } + + if statusCode == http.StatusTooManyRequests { + slog.Debug("treating OAuth rate-limit response as transient", + "status_code", statusCode, + "error_code", retrieveErr.ErrorCode, + ) + return true + } + + if retrieveErr.ErrorCode == "" { + slog.Debug("treating OAuth 4xx without error code as transient", + "status_code", statusCode, + ) + return true + } + + return false +} + // isOAuthParseError detects errors from the oauth2 library that indicate the // token endpoint returned an unparsable response body on a 2xx status. This // typically happens when a load balancer, CDN, or reverse proxy intercepts the @@ -414,13 +534,39 @@ func isOAuthParseError(err error) bool { strings.Contains(msg, "oauth2: cannot parse response") } -// markAsUnauthenticated marks the workload as unauthenticated and stops background monitoring. -func (mts *MonitoredTokenSource) markAsUnauthenticated(reason string) { +// markAsUnauthenticated marks the workload as unauthenticated and stops +// background monitoring. If permanent4xx is true and the workload was +// constructed with a non-empty client_id, a one-shot DCR/CIMD remediation +// hint is emitted alongside the stop transition. The hint and the close +// of stopMonitoring share stopOnce, so a caller (e.g. a tight Token() +// loop) cannot spam the record on every call after the workload has +// already transitioned to Unauthenticated. +func (mts *MonitoredTokenSource) markAsUnauthenticated(reason string, permanent4xx bool) { _ = mts.statusUpdater.SetWorkloadStatus( context.Background(), mts.workloadName, runtime.WorkloadStatusUnauthenticated, reason, ) - mts.stopOnce.Do(func() { close(mts.stopMonitoring) }) + mts.stopOnce.Do(func() { + // A permanent 4xx from the token endpoint commonly indicates the + // cached client (DCR or CIMD) is no longer recognised — but the + // same branch fires for revoked consent, disabled accounts, and + // statically configured clients, so the message has to be honest + // about the variability. Gating on clientID != "" suppresses the + // log entirely for workloads where no client_id context is + // available; the operator-correlation it provides would be empty. + if permanent4xx && mts.clientID != "" { + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Warn( + "token endpoint returned a permanent error; if this workload uses "+ + "cached DCR or CIMD credentials they may be stale — delete the "+ + "cached credentials and restart to re-register.", + "workload", mts.workloadName, + "upstream", mts.upstream, + "client_id", mts.clientID, + ) + } + close(mts.stopMonitoring) + }) } diff --git a/pkg/auth/monitored_token_source_test.go b/pkg/auth/monitored_token_source_test.go index d27b9ef40d..c5e3137222 100644 --- a/pkg/auth/monitored_token_source_test.go +++ b/pkg/auth/monitored_token_source_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "net/url" "os" "strings" @@ -86,7 +87,10 @@ func (m *mockTokenSource) Token() (*oauth2.Token, error) { return tok, err } -// createRetrieveError creates an error for testing token failures +// createRetrieveError creates an error for testing token failures. ErrorCode +// is left unset, mirroring what golang.org/x/oauth2 produces when the response +// body is not a parseable RFC 6749 error response (e.g. an HTML page from a +// WAF or load balancer). func createRetrieveError(statusCode int, body string) *oauth2.RetrieveError { response := &http.Response{ StatusCode: statusCode, @@ -98,6 +102,16 @@ func createRetrieveError(statusCode int, body string) *oauth2.RetrieveError { } } +// createRetrieveErrorWithCode is like createRetrieveError but also sets the +// ErrorCode field, mirroring what golang.org/x/oauth2 populates when the +// server responds with a parseable JSON error body containing an "error" +// field. +func createRetrieveErrorWithCode(statusCode int, errorCode, body string) *oauth2.RetrieveError { + err := createRetrieveError(statusCode, body) + err.ErrorCode = errorCode + return err +} + func TestMonitoredTokenSource_SuccessfulTokenRetrieval(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -118,7 +132,7 @@ func TestMonitoredTokenSource_SuccessfulTokenRetrieval(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Test successful token retrieval token, err := ats.Token() @@ -143,7 +157,7 @@ func TestMonitoredTokenSource_AuthenticationErrorMarksUnauthenticated(t *testing tokenSource := newMockTokenSource() // Create an error that simulates token retrieval failure - retrieveErr := createRetrieveError(http.StatusBadRequest, `{"error":"invalid_grant","error_description":"refresh token expired"}`) + retrieveErr := createRetrieveErrorWithCode(http.StatusBadRequest, "invalid_grant", `{"error":"invalid_grant","error_description":"refresh token expired"}`) tokenSource.setTokenFn(func() (*oauth2.Token, error) { return nil, retrieveErr }) @@ -151,7 +165,7 @@ func TestMonitoredTokenSource_AuthenticationErrorMarksUnauthenticated(t *testing ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Expect SetWorkloadStatus to be called with unauthenticated status statusManager.EXPECT(). @@ -195,7 +209,7 @@ func TestMonitoredTokenSource_ErrorMarksUnauthenticated(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Expect SetWorkloadStatus to be called for any error statusManager.EXPECT(). @@ -238,14 +252,14 @@ func TestMonitoredTokenSource_BackgroundMonitoring(t *testing.T) { }, nil } // Subsequent calls: return authentication error - retrieveErr := createRetrieveError(http.StatusUnauthorized, `{"error":"invalid_token"}`) + retrieveErr := createRetrieveErrorWithCode(http.StatusUnauthorized, "invalid_token", `{"error":"invalid_token"}`) return nil, retrieveErr }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Expect SetWorkloadStatus to be called when auth error occurs statusManager.EXPECT(). @@ -297,7 +311,7 @@ func TestMonitoredTokenSource_BackgroundMonitoringStopsOnAnyError(t *testing.T) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Expect SetWorkloadStatus to be called when any error occurs statusManager.EXPECT(). @@ -341,7 +355,7 @@ func TestMonitoredTokenSource_ExpiredTokenHandling(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) // Should not mark as unauthenticated just for expired token // (oauth2 library should handle refresh; we only mark on actual auth errors) @@ -374,7 +388,7 @@ func TestMonitoredTokenSource_StopMonitoring(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) ats.StartBackgroundMonitoring() // Wait a bit to ensure monitoring started @@ -399,7 +413,7 @@ func TestMonitoredTokenSource_MultipleCallsToToken(t *testing.T) { statusUpdater, statusManager := newMockStatusUpdater(ctrl) tokenSource := newMockTokenSource() - retrieveErr := createRetrieveError(http.StatusUnauthorized, `{"error":"invalid_token"}`) + retrieveErr := createRetrieveErrorWithCode(http.StatusUnauthorized, "invalid_token", `{"error":"invalid_token"}`) tokenSource.setTokenFn(func() (*oauth2.Token, error) { return nil, retrieveErr }) @@ -407,7 +421,7 @@ func TestMonitoredTokenSource_MultipleCallsToToken(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) + ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", "", "", statusUpdater) statusManager.EXPECT(). SetWorkloadStatus( @@ -430,10 +444,14 @@ func TestMonitoredTokenSource_MultipleCallsToToken(t *testing.T) { time.Sleep(50 * time.Millisecond) } -// TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries verifies that -// concurrent Refresh() calls are funnelled through a single retry loop via -// singleflight, so the underlying token source is not hammered by independent -// retry loops ("thundering herd"). +// TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries verifies the +// one property the refresher owns: it calls singleflight.Group.Do with a +// constant key, so concurrent Refresh() invocations are coalesced. Singleflight +// itself is a stable library; we don't re-validate its dedup semantics here. +// +// A regression that swapped the constant "token-refresh" key for something +// per-caller (e.g. a per-upstream key) would let the follower start its own +// flight, doubling the Token() call count, and this test would catch it. func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing.T) { t.Parallel() @@ -443,6 +461,8 @@ func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing Expiry: time.Now().Add(time.Hour), } + // Block the leader inside the singleflight callback so the follower has + // a window to join the in-flight entry. releaseLeader := make(chan struct{}) leaderEntered := make(chan struct{}) var leaderOnce sync.Once @@ -458,11 +478,7 @@ func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing } ctx := context.Background() - refresher := &transientRefresher{ - source: tokenSource, - workload: "test-workload", - newBackOff: fastBackOff, - } + refresher := newTransientRefresher(tokenSource, "test-workload", "", "", fastBackOff) type result struct { tok *oauth2.Token @@ -475,6 +491,8 @@ func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing leaderResult <- result{tok, err} }() + // Wait until the leader is provably inside Token() — only then is it safe + // to spawn a follower that will deterministically join this flight. select { case <-leaderEntered: case <-time.After(5 * time.Second): @@ -487,6 +505,10 @@ func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing followerResult <- result{tok, err} }() + // Short sleep to let the follower call group.Do and join the in-flight + // entry before we release the leader. This is the same technique used by + // golang.org/x/sync's own singleflight tests — singleflight exposes no + // observable "follower is queued" signal. time.Sleep(10 * time.Millisecond) close(releaseLeader) @@ -520,6 +542,8 @@ func TestTransientRefresher_SingleflightDeduplicatesConcurrentRetries(t *testing t.Errorf("follower: expected recovered-token, got %v", follower.tok) } + // The load-bearing assertion: if Refresh used a per-caller key, the + // follower's flight would have called Token() a second time. tokenSource.mu.Lock() calls := tokenSource.callCount tokenSource.mu.Unlock() @@ -562,12 +586,14 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T err error isTransient bool // true → monitor retries; false → monitor marks unauthenticated }{ - // Non-transient: plain and auth-level errors must fail fast. + // Non-transient: plain errors and OAuth protocol failures (4xx with a + // populated RFC 6749 error code) must fail fast. {name: "plain error", err: errors.New("some error"), isTransient: false}, {name: "context.Canceled", err: context.Canceled, isTransient: false}, {name: "context.DeadlineExceeded", err: context.DeadlineExceeded, isTransient: false}, - {name: "oauth2.RetrieveError 401", err: createRetrieveError(http.StatusUnauthorized, "unauthorized"), isTransient: false}, - {name: "oauth2.RetrieveError 400 invalid_grant", err: createRetrieveError(http.StatusBadRequest, "invalid_grant"), isTransient: false}, + {name: "oauth2.RetrieveError 400 invalid_grant", err: createRetrieveErrorWithCode(http.StatusBadRequest, "invalid_grant", `{"error":"invalid_grant"}`), isTransient: false}, + {name: "oauth2.RetrieveError 401 invalid_client", err: createRetrieveErrorWithCode(http.StatusUnauthorized, "invalid_client", `{"error":"invalid_client"}`), isTransient: false}, + {name: "oauth2.RetrieveError 403 unauthorized_client", err: createRetrieveErrorWithCode(http.StatusForbidden, "unauthorized_client", `{"error":"unauthorized_client"}`), isTransient: false}, {name: "oauth2.RetrieveError nil response", err: &oauth2.RetrieveError{}, isTransient: false}, // Transient: network-level errors must be retried. {name: "*net.DNSError timeout", err: &net.DNSError{Err: "i/o timeout", Name: "example.com", IsTimeout: true}, isTransient: true}, @@ -579,6 +605,17 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T {name: "oauth2.RetrieveError 502", err: createRetrieveError(http.StatusBadGateway, "Bad Gateway"), isTransient: true}, {name: "oauth2.RetrieveError 503", err: createRetrieveError(http.StatusServiceUnavailable, "Service Unavailable"), isTransient: true}, {name: "oauth2.RetrieveError 504", err: createRetrieveError(http.StatusGatewayTimeout, "Gateway Timeout"), isTransient: true}, + // Transient: 4xx without an RFC 6749 error code in the body. + // These are infrastructure-level errors (WAF, CDN, proxy) that + // commonly resolve on their own, not OAuth protocol failures. + {name: "oauth2.RetrieveError 401 with HTML body", err: createRetrieveError(http.StatusUnauthorized, "Unauthorized"), isTransient: true}, + {name: "oauth2.RetrieveError 403 WAF block", err: createRetrieveError(http.StatusForbidden, "Cloudflare Firewall Block"), isTransient: true}, + {name: "oauth2.RetrieveError 400 with empty body", err: createRetrieveError(http.StatusBadRequest, ""), isTransient: true}, + {name: "oauth2.RetrieveError 408 request timeout", err: createRetrieveError(http.StatusRequestTimeout, ""), isTransient: true}, + // Transient: 429 Too Many Requests is retryable per HTTP standard + // regardless of body content. + {name: "oauth2.RetrieveError 429 empty body", err: createRetrieveError(http.StatusTooManyRequests, ""), isTransient: true}, + {name: "oauth2.RetrieveError 429 with rate-limit error code", err: createRetrieveErrorWithCode(http.StatusTooManyRequests, "rate_limit_exceeded", `{"error":"rate_limit_exceeded"}`), isTransient: true}, // Transient: unparsable OAuth responses (HTML from load balancer on 200). {name: "oauth2 cannot parse json", err: fmt.Errorf("oauth2: cannot parse json: invalid character '<'"), isTransient: true}, {name: "wrapped oauth2 parse error", err: fmt.Errorf("refresh failed: %w", fmt.Errorf("oauth2: cannot parse json: invalid character '<'")), isTransient: true}, @@ -611,8 +648,7 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T statusUpdater, _ := newMockStatusUpdater(ctrl) retrying := tokenSource.notifyOnCall(2) - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) - ats.refresher.newBackOff = fastBackOff + ats := newMonitoredTokenSourceWithBackOff(ctx, tokenSource, "test-workload", "", "", statusUpdater, fastBackOff) ats.StartBackgroundMonitoring() <-retrying // Ensure the retry loop has been entered before cancelling. @@ -631,8 +667,7 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T Return(nil). Times(1) - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) - ats.refresher.newBackOff = fastBackOff + ats := newMonitoredTokenSourceWithBackOff(ctx, tokenSource, "test-workload", "", "", statusUpdater, fastBackOff) ats.StartBackgroundMonitoring() <-ats.Stopped() // Monitor stops itself after marking unauthenticated. @@ -641,6 +676,145 @@ func TestMonitoredTokenSource_BackgroundMonitor_ErrorClassification(t *testing.T } } +// TestIsPermanentTokenEndpointError verifies that isPermanentTokenEndpointError +// is the strict inverse of classifyOAuthRetrieveError on the +// *oauth2.RetrieveError branch (with a non-nil Response). The DCR/CIMD +// remediation Warn fires only when the OAuth server returned a structured +// RFC 6749 error code; non-spec-compliant responses (HTML pages from a WAF, +// CDN, or reverse proxy) should not trigger that Warn because they carry no +// OAuth-protocol verdict. +// +// Existing Token() / markAsUnauthenticated tests reach this function through +// indirect call paths and yield 100% line coverage, but none of them assert +// on the boolean it returns. This test pins the behavioral contract directly. +func TestIsPermanentTokenEndpointError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + isPermanent bool + }{ + // Not an *oauth2.RetrieveError at all — no OAuth verdict to act on. + {name: "plain error", err: errors.New("some error"), isPermanent: false}, + {name: "*oauth2.RetrieveError with nil Response", err: &oauth2.RetrieveError{}, isPermanent: false}, + // Transient HTTP-level conditions — never permanent. + {name: "5xx server error", err: createRetrieveError(http.StatusInternalServerError, "Internal Server Error"), isPermanent: false}, + {name: "429 Too Many Requests", err: createRetrieveError(http.StatusTooManyRequests, ""), isPermanent: false}, + {name: "408 Request Timeout", err: createRetrieveError(http.StatusRequestTimeout, ""), isPermanent: false}, + // 4xx without an RFC 6749 error code — infrastructure response, no OAuth verdict. + {name: "401 with HTML body (WAF)", err: createRetrieveError(http.StatusUnauthorized, "Unauthorized"), isPermanent: false}, + {name: "403 with HTML body (Cloudflare)", err: createRetrieveError(http.StatusForbidden, "Firewall Block"), isPermanent: false}, + // 4xx with an RFC 6749 error code — OAuth server rendered a verdict. + {name: "400 invalid_grant", err: createRetrieveErrorWithCode(http.StatusBadRequest, "invalid_grant", `{"error":"invalid_grant"}`), isPermanent: true}, + {name: "401 invalid_client", err: createRetrieveErrorWithCode(http.StatusUnauthorized, "invalid_client", `{"error":"invalid_client"}`), isPermanent: true}, + {name: "403 unauthorized_client", err: createRetrieveErrorWithCode(http.StatusForbidden, "unauthorized_client", `{"error":"unauthorized_client"}`), isPermanent: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isPermanentTokenEndpointError(tt.err) + if got != tt.isPermanent { + t.Errorf("isPermanentTokenEndpointError(%v) = %v, want %v", + tt.err, got, tt.isPermanent) + } + }) + } +} + +// TestIsTransientNetworkError_AgainstRealOAuth2Library is a contract test, +// not a unit test of the package's own logic. It pins the assumption that +// isTransientRetrieveError relies on: that golang.org/x/oauth2 populates +// RetrieveError.ErrorCode iff the response carries a parseable RFC 6749 +// 'error' field (whether JSON or form-encoded), and leaves ErrorCode empty +// for non-spec-compliant response shapes (HTML pages from a WAF, CDN, or +// reverse proxy). +// +// Cases here are deliberately limited to response shapes where the +// synthetic test helpers (createRetrieveError, createRetrieveErrorWithCode) +// could plausibly diverge from reality. Cases unambiguously covered by the +// synthetic table (clearly populated ErrorCode JSON, status-code-only +// branches like 5xx and 429) are intentionally not duplicated here. +func TestIsTransientNetworkError_AgainstRealOAuth2Library(t *testing.T) { + t.Parallel() + + const refreshToken = "test-refresh-token" + + tests := []struct { + name string + handler http.HandlerFunc + isTransient bool + }{ + { + // HTML 4xx is the canonical WAF/CDN block shape. Pinning that + // the library leaves ErrorCode empty here is what underpins the + // "infrastructure error" branch of isTransientRetrieveError. + name: "403 with HTML body (Cloudflare WAF)", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Cloudflare Firewall Block")) + }, + isTransient: true, + }, + { + name: "401 with HTML body", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + }, + isTransient: true, + }, + { + // Form-encoded error responses are non-spec but supported by + // the library. Pinning that the library DOES populate ErrorCode + // from form-encoded bodies — a synthetic helper used naively + // (createRetrieveError without WithCode) would lie about this. + name: "400 with form-encoded invalid_grant", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("error=invalid_grant&error_description=refresh+token+expired")) + }, + isTransient: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(tt.handler) + t.Cleanup(server.Close) + + cfg := &oauth2.Config{ + ClientID: "test-client", + ClientSecret: "test-secret", + Endpoint: oauth2.Endpoint{TokenURL: server.URL}, + } + + expired := &oauth2.Token{ + AccessToken: "expired-access-token", + RefreshToken: refreshToken, + Expiry: time.Now().Add(-time.Hour), + } + + _, err := cfg.TokenSource(context.Background(), expired).Token() + if err == nil { + t.Fatalf("expected refresh to fail, got nil error") + } + + got := isTransientNetworkError(err) + if got != tt.isTransient { + t.Errorf("isTransientNetworkError(%v) = %v, want %v", + err, got, tt.isTransient) + } + }) + } +} + // --- background monitor transient-error behaviour --- // TestMonitoredTokenSource_TransientErrorRetriesAndSucceeds verifies that when the @@ -682,8 +856,7 @@ func TestMonitoredTokenSource_TransientErrorRetriesAndSucceeds(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) - ats.refresher.newBackOff = fastBackOff + ats := newMonitoredTokenSourceWithBackOff(ctx, tokenSource, "test-workload", "", "", statusUpdater, fastBackOff) ats.StartBackgroundMonitoring() // Block until the monitor has successfully recovered, then stop it. @@ -722,8 +895,7 @@ func TestMonitoredTokenSource_TransientErrorContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) - ats.refresher.newBackOff = fastBackOff + ats := newMonitoredTokenSourceWithBackOff(ctx, tokenSource, "test-workload", "", "", statusUpdater, fastBackOff) ats.StartBackgroundMonitoring() // Cancel once we know the retry loop is running, then wait for clean exit. @@ -755,7 +927,7 @@ func TestMonitoredTokenSource_TransientThenNonTransientMarksUnauthenticated(t *t Times(1) transientErr := &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}} - nonTransientErr := createRetrieveError(http.StatusUnauthorized, `{"error":"invalid_token"}`) + nonTransientErr := createRetrieveErrorWithCode(http.StatusUnauthorized, "invalid_token", `{"error":"invalid_token"}`) tokenSource.setTokenFn(func() (*oauth2.Token, error) { switch tokenSource.callCount { @@ -777,8 +949,7 @@ func TestMonitoredTokenSource_TransientThenNonTransientMarksUnauthenticated(t *t ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ats := NewMonitoredTokenSource(ctx, tokenSource, "test-workload", statusUpdater) - ats.refresher.newBackOff = fastBackOff + ats := newMonitoredTokenSourceWithBackOff(ctx, tokenSource, "test-workload", "", "", statusUpdater, fastBackOff) ats.StartBackgroundMonitoring() // Monitor stops itself after the non-transient error; wait for that. diff --git a/pkg/auth/oauth/dynamic_registration_test.go b/pkg/auth/oauth/dynamic_registration_test.go deleted file mode 100644 index 2671be7499..0000000000 --- a/pkg/auth/oauth/dynamic_registration_test.go +++ /dev/null @@ -1,748 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - oauthproto "github.com/stacklok/toolhive/pkg/oauth" -) - -func TestDiscoverOIDCEndpointsWithRegistration(t *testing.T) { - t.Parallel() - tests := []struct { - name string - issuer string - response string - expectedError bool - expectedResult *oauthproto.OIDCDiscoveryDocument - }{ - { - name: "valid OIDC discovery with registration endpoint", - issuer: "https://example.com", - response: `{ - "issuer": "{{SERVER_URL}}", - "authorization_endpoint": "{{SERVER_URL}}/oauth/authorize", - "token_endpoint": "{{SERVER_URL}}/oauth/token", - "userinfo_endpoint": "{{SERVER_URL}}/oauth/userinfo", - "jwks_uri": "{{SERVER_URL}}/oauth/jwks", - "registration_endpoint": "{{SERVER_URL}}/oauth/register" - }`, - expectedError: false, - expectedResult: &oauthproto.OIDCDiscoveryDocument{ - AuthorizationServerMetadata: oauthproto.AuthorizationServerMetadata{ - Issuer: "https://example.com", - AuthorizationEndpoint: "https://example.com/oauth/authorize", - TokenEndpoint: "https://example.com/oauth/token", - UserinfoEndpoint: "https://example.com/oauth/userinfo", - JWKSURI: "https://example.com/oauth/jwks", - RegistrationEndpoint: "https://example.com/oauth/register", - }, - }, - }, - { - name: "valid OIDC discovery without registration endpoint", - issuer: "https://example.com", - response: `{ - "issuer": "{{SERVER_URL}}", - "authorization_endpoint": "{{SERVER_URL}}/oauth/authorize", - "token_endpoint": "{{SERVER_URL}}/oauth/token", - "userinfo_endpoint": "{{SERVER_URL}}/oauth/userinfo", - "jwks_uri": "{{SERVER_URL}}/oauth/jwks" - }`, - expectedError: false, - expectedResult: &oauthproto.OIDCDiscoveryDocument{ - AuthorizationServerMetadata: oauthproto.AuthorizationServerMetadata{ - Issuer: "https://example.com", - AuthorizationEndpoint: "https://example.com/oauth/authorize", - TokenEndpoint: "https://example.com/oauth/token", - UserinfoEndpoint: "https://example.com/oauth/userinfo", - JWKSURI: "https://example.com/oauth/jwks", - RegistrationEndpoint: "", - }, - }, - }, - { - name: "invalid issuer URL", - issuer: "not-a-url", - expectedError: true, - }, - { - name: "non-HTTPS issuer", - issuer: "http://example.com", - expectedError: true, - }, - { - name: "localhost HTTP allowed for development", - issuer: "http://localhost:8080", - response: `{ - "issuer": "{{SERVER_URL}}", - "authorization_endpoint": "{{SERVER_URL}}/oauth/authorize", - "token_endpoint": "{{SERVER_URL}}/oauth/token", - "userinfo_endpoint": "{{SERVER_URL}}/oauth/userinfo", - "jwks_uri": "{{SERVER_URL}}/oauth/jwks", - "registration_endpoint": "{{SERVER_URL}}/oauth/register" - }`, - expectedError: false, - expectedResult: &oauthproto.OIDCDiscoveryDocument{ - AuthorizationServerMetadata: oauthproto.AuthorizationServerMetadata{ - Issuer: "http://localhost:8080", - AuthorizationEndpoint: "http://localhost:8080/oauth/authorize", - TokenEndpoint: "http://localhost:8080/oauth/token", - UserinfoEndpoint: "http://localhost:8080/oauth/userinfo", - JWKSURI: "http://localhost:8080/oauth/jwks", - RegistrationEndpoint: "http://localhost:8080/oauth/register", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var server *httptest.Server - var responseTemplate string - - if tt.response != "" { - responseTemplate = tt.response - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle both OIDC and OAuth discovery endpoints - if r.URL.Path == oauthproto.WellKnownOIDCPath || - r.URL.Path == oauthproto.WellKnownOAuthServerPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - // Replace placeholder with actual server URL - response := strings.ReplaceAll(responseTemplate, "{{SERVER_URL}}", server.URL) - w.Write([]byte(response)) - } else { - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - } - - issuer := tt.issuer - if server != nil { - // For test server, use the actual server URL - issuer = server.URL - } - - result, err := DiscoverOIDCEndpoints(context.Background(), issuer) - - if tt.expectedError { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.NotNil(t, result) - if server != nil { - // For test server, we can't predict the exact URLs, so just check structure - assert.NotEmpty(t, result.Issuer) - assert.NotEmpty(t, result.AuthorizationEndpoint) - assert.NotEmpty(t, result.TokenEndpoint) - if tt.expectedResult.RegistrationEndpoint != "" { - assert.NotEmpty(t, result.RegistrationEndpoint) - } - } else { - // For static tests, check exact values - assert.Equal(t, tt.expectedResult.Issuer, result.Issuer) - assert.Equal(t, tt.expectedResult.AuthorizationEndpoint, result.AuthorizationEndpoint) - assert.Equal(t, tt.expectedResult.TokenEndpoint, result.TokenEndpoint) - assert.Equal(t, tt.expectedResult.RegistrationEndpoint, result.RegistrationEndpoint) - } - } - }) - } -} - -func TestNewDynamicClientRegistrationRequest(t *testing.T) { - t.Parallel() - tests := []struct { - name string - scopes []string - callbackPort int - expected *DynamicClientRegistrationRequest - }{ - { - name: "basic request", - scopes: []string{"openid", "profile"}, - callbackPort: 8080, - expected: &DynamicClientRegistrationRequest{ - ClientName: "ToolHive MCP Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - TokenEndpointAuthMethod: "none", - GrantTypes: []string{"authorization_code", "refresh_token"}, - ResponseTypes: []string{"code"}, - Scopes: []string{"openid", "profile"}, - }, - }, - { - name: "empty scopes", - scopes: []string{}, - callbackPort: 8666, - expected: &DynamicClientRegistrationRequest{ - ClientName: "ToolHive MCP Client", - RedirectURIs: []string{"http://localhost:8666/callback"}, - TokenEndpointAuthMethod: "none", - GrantTypes: []string{"authorization_code", "refresh_token"}, - ResponseTypes: []string{"code"}, - Scopes: []string{}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := NewDynamicClientRegistrationRequest(tt.scopes, tt.callbackPort) - - assert.Equal(t, tt.expected.ClientName, result.ClientName) - assert.Equal(t, tt.expected.RedirectURIs, result.RedirectURIs) - assert.Equal(t, tt.expected.TokenEndpointAuthMethod, result.TokenEndpointAuthMethod) - assert.Equal(t, tt.expected.GrantTypes, result.GrantTypes) - assert.Equal(t, tt.expected.ResponseTypes, result.ResponseTypes) - assert.Equal(t, tt.expected.Scopes, result.Scopes) - }) - } -} - -func TestDynamicClientRegistrationRequest_ScopeSerialization(t *testing.T) { - t.Parallel() - - // This test verifies RFC 7591 Section 2 compliance for scope serialization. - // Per the spec, scopes MUST be serialized as a space-delimited string, not a JSON array. - // Empty/nil scopes should result in the scope field being omitted entirely (omitempty), - // which is RFC 7591 compliant since the scope parameter is optional. - - tests := []struct { - name string - scopes []string - shouldOmitScope bool - expectedScopeJSON string // Expected scope field in JSON, empty if omitted - }{ - { - name: "nil scopes should omit scope field entirely", - scopes: nil, - shouldOmitScope: true, - }, - { - name: "empty slice scopes should omit scope field entirely", - scopes: []string{}, - shouldOmitScope: true, - }, - { - name: "single scope should be space-delimited string per RFC 7591", - scopes: []string{"openid"}, - shouldOmitScope: false, - expectedScopeJSON: `"scope":"openid"`, - }, - { - name: "multiple scopes should be space-delimited string per RFC 7591", - scopes: []string{"openid", "profile"}, - shouldOmitScope: false, - expectedScopeJSON: `"scope":"openid profile"`, - }, - { - name: "three scopes should be space-delimited string per RFC 7591", - scopes: []string{"openid", "profile", "email"}, - shouldOmitScope: false, - expectedScopeJSON: `"scope":"openid profile email"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Create request with specified scopes - request := NewDynamicClientRegistrationRequest(tt.scopes, 8666) - - // Marshal to JSON - jsonBytes, err := json.Marshal(request) - require.NoError(t, err, "JSON marshaling should succeed") - - jsonStr := string(jsonBytes) - - // Verify scope field behavior - if tt.shouldOmitScope { - assert.NotContains(t, jsonStr, `"scope"`, - "JSON should NOT contain scope field when scopes are empty/nil (omitempty behavior)") - } else { - assert.Contains(t, jsonStr, tt.expectedScopeJSON, - "JSON should contain expected scope field") - } - - // Verify other required fields are always present - assert.Contains(t, jsonStr, `"redirect_uris"`, "redirect_uris should be present") - assert.Contains(t, jsonStr, `"client_name"`, "client_name should be present") - assert.Contains(t, jsonStr, `"grant_types"`, "grant_types should be present") - }) - } -} - -func TestRegisterClientDynamically(t *testing.T) { - t.Parallel() - tests := []struct { - name string - request *DynamicClientRegistrationRequest - response string - responseStatus int - expectedError bool - expectedResult *DynamicClientRegistrationResponse - }{ - { - name: "successful registration", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - TokenEndpointAuthMethod: "none", - GrantTypes: []string{"authorization_code"}, - ResponseTypes: []string{"code"}, - Scopes: []string{"openid", "profile"}, - }, - response: `{ - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "client_id_issued_at": 1234567890, - "client_secret_expires_at": 0, - "registration_access_token": "reg-token", - "registration_client_uri": "https://example.com/oauth/register/test-client-id" - }`, - responseStatus: http.StatusCreated, - expectedError: false, - expectedResult: &DynamicClientRegistrationResponse{ - ClientID: "test-client-id", - ClientSecret: "test-client-secret", - ClientIDIssuedAt: 1234567890, - ClientSecretExpiresAt: 0, - RegistrationAccessToken: "reg-token", - RegistrationClientURI: "https://example.com/oauth/register/test-client-id", - }, - }, - { - name: "registration without client secret (PKCE flow)", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - TokenEndpointAuthMethod: "none", - GrantTypes: []string{"authorization_code"}, - ResponseTypes: []string{"code"}, - }, - response: `{ - "client_id": "test-client-id", - "client_id_issued_at": 1234567890 - }`, - responseStatus: http.StatusCreated, - expectedError: false, - expectedResult: &DynamicClientRegistrationResponse{ - ClientID: "test-client-id", - ClientIDIssuedAt: 1234567890, - }, - }, - { - name: "server error", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - }, - response: `{"error": "invalid_request", "error_description": "Invalid request"}`, - responseStatus: http.StatusBadRequest, - expectedError: true, - }, - { - name: "DCR not supported - 404 Not Found", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - }, - response: `{"error": "not_found"}`, - responseStatus: http.StatusNotFound, - expectedError: true, - }, - { - name: "DCR not supported - 405 Method Not Allowed", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - }, - response: `{"error": "method_not_allowed"}`, - responseStatus: http.StatusMethodNotAllowed, - expectedError: true, - }, - { - name: "DCR not supported - 501 Not Implemented", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - }, - response: `{"error": "not_implemented", "error_description": "Dynamic Client Registration is not supported"}`, - responseStatus: http.StatusNotImplemented, - expectedError: true, - }, - { - name: "invalid request - no redirect URIs", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - }, - expectedError: true, - }, - { - name: "invalid request - scope with spaces", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - Scopes: []string{"openid", "profile email", "another"}, - }, - expectedError: true, - }, - { - name: "invalid request - scope with leading space", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - Scopes: []string{" openid"}, - }, - expectedError: true, - }, - { - name: "invalid request - scope with trailing space", - request: &DynamicClientRegistrationRequest{ - ClientName: "Test Client", - RedirectURIs: []string{"http://localhost:8080/callback"}, - Scopes: []string{"openid "}, - }, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var server *httptest.Server - if tt.response != "" { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - assert.Equal(t, "application/json", r.Header.Get("Accept")) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(tt.responseStatus) - w.Write([]byte(tt.response)) - })) - defer server.Close() - } - - var registrationEndpoint string - if server != nil { - registrationEndpoint = server.URL - } else { - registrationEndpoint = "https://example.com/oauth/register" - } - - result, err := RegisterClientDynamically(context.Background(), registrationEndpoint, tt.request) - - if tt.expectedError { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.NotNil(t, result) - assert.Equal(t, tt.expectedResult.ClientID, result.ClientID) - assert.Equal(t, tt.expectedResult.ClientSecret, result.ClientSecret) - assert.Equal(t, tt.expectedResult.ClientIDIssuedAt, result.ClientIDIssuedAt) - assert.Equal(t, tt.expectedResult.RegistrationAccessToken, result.RegistrationAccessToken) - assert.Equal(t, tt.expectedResult.RegistrationClientURI, result.RegistrationClientURI) - } - }) - } -} - -func TestDynamicClientRegistrationRequest_Defaults(t *testing.T) { - t.Parallel() - // Test that default values are set correctly - request := &DynamicClientRegistrationRequest{ - RedirectURIs: []string{"http://localhost:8080/callback"}, - } - - // Serialize to JSON to verify defaults - data, err := json.Marshal(request) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - // Verify that required fields are present - assert.Contains(t, result, "redirect_uris") - assert.Equal(t, []interface{}{"http://localhost:8080/callback"}, result["redirect_uris"]) -} - -// TestDynamicClientRegistrationResponse_Validation tests that the response validation works correctly -func TestDynamicClientRegistrationResponse_Validation(t *testing.T) { - t.Parallel() - // Test that response validation works correctly - validResponse := &DynamicClientRegistrationResponse{ - ClientID: "test-client-id", - } - - // Serialize to JSON - data, err := json.Marshal(validResponse) - require.NoError(t, err) - - var result DynamicClientRegistrationResponse - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Equal(t, "test-client-id", result.ClientID) -} - -func TestDiscoverOIDCEndpointsWithRegistrationFallback(t *testing.T) { - t.Parallel() - - // Test case: OIDC well-known succeeds but lacks registration_endpoint, - // OAuth authorization server well-known has it - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - baseURL := "http://" + r.Host - switch r.URL.Path { - case oauthproto.WellKnownOIDCPath: - // OIDC discovery - no registration_endpoint - response := `{ - "issuer": "` + baseURL + `", - "authorization_endpoint": "` + baseURL + `/oauth/authorize", - "token_endpoint": "` + baseURL + `/oauth/token", - "userinfo_endpoint": "` + baseURL + `/oauth/userinfo", - "jwks_uri": "` + baseURL + `/oauth/jwks" - }` - w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) - case oauthproto.WellKnownOAuthServerPath: - // OAuth authorization server - has registration_endpoint - response := `{ - "issuer": "` + baseURL + `", - "authorization_endpoint": "` + baseURL + `/oauth/authorize", - "token_endpoint": "` + baseURL + `/oauth/token", - "registration_endpoint": "` + baseURL + `/oauth/register" - }` - w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - result, err := DiscoverOIDCEndpoints(context.Background(), server.URL) - - require.NoError(t, err) - require.NotNil(t, result) - assert.Equal(t, server.URL, result.Issuer) - assert.NotEmpty(t, result.AuthorizationEndpoint) - assert.NotEmpty(t, result.TokenEndpoint) - // Registration endpoint should be found from OAuth authorization server well-known - assert.NotEmpty(t, result.RegistrationEndpoint, "registration_endpoint should be found via OAuth authorization server fallback") - assert.Equal(t, server.URL+"/oauth/register", result.RegistrationEndpoint) -} - -func TestDiscoverOIDCEndpointsWithRegistrationFallbackIssuerMismatch(t *testing.T) { - t.Parallel() - - // Test case: OIDC and OAuth have different issuers - should not merge - // Use DiscoverActualIssuer which doesn't validate issuer, allowing us to test the merge logic - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - baseURL := "http://" + r.Host - switch r.URL.Path { - case oauthproto.WellKnownOIDCPath: - // OIDC discovery - no registration_endpoint, different issuer - response := `{ - "issuer": "https://oidc.example.com", - "authorization_endpoint": "` + baseURL + `/oauth/authorize", - "token_endpoint": "` + baseURL + `/oauth/token", - "userinfo_endpoint": "` + baseURL + `/oauth/userinfo", - "jwks_uri": "` + baseURL + `/oauth/jwks" - }` - w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) - case oauthproto.WellKnownOAuthServerPath: - // OAuth authorization server - has registration_endpoint but different issuer - response := `{ - "issuer": "https://oauth.example.com", - "authorization_endpoint": "` + baseURL + `/oauth/authorize", - "token_endpoint": "` + baseURL + `/oauth/token", - "registration_endpoint": "` + baseURL + `/oauth/register" - }` - w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - // Use DiscoverActualIssuer which doesn't validate issuer, allowing us to test merge logic - result, err := DiscoverActualIssuer(context.Background(), server.URL) - - require.NoError(t, err) - require.NotNil(t, result) - // Registration endpoint should NOT be merged due to issuer mismatch - assert.Empty(t, result.RegistrationEndpoint, "registration_endpoint should not be merged when issuers don't match") -} - -// TestIsLocalhost is already defined in oidc_test.go - -// TestScopeList_MarshalJSON tests that the ScopeList marshaling works correctly -// and produces RFC 7591 compliant space-delimited strings. -func TestScopeList_MarshalJSON(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - scopes ScopeList - wantJSON string - wantOmit bool // If true, expect omitempty to hide the field - }{ - { - name: "nil scopes => empty string (omitempty will hide at struct level)", - scopes: nil, - wantJSON: `""`, - wantOmit: true, - }, - { - name: "empty slice => empty string (omitempty will hide at struct level)", - scopes: ScopeList{}, - wantJSON: `""`, - wantOmit: true, - }, - { - name: "single scope => string", - scopes: ScopeList{"openid"}, - wantJSON: `"openid"`, - }, - { - name: "two scopes => space-delimited string", - scopes: ScopeList{"openid", "profile"}, - wantJSON: `"openid profile"`, - }, - { - name: "three scopes => space-delimited string", - scopes: ScopeList{"openid", "profile", "email"}, - wantJSON: `"openid profile email"`, - }, - { - name: "scopes with special characters", - scopes: ScopeList{"read:user", "write:repo"}, - wantJSON: `"read:user write:repo"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - jsonBytes, err := json.Marshal(tt.scopes) - require.NoError(t, err, "marshaling should succeed") - - jsonStr := string(jsonBytes) - assert.Equal(t, tt.wantJSON, jsonStr, "marshaled JSON should match expected format") - - // Verify omitempty behavior in a struct - // Note: omitempty checks the Go value (empty slice) before calling MarshalJSON, - // so empty slices are omitted regardless of what MarshalJSON returns. - if tt.wantOmit { - type testStruct struct { - Scope ScopeList `json:"scope,omitempty"` - } - s := testStruct{Scope: tt.scopes} - structJSON, err := json.Marshal(s) - require.NoError(t, err) - assert.Equal(t, "{}", string(structJSON), "omitempty should hide empty scope field") - } - }) - } -} - -// TestScopeList_UnmarshalJSON tests that the ScopeList unmarshaling works correctly. -func TestScopeList_UnmarshalJSON(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - jsonIn string - want []string - wantErr bool - }{ - { - name: "space-delimited string", - jsonIn: `"openid profile email"`, - want: []string{"openid", "profile", "email"}, - }, - { - name: "empty string => nil", - jsonIn: `""`, - want: nil, - }, - { - name: "string with extra spaces", - jsonIn: `" openid profile "`, - want: []string{"openid", "profile"}, - }, - { - name: "normal array", - jsonIn: `["openid","profile","email"]`, - want: []string{"openid", "profile", "email"}, - }, - { - name: "array with whitespace and empties", - jsonIn: `[" openid ",""," profile "]`, - want: []string{"openid", "profile"}, - }, - { - name: "all-empty array => nil", - jsonIn: `[""," "]`, - want: nil, - }, - { - name: "explicit null => nil", - jsonIn: `null`, - want: nil, - }, - { - name: "invalid type (number)", - jsonIn: `123`, - wantErr: true, - }, - { - name: "invalid type (object)", - jsonIn: `{"not":"valid"}`, - wantErr: true, - }, - } - - for _, tt := range tests { - tt := tt // capture loop variable - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var s ScopeList - err := json.Unmarshal([]byte(tt.jsonIn), &s) - - if tt.wantErr { - assert.Error(t, err, "expected error but got none") - return - } - - assert.NoError(t, err, "unexpected unmarshal error") - assert.Equal(t, tt.want, []string(s)) - }) - } -} diff --git a/pkg/auth/oauth/flow.go b/pkg/auth/oauth/flow.go index bb17dd3060..18bca8e1b0 100644 --- a/pkg/auth/oauth/flow.go +++ b/pkg/auth/oauth/flow.go @@ -24,7 +24,7 @@ import ( "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // Config contains configuration for OAuth authentication @@ -503,8 +503,12 @@ func (f *Flow) processToken(_ context.Context, token *oauth2.Token) *TokenResult // Use resourceTokenSource wrapper to add resource parameter to refresh requests (RFC 8707) base = NewResourceTokenSource(f.oauth2Config, token, f.config.Resource) } else { - // No resource parameter needed, use standard token source - base = f.oauth2Config.TokenSource(context.Background(), token) + // No resource parameter needed, use standard token source. Inject an + // HTTP client whose transport sets the ToolHive User-Agent so the + // oauth2 library does not fall back to Go-http-client/2.0 on token + // refresh requests. + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, oauthproto.NewHTTPClient()) + base = f.oauth2Config.TokenSource(ctx, token) } // ReuseTokenSource ensures that refresh happens only when needed diff --git a/pkg/auth/oauth/non_caching_refresher.go b/pkg/auth/oauth/non_caching_refresher.go new file mode 100644 index 0000000000..b0210961b6 --- /dev/null +++ b/pkg/auth/oauth/non_caching_refresher.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauth + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// NonCachingRefresher is an oauth2.TokenSource that always performs a network +// refresh when Token() is called — it holds no internal token cache. +// +// This is the correct innermost source for a preemptive-refresh chain: +// the outer oauth2.ReuseTokenSource provides caching; the inner source must +// always refresh when asked so that one network round-trip occurs per +// preemptive window instead of looping indefinitely. +// +// It handles both standard OAuth 2.0 refresh (resource == "") and RFC 8707 +// resource-indicator refresh (resource != "") in a single type. +// +// Token() is safe for concurrent use. mu serializes access to refreshToken, +// which is updated in place when the IdP rotates it. +// +// When the IdP omits a new refresh token the previous token is preserved so +// the session survives providers that do not rotate on every refresh. +type NonCachingRefresher struct { + mu sync.Mutex + cfg *oauth2.Config + resource string // RFC 8707 resource indicator; empty for standard OAuth 2.0 + refreshToken string + httpClient *http.Client +} + +// NewNonCachingRefresher creates a NonCachingRefresher that refreshes using +// cfg and the given refresh token. resource is the RFC 8707 resource indicator; +// pass "" for standard OAuth 2.0 refresh. +func NewNonCachingRefresher(cfg *oauth2.Config, refreshToken, resource string) *NonCachingRefresher { + return &NonCachingRefresher{ + cfg: cfg, + resource: resource, + refreshToken: refreshToken, + httpClient: oauthproto.NewHTTPClient(), + } +} + +// Token always performs a token-endpoint refresh. It updates the stored refresh +// token when the IdP rotates it so callers (e.g. PersistingTokenSource) can +// detect the change and persist it. +func (n *NonCachingRefresher) Token() (*oauth2.Token, error) { + n.mu.Lock() + defer n.mu.Unlock() + + if n.refreshToken == "" { + return nil, fmt.Errorf("no refresh token available") + } + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, n.httpClient) + + var ( + tok *oauth2.Token + err error + ) + if n.resource != "" { + tok, err = n.refreshWithResource(ctx) + } else { + tok, err = n.refreshStandard(ctx) + } + if err != nil { + return nil, err + } + + if tok.RefreshToken == "" { + tok.RefreshToken = n.refreshToken + } else { + n.refreshToken = tok.RefreshToken + } + + slog.Debug("Token refreshed", "resource", n.resource) + return tok, nil +} + +// refreshStandard uses the library's native tokenRefresher path, which sends +// grant_type=refresh_token without the empty code= parameter that Exchange +// always appends. A fresh TokenSource is constructed on each call so there is +// no internal cache. Scopes are not sent explicitly; servers must preserve them +// per RFC 6749 §6 (MUST). +func (n *NonCachingRefresher) refreshStandard(ctx context.Context) (*oauth2.Token, error) { + // Passing an always-expired token forces the ReuseTokenSource returned by + // cfg.TokenSource to call the inner tokenRefresher immediately. + ts := n.cfg.TokenSource(ctx, &oauth2.Token{ + RefreshToken: n.refreshToken, + Expiry: time.Unix(1, 0), + }) + tok, err := ts.Token() + if err != nil { + return nil, fmt.Errorf("token refresh failed: %w", err) + } + return tok, nil +} + +// refreshWithResource uses cfg.Exchange with overridden grant_type and +// refresh_token parameters to send the RFC 8707 resource= indicator. +// golang.org/x/oauth2 has no native support for resource indicators, so the +// Exchange workaround is unavoidable here. The side-effect empty code= +// parameter from Exchange is acceptable: resource-indicator IdPs are required +// to dispatch on grant_type first and typically tolerate extra parameters. +// Scopes are sent explicitly because some resource-indicator IdPs do not +// preserve them when omitted despite the RFC 6749 §6 MUST requirement. +func (n *NonCachingRefresher) refreshWithResource(ctx context.Context) (*oauth2.Token, error) { + opts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", "refresh_token"), + oauth2.SetAuthURLParam("refresh_token", n.refreshToken), + oauth2.SetAuthURLParam("resource", n.resource), + } + if len(n.cfg.Scopes) > 0 { + opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(n.cfg.Scopes, " "))) + } + tok, err := n.cfg.Exchange(ctx, "", opts...) + if err != nil { + return nil, fmt.Errorf("token refresh failed: %w", err) + } + return tok, nil +} diff --git a/pkg/auth/oauth/oidc.go b/pkg/auth/oauth/oidc.go index 6db03ab9f9..8dbfd3f21e 100644 --- a/pkg/auth/oauth/oidc.go +++ b/pkg/auth/oauth/oidc.go @@ -17,12 +17,9 @@ import ( "time" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) -// UserAgent is the user agent for the ToolHive MCP client -const UserAgent = "ToolHive/1.0" - // DiscoverOIDCEndpoints discovers OAuth endpoints from an OIDC issuer func DiscoverOIDCEndpoints(ctx context.Context, issuer string) (*oauthproto.OIDCDiscoveryDocument, error) { return discoverOIDCEndpointsWithClient(ctx, issuer, nil, false) @@ -76,7 +73,7 @@ func discoverOIDCEndpointsWithClientAndValidation( if err != nil { return nil, fmt.Errorf("build request: %w", err) } - req.Header.Set("User-Agent", UserAgent) + req.Header.Set("User-Agent", oauthproto.UserAgent) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) @@ -127,6 +124,12 @@ func discoverOIDCEndpointsWithClientAndValidation( if oauthDoc.Issuer == doc.Issuer { doc.RegistrationEndpoint = oauthDoc.RegistrationEndpoint slog.Debug("Found registration_endpoint in OAuth authorization server metadata", "endpoint", doc.RegistrationEndpoint) + // Merge CIMD support flag — some servers (e.g. Granola) only advertise + // client_id_metadata_document_supported in the OAuth AS metadata, not + // in the OIDC discovery document. + if oauthDoc.ClientIDMetadataDocumentSupported { + doc.ClientIDMetadataDocumentSupported = true + } } else { slog.Warn("Issuer mismatch between OIDC and OAuth discovery documents, not merging registration_endpoint", "oidc_issuer", doc.Issuer, "oauth_issuer", oauthDoc.Issuer) diff --git a/pkg/auth/oauth/oidc_test.go b/pkg/auth/oauth/oidc_test.go index 6d20f1c3ed..4a523abbb7 100644 --- a/pkg/auth/oauth/oidc_test.go +++ b/pkg/auth/oauth/oidc_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( diff --git a/pkg/auth/oauth/resource_token_source.go b/pkg/auth/oauth/resource_token_source.go index b5a71c3b8a..1bce5eebc9 100644 --- a/pkg/auth/oauth/resource_token_source.go +++ b/pkg/auth/oauth/resource_token_source.go @@ -4,24 +4,16 @@ package oauth import ( - "context" - "fmt" - "log/slog" - "net/http" - "strings" - "time" - "golang.org/x/oauth2" ) -// resourceTokenSource wraps an oauth2.TokenSource and adds the resource parameter -// to token refresh requests per RFC 8707. This is necessary because the standard -// golang.org/x/oauth2 library doesn't support adding custom parameters during refresh. +// resourceTokenSource wraps a NonCachingRefresher with an internal token cache +// and adds the resource parameter to token refresh requests per RFC 8707. +// Token() returns the cached token when still valid and delegates to the +// inner NonCachingRefresher only when a refresh is needed. type resourceTokenSource struct { - config *oauth2.Config - resource string - token *oauth2.Token - httpClient *http.Client + ncr *NonCachingRefresher + token *oauth2.Token } // NewResourceTokenSource creates a token source that includes the resource parameter @@ -29,26 +21,20 @@ type resourceTokenSource struct { // The resource parameter must be non-empty (caller should check before calling). func NewResourceTokenSource(config *oauth2.Config, token *oauth2.Token, resource string) oauth2.TokenSource { return &resourceTokenSource{ - config: config, - resource: resource, - token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + ncr: NewNonCachingRefresher(config, token.RefreshToken, resource), + token: token, } } // Token returns a valid token, refreshing it if necessary. -// When refreshing, it adds the resource parameter to the request. +// When refreshing, it delegates to NonCachingRefresher which adds the resource +// parameter per RFC 8707. func (r *resourceTokenSource) Token() (*oauth2.Token, error) { - // If token is still valid, return it if r.token.Valid() { return r.token, nil } - // Token expired, refresh with resource parameter - ctx := context.Background() - newToken, err := r.refreshWithResource(ctx) + newToken, err := r.ncr.Token() if err != nil { return nil, err } @@ -56,48 +42,3 @@ func (r *resourceTokenSource) Token() (*oauth2.Token, error) { r.token = newToken return newToken, nil } - -// refreshWithResource performs token refresh with the resource parameter per RFC 8707. -func (r *resourceTokenSource) refreshWithResource(ctx context.Context) (*oauth2.Token, error) { - if r.token == nil || r.token.RefreshToken == "" { - return nil, fmt.Errorf("no refresh token available") - } - - // Use x/oauth2 internals via Exchange() so we get: - // - expires_in -> Expiry handling - // - AuthStyle from the Config's Endpoint (AuthStyleInParams) - // - structured error parsing via oauth2.RetrieveError - // - // Note: Exchange() will include an empty "code" parameter in the body. - // This is a known workaround for adding custom refresh params. - refreshToken := r.token.RefreshToken - opts := []oauth2.AuthCodeOption{ - oauth2.SetAuthURLParam("grant_type", "refresh_token"), - oauth2.SetAuthURLParam("refresh_token", refreshToken), - oauth2.SetAuthURLParam("resource", r.resource), - } - - // Include scope in refresh request. RFC 6749 section 6 says servers MUST - // preserve scopes when omitted, but not all do. Being explicit is safe. - if len(r.config.Scopes) > 0 { - opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(r.config.Scopes, " "))) - } - - ctx = context.WithValue(ctx, oauth2.HTTPClient, r.httpClient) - - token, err := r.config.Exchange(ctx, "", opts...) - if err != nil { - return nil, fmt.Errorf("token refresh with resource parameter failed: %w", err) - } - - // Preserve refresh token if the server does not return a new one. - if token.RefreshToken == "" { - token.RefreshToken = refreshToken - } - - slog.Debug("Token refreshed with resource parameter", - "resource", r.resource, - ) - - return token, nil -} diff --git a/pkg/auth/oauth/resource_token_source_test.go b/pkg/auth/oauth/resource_token_source_test.go index d21ed2612b..3fcb4599a5 100644 --- a/pkg/auth/oauth/resource_token_source_test.go +++ b/pkg/auth/oauth/resource_token_source_test.go @@ -14,6 +14,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" ) func clientCredentialsFromRequest(r *http.Request) (clientID, clientSecret string) { @@ -52,13 +54,12 @@ func TestNewResourceTokenSource(t *testing.T) { ts := NewResourceTokenSource(config, validToken, "https://api.example.com") require.NotNil(t, ts) - // Should be our custom type rts, ok := ts.(*resourceTokenSource) require.True(t, ok, "expected resourceTokenSource type") - assert.Equal(t, "https://api.example.com", rts.resource) - assert.Equal(t, config, rts.config) - assert.NotNil(t, rts.httpClient) - assert.Equal(t, 30*time.Second, rts.httpClient.Timeout) + assert.Equal(t, "https://api.example.com", rts.ncr.resource) + assert.Equal(t, config, rts.ncr.cfg) + assert.NotNil(t, rts.ncr.httpClient) + assert.Equal(t, 30*time.Second, rts.ncr.httpClient.Timeout) }) t.Run("stores token reference", func(t *testing.T) { @@ -165,6 +166,7 @@ func TestResourceTokenSource_Token_ExpiredToken(t *testing.T) { require.NotNil(t, capturedRequest) assert.Equal(t, "POST", capturedRequest.Method) assert.Equal(t, "application/x-www-form-urlencoded", capturedRequest.Header.Get("Content-Type")) + assert.Equal(t, oauthproto.UserAgent, capturedRequest.Header.Get("User-Agent")) }) t.Run("includes client credentials in refresh request", func(t *testing.T) { @@ -300,7 +302,7 @@ func TestResourceTokenSource_RefreshErrors(t *testing.T) { ts := NewResourceTokenSource(config, expiredToken, "https://api.example.com") _, err := ts.Token() require.Error(t, err) - assert.Contains(t, err.Error(), "token refresh with resource parameter failed") + assert.Contains(t, err.Error(), "token refresh failed") }) t.Run("returns error on invalid JSON response", func(t *testing.T) { @@ -328,7 +330,7 @@ func TestResourceTokenSource_RefreshErrors(t *testing.T) { ts := NewResourceTokenSource(config, expiredToken, "https://api.example.com") _, err := ts.Token() require.Error(t, err) - assert.Contains(t, err.Error(), "token refresh with resource parameter failed") + assert.Contains(t, err.Error(), "token refresh failed") }) t.Run("returns error when token endpoint is unreachable", func(t *testing.T) { @@ -350,7 +352,7 @@ func TestResourceTokenSource_RefreshErrors(t *testing.T) { ts := NewResourceTokenSource(config, expiredToken, "https://api.example.com") _, err := ts.Token() require.Error(t, err) - assert.Contains(t, err.Error(), "token refresh with resource parameter failed") + assert.Contains(t, err.Error(), "token refresh failed") }) t.Run("returns error on non-200 status codes", func(t *testing.T) { @@ -391,7 +393,7 @@ func TestResourceTokenSource_RefreshErrors(t *testing.T) { ts := NewResourceTokenSource(config, expiredToken, "https://api.example.com") _, err := ts.Token() require.Error(t, err) - assert.Contains(t, err.Error(), "token refresh with resource parameter failed") + assert.Contains(t, err.Error(), "token refresh failed") }) } }) @@ -434,15 +436,15 @@ func TestResourceTokenSource_HTTPClientReuse(t *testing.T) { rts := ts.(*resourceTokenSource) // Verify HTTP client is created - require.NotNil(t, rts.httpClient) - client1 := rts.httpClient + require.NotNil(t, rts.ncr.httpClient) + client1 := rts.ncr.httpClient // First refresh _, err := ts.Token() require.NoError(t, err) // Verify same client instance - assert.Same(t, client1, rts.httpClient, "HTTP client should be reused") + assert.Same(t, client1, rts.ncr.httpClient, "HTTP client should be reused") assert.Equal(t, 1, callCount) }) @@ -465,7 +467,7 @@ func TestResourceTokenSource_HTTPClientReuse(t *testing.T) { ts := NewResourceTokenSource(config, token, "https://api.example.com") rts := ts.(*resourceTokenSource) - assert.Equal(t, 30*time.Second, rts.httpClient.Timeout) + assert.Equal(t, 30*time.Second, rts.ncr.httpClient.Timeout) }) } diff --git a/pkg/auth/obo/errors.go b/pkg/auth/obo/errors.go new file mode 100644 index 0000000000..8b51056028 --- /dev/null +++ b/pkg/auth/obo/errors.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package obo + +import "errors" + +// ErrEnterpriseRequired is returned by every default OBO dispatch point — the +// controllerutil handler hook, the vMCP converter stub, and the middleware +// stub — when no out-of-tree handler/factory has been registered. Callers must +// use errors.Is to compare; the error wraps cleanly through +// fmt.Errorf("...: %w", ...). +// +// Lives in pkg/auth/obo (a leaf package) so that callers in +// cmd/thv-operator/... and pkg/vmcp/... can share the same sentinel without +// either layer importing the other. To register an out-of-tree handler, see +// controllerutil.RegisterOBOHandler (for the operator dispatch points) and +// obo.RegisterFactory (for the proxy middleware factory). +var ErrEnterpriseRequired = errors.New( + "on-behalf-of (OBO) external auth type requires an enterprise build") + +// ValidationError is the typed error an OBO handler returns when its input +// is genuinely malformed and the user must fix the spec for the failure to +// clear. It is the contract for the "permanent, user-fix" bucket in the +// OBOHandler error triage: +// +// - errors.Is(err, ErrEnterpriseRequired) → not licensed; permanent until an +// out-of-tree handler is registered. +// - errors.As(err, &*ValidationError) → permanent until the user edits the +// spec; the operator writes condition.Reason=InvalidConfig and does not +// requeue. +// - anything else → treated as transient by the reconciler, which returns +// the error so controller-runtime requeues with backoff. +// +// Handler authors must use ValidationError for any condition the user can +// fix by editing the spec (missing field, malformed URL, schema violation) +// and must return a non-ValidationError for transient I/O failures (Secret +// not yet available, JWKS unreachable, webhook 5xx) so the reconciler +// retries instead of locking the resource into a permanent InvalidConfig +// state. +// +// The Message field is written verbatim into condition.Message — handler +// authors are responsible for ensuring it is safe to expose (no Secret +// names, no internal addressing, no credential fragments). +type ValidationError struct { + Message string +} + +// Error returns the user-facing message verbatim. The OBO branch in the +// MCPExternalAuthConfig reconciler writes this string into the Valid +// condition's Message field. +func (e *ValidationError) Error() string { return e.Message } diff --git a/pkg/auth/obo/errors_test.go b/pkg/auth/obo/errors_test.go new file mode 100644 index 0000000000..261787ee04 --- /dev/null +++ b/pkg/auth/obo/errors_test.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package obo + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrEnterpriseRequired_IsSentinel(t *testing.T) { + t.Parallel() + + // Wrapping the sentinel and unwrapping with errors.Is must work both + // directly and through fmt.Errorf("...: %w", ...). + wrapped := fmt.Errorf("outer wrap: %w", ErrEnterpriseRequired) + assert.ErrorIs(t, wrapped, ErrEnterpriseRequired) +} + +func TestValidationError_ErrorAndAsThroughWrap(t *testing.T) { + t.Parallel() + + original := &ValidationError{Message: "audience must be a non-empty URL"} + + // Error() returns the Message verbatim — the reconciler writes this + // string into condition.Message, so handler authors control the + // kubectl-describe output through this field. + assert.Equal(t, "audience must be a non-empty URL", original.Error()) + + // errors.As must match the typed error through a fmt.Errorf wrap so + // handler authors can wrap with extra context without breaking the + // reconciler's triage. + wrapped := fmt.Errorf("validating obo spec: %w", original) + var got *ValidationError + require.True(t, errors.As(wrapped, &got), + "errors.As must match *ValidationError through a fmt.Errorf wrap") + assert.Same(t, original, got, + "errors.As must return the same pointer that was wrapped") + + // errors.Is(err, ErrEnterpriseRequired) must NOT match a ValidationError + // — they live in different buckets of the OBOHandler error contract. + assert.False(t, errors.Is(original, ErrEnterpriseRequired), + "ValidationError must not be confused with the EnterpriseRequired sentinel") + assert.False(t, errors.Is(wrapped, ErrEnterpriseRequired), + "wrapped ValidationError must not be confused with the EnterpriseRequired sentinel") +} diff --git a/pkg/auth/obo/middleware.go b/pkg/auth/obo/middleware.go new file mode 100644 index 0000000000..ce527355bc --- /dev/null +++ b/pkg/auth/obo/middleware.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package obo provides the proxy-runtime middleware factory hook for the +// on-behalf-of (OBO) external auth type. The default factory produces a +// stub middleware that responds 503 to every request. An out-of-tree build +// replaces the factory by calling RegisterFactory once during init(). +package obo + +import ( + "net/http" + "sync" + + "github.com/stacklok/toolhive/pkg/transport/types" +) + +// MiddlewareType is the type identifier used in MiddlewareConfig.Type for +// OBO middleware. Matches the ExternalAuthType constant value "obo". +const MiddlewareType = "obo" + +// stubMessage is the body returned by the default 503 handler. It is shared +// between the default handler and tests in this package so the exact literal +// is only spelled once. Unexported because no caller outside this package has +// a use for it — tests reference it directly. +const stubMessage = "obo requires a build with a registered OBO middleware factory" + +// stub is the default Middleware implementation. Its handler responds 503 +// with a body explaining that the OBO middleware factory has not been +// registered. It is never instantiated except by the default CreateMiddleware +// factory, which an out-of-tree build replaces via RegisterFactory. +type stub struct{} + +// Handler returns a MiddlewareFunction that wraps any downstream handler with +// a 503 response. The downstream handler is intentionally never called — the +// stub is the user-visible signal that the OBO middleware factory has not +// been registered. +func (*stub) Handler() types.MiddlewareFunction { + return func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, stubMessage, http.StatusServiceUnavailable) + }) + } +} + +// Close is a no-op for the stub middleware. +func (*stub) Close() error { return nil } + +// factoryMu guards reads and writes of currentFactory. Each call to +// CreateMiddleware takes the read lock; RegisterFactory takes the write lock. +// The mutex makes hot-reload / re-registration safe in case a future caller +// (e.g. an admission webhook) wires it up, even though production today only +// ever writes during init(). +var factoryMu sync.RWMutex + +// currentFactory holds the actual middleware factory dispatched to by +// CreateMiddleware. The default returns a 503 stub; an out-of-tree build +// replaces it via RegisterFactory. +var currentFactory types.MiddlewareFactory = DefaultFactory + +// DefaultFactory adds a stub middleware whose handler responds 503 to every +// request. Exposed primarily so external test code (e.g. pkg/runner) can pass +// it to RegisterFactory in a t.Cleanup to restore the package's default +// behavior after a test mutates currentFactory. +func DefaultFactory(config *types.MiddlewareConfig, runner types.MiddlewareRunner) error { + runner.AddMiddleware(config.Type, &stub{}) + return nil +} + +// CreateMiddleware is the package-level middleware factory. It is a stable +// indirection over currentFactory: each call dispatches to whatever factory +// is registered at call time, so out-of-tree builds replacing the factory +// via RegisterFactory take effect on subsequent calls even if a caller has +// already captured CreateMiddleware itself (e.g. pkg/runner builds its +// factory map once and reuses it across runner instances). The default +// produces a 503 stub. +// +// Declared as a function (matching sibling middleware packages such as +// awssts, upstreamswap, and oauthproto/tokenexchange) so RegisterFactory is +// the only mutation path — there is no second escape hatch via direct +// assignment to CreateMiddleware. +func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRunner) error { + factoryMu.RLock() + f := currentFactory + factoryMu.RUnlock() + return f(config, runner) +} + +// RegisterFactory replaces the underlying middleware factory. Calling it +// more than once is allowed and last-write-wins, matching the existing +// pkg/config.RegisterProviderFactory precedent. Panics if f is nil — a nil +// factory would dispatch into a nil function on the next CreateMiddleware +// call, far from the registration site; surface the problem at init() time +// instead. +func RegisterFactory(f types.MiddlewareFactory) { + if f == nil { + panic("obo.RegisterFactory: factory is nil") + } + factoryMu.Lock() + defer factoryMu.Unlock() + currentFactory = f +} diff --git a/pkg/auth/obo/middleware_test.go b/pkg/auth/obo/middleware_test.go new file mode 100644 index 0000000000..197059be4a --- /dev/null +++ b/pkg/auth/obo/middleware_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package obo + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/transport/types/mocks" +) + +// withDefaultFactory captures the underlying middleware factory and restores +// it on cleanup so tests that call RegisterFactory do not leak state to +// other tests in the package. Capture and restore both pass through +// factoryMu so they participate in the same synchronization contract as +// production reads/writes. +func withDefaultFactory(t *testing.T) { + t.Helper() + factoryMu.RLock() + original := currentFactory + factoryMu.RUnlock() + t.Cleanup(func() { + factoryMu.Lock() + currentFactory = original + factoryMu.Unlock() + }) +} + +func TestMiddlewareType(t *testing.T) { + t.Parallel() + + assert.Equal(t, "obo", MiddlewareType, + "MiddlewareType must equal the ExternalAuthType value 'obo'") +} + +func TestDefaultCreateMiddleware_AddsStub(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + mockRunner.EXPECT().AddMiddleware(MiddlewareType, gomock.Any()).Times(1) + + cfg := &types.MiddlewareConfig{Type: MiddlewareType} + require.NoError(t, CreateMiddleware(cfg, mockRunner)) +} + +func TestStubHandler_Returns503(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + downstream http.Handler + method string + path string + }{ + { + name: "nil downstream handler is ignored", + downstream: nil, + method: http.MethodGet, + path: "/anything", + }, + { + name: "non-nil downstream handler is not invoked", + downstream: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // If this is ever reached, the stub leaked through — the test + // asserts on the response code below to catch that. + w.WriteHeader(http.StatusTeapot) + }), + method: http.MethodPost, + path: "/whatever", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := &stub{} + wrapper := s.Handler() + require.NotNil(t, wrapper) + + h := wrapper(tt.downstream) + require.NotNil(t, h) + + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code, + "stub handler must respond 503, not call downstream") + // http.Error appends a newline to the supplied message; assert on + // the exact body so the cross-file contract with stubMessage stays + // pinned (any change to stubMessage that wasn't intentional + // breaks this test loudly). + assert.Equal(t, stubMessage+"\n", rec.Body.String(), + "stub handler must echo the package stubMessage constant") + }) + } +} + +func TestStub_Close(t *testing.T) { + t.Parallel() + + assert.NoError(t, (&stub{}).Close(), "stub Close must be a no-op") +} + +//nolint:paralleltest // Mutates package-level currentFactory; must not race other tests. +func TestRegisterFactory_ReplacesDefault(t *testing.T) { + withDefaultFactory(t) + + var called bool + replacement := types.MiddlewareFactory(func(*types.MiddlewareConfig, types.MiddlewareRunner) error { + called = true + return nil + }) + RegisterFactory(replacement) + + // Calling through CreateMiddleware after RegisterFactory must dispatch to + // the replacement. + require.NoError(t, CreateMiddleware(&types.MiddlewareConfig{Type: MiddlewareType}, nil)) + assert.True(t, called, "RegisterFactory must replace the underlying factory") +} + +//nolint:paralleltest // Mutates package-level currentFactory; must not race other tests. +func TestRegisterFactory_LastWriteWins(t *testing.T) { + withDefaultFactory(t) + + sentinel := errors.New("second factory") + RegisterFactory(func(*types.MiddlewareConfig, types.MiddlewareRunner) error { + return errors.New("first factory") + }) + RegisterFactory(func(*types.MiddlewareConfig, types.MiddlewareRunner) error { + return sentinel + }) + + err := CreateMiddleware(&types.MiddlewareConfig{Type: MiddlewareType}, nil) + require.ErrorIs(t, err, sentinel, "second RegisterFactory must overwrite the first") +} + +func TestRegisterFactory_PanicsOnNil(t *testing.T) { + t.Parallel() + + assert.Panics(t, func() { RegisterFactory(nil) }, + "RegisterFactory must panic when the supplied factory is nil") +} diff --git a/pkg/auth/remote/config.go b/pkg/auth/remote/config.go index 98c8fecd2f..8675036dda 100644 --- a/pkg/auth/remote/config.go +++ b/pkg/auth/remote/config.go @@ -71,6 +71,13 @@ type Config struct { // RegistrationAccessToken is used to update/delete the client registration. // Stored as a secret reference since it's sensitive. CachedRegTokenRef string `json:"cached_reg_token_ref,omitempty" yaml:"cached_reg_token_ref,omitempty"` + + // CachedCIMDClientID stores the CIMD metadata URL used as client_id when CIMD + // authentication was used. Kept separate from CachedClientID (which holds + // DCR-issued IDs) so the two can have independent lifecycles — DCR credential + // rotation clears CachedClientID without touching the stable CIMD URL. + // Read by resolveClientCredentials to send the correct client_id on token refresh. + CachedCIMDClientID string `json:"cached_cimd_client_id,omitempty" yaml:"cached_cimd_client_id,omitempty"` } // BearerTokenEnvVarName is the environment variable name used for bearer token authentication. @@ -164,7 +171,14 @@ func (c *Config) HasCachedClientCredentials() bool { return c.CachedClientID != "" } +// HasCachedCIMDClientID returns true if a CIMD client_id was cached from a prior session. +func (c *Config) HasCachedCIMDClientID() bool { + return c.CachedCIMDClientID != "" +} + // ClearCachedClientCredentials removes any cached DCR client credential references from the config. +// It does not clear CachedCIMDClientID — the CIMD URL is a stable constant that does not +// need to be rotated alongside DCR secrets. func (c *Config) ClearCachedClientCredentials() { c.CachedClientID = "" c.CachedClientSecretRef = "" @@ -172,6 +186,30 @@ func (c *Config) ClearCachedClientCredentials() { c.CachedRegTokenRef = "" } +// LogContext returns the upstream issuer and resolved client_id for use as +// log-correlation fields on the MonitoredTokenSource. Returns ("", "") +// when c is nil so callers do not need to guard the call. Mirrors the +// precedence applied at runtime when sending the client_id on token +// refresh: cached CIMD URL > cached DCR client_id > statically configured +// client_id. Lives next to resolveClientCredentials so the precedence has +// a single home — adding a fourth cached field updates both call sites +// in one place. +func (c *Config) LogContext() (upstream, clientID string) { + if c == nil { + return "", "" + } + clientID = func() string { + if c.CachedCIMDClientID != "" { + return c.CachedCIMDClientID + } + if c.CachedClientID != "" { + return c.CachedClientID + } + return c.ClientID + }() + return c.Issuer, clientID +} + // DefaultResourceIndicator derives the resource indicator (RFC 8707) from the remote server URL. // This function should only be called when the user has not explicitly provided a resource indicator. // If the resource indicator cannot be derived, it returns an empty string. diff --git a/pkg/auth/remote/config_test.go b/pkg/auth/remote/config_test.go index c6c014ed26..872c954579 100644 --- a/pkg/auth/remote/config_test.go +++ b/pkg/auth/remote/config_test.go @@ -204,6 +204,37 @@ func TestConfig_HasCachedClientCredentials(t *testing.T) { } } +func TestConfig_HasCachedCIMDClientID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + expected bool + }{ + { + name: "no cached CIMD client_id", + config: Config{}, + expected: false, + }, + { + name: "has cached CIMD client_id", + config: Config{ + CachedCIMDClientID: "https://toolhive.dev/oauth/client-metadata.json", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tt.config.HasCachedCIMDClientID() + assert.Equal(t, tt.expected, result) + }) + } +} + func TestConfig_ClearCachedClientCredentials(t *testing.T) { t.Parallel() @@ -221,3 +252,76 @@ func TestConfig_ClearCachedClientCredentials(t *testing.T) { t.Errorf("CachedClientSecretRef should be empty, got %s", config.CachedClientSecretRef) } } + +func TestConfig_LogContext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *Config + wantUpstream string + wantClientID string + }{ + { + name: "nil config returns empty strings", + cfg: nil, + wantUpstream: "", + wantClientID: "", + }, + { + name: "static client_id when no cache", + cfg: &Config{ + Issuer: "https://idp.example.com", + ClientID: "static-client-id", + }, + wantUpstream: "https://idp.example.com", + wantClientID: "static-client-id", + }, + { + name: "cached DCR client_id wins over static", + cfg: &Config{ + Issuer: "https://idp.example.com", + ClientID: "static-client-id", + CachedClientID: "dcr-client-id", + }, + wantUpstream: "https://idp.example.com", + wantClientID: "dcr-client-id", + }, + { + name: "cached CIMD client_id wins over DCR and static", + cfg: &Config{ + Issuer: "https://idp.example.com", + ClientID: "static-client-id", + CachedClientID: "dcr-client-id", + CachedCIMDClientID: "https://idp.example.com/cimd", + }, + wantUpstream: "https://idp.example.com", + wantClientID: "https://idp.example.com/cimd", + }, + { + name: "cached CIMD client_id used when no DCR or static", + cfg: &Config{ + Issuer: "https://idp.example.com", + CachedCIMDClientID: "https://idp.example.com/cimd", + }, + wantUpstream: "https://idp.example.com", + wantClientID: "https://idp.example.com/cimd", + }, + { + name: "empty config returns empty fields", + cfg: &Config{}, + wantUpstream: "", + wantClientID: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + upstream, clientID := tc.cfg.LogContext() + assert.Equal(t, tc.wantUpstream, upstream) + assert.Equal(t, tc.wantClientID, clientID) + }) + } +} diff --git a/pkg/auth/remote/handler.go b/pkg/auth/remote/handler.go index ccce3fb17b..ce7d271288 100644 --- a/pkg/auth/remote/handler.go +++ b/pkg/auth/remote/handler.go @@ -5,12 +5,15 @@ package remote import ( "context" + "errors" "fmt" "log/slog" + "strings" "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth/discovery" + "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/secrets" ) @@ -131,15 +134,29 @@ func (h *Handler) performOAuthFlow( ) (oauth2.TokenSource, error) { slog.Debug("Starting OAuth authentication flow", "issuer", issuer) - // Create OAuth flow config + // Client registration priority (MCP spec: stored credentials → CIMD → DCR): + // Priority 1: Pre-configured credentials — set by buildOAuthFlowConfig from h.config.ClientID/ClientSecret. + // Priority 2: CIMD — AS advertises support and no credentials are set; use metadata URL as client_id. + // Priority 3: DCR — PerformOAuthFlow handles this when ClientID is still empty after the above. flowConfig := h.buildOAuthFlowConfig(scopes, authServerInfo) + if shouldUseCIMD(authServerInfo, flowConfig) { + flowConfig.ClientID = oauthproto.ToolHiveClientMetadataDocumentURL + slog.Debug("Using CIMD client_id", "url", oauthproto.ToolHiveClientMetadataDocumentURL) + } result, err := discovery.PerformOAuthFlow(ctx, issuer, flowConfig) + if err != nil { + // If we used CIMD and it was rejected, we need to retry with DCR. + if flowConfig.ClientID == oauthproto.ToolHiveClientMetadataDocumentURL && isCIMDRejectionError(err) { + slog.Warn("CIMD client_id rejected by AS, retrying with DCR", "issuer", issuer, "error", err) + flowConfig.ClientID = "" + result, err = discovery.PerformOAuthFlow(ctx, issuer, flowConfig) + } + } if err != nil { return nil, err } - // Persist and wrap the token source return h.wrapWithPersistence(result), nil } @@ -187,7 +204,9 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2. // Persist DCR client credentials if available (for servers that use Dynamic Client Registration) // Only persist if client_id exists - client_secret may be empty for PKCE flows - if h.clientCredentialsPersister != nil && result.ClientID != "" { + // CIMD client IDs (HTTPS URLs) are stable constants and are stored separately below. + if h.clientCredentialsPersister != nil && result.ClientID != "" && + !oauthproto.IsClientIDMetadataDocumentURL(result.ClientID) { if err := h.clientCredentialsPersister(result.ClientID, result.ClientSecret); err != nil { slog.Warn("Failed to persist DCR client credentials", "error", err) } else { @@ -195,6 +214,13 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2. } } + // Persist the CIMD metadata URL separately so it can be used as client_id + // on token refresh without conflating it with DCR-issued credentials. + if oauthproto.IsClientIDMetadataDocumentURL(result.ClientID) { + h.config.CachedCIMDClientID = result.ClientID + slog.Debug("Persisted CIMD client_id for future restarts", "url", result.ClientID) + } + // Wrap the token source to persist refreshed tokens tokenSource := result.TokenSource if h.tokenPersister != nil { @@ -211,6 +237,15 @@ func (h *Handler) resolveClientCredentials(ctx context.Context) (clientID, clien clientID = h.config.ClientID clientSecret = h.config.ClientSecret + // If CIMD was used in a prior session, use the cached metadata URL as client_id. + // CIMD clients have no secret (token_endpoint_auth_method=none). + // Checked before DCR so that DCR credential rotation does not change which + // client_id is sent on token refresh. + if h.config.HasCachedCIMDClientID() { + slog.Debug("Using cached CIMD client_id", "url", h.config.CachedCIMDClientID) + return h.config.CachedCIMDClientID, "" + } + // If we have cached DCR client credentials, use those instead if h.config.HasCachedClientCredentials() { // ClientID is stored as plain text (it's public information) @@ -317,18 +352,23 @@ func (h *Handler) discoverIssuerAndScopes( authInfo *discovery.AuthInfo, remoteURL string, ) (string, []string, *discovery.AuthServerInfo, error) { - // Priority 1: Use configured issuer if available + // Priority 1: Use configured issuer if available. Fetch discovery to populate + // AuthServerInfo (including ClientIDMetadataDocumentSupported) even when the + // issuer is pre-configured, so CIMD detection works on this path. if h.config.Issuer != "" { slog.Debug("Using configured issuer", "issuer", h.config.Issuer) - return h.config.Issuer, h.config.Scopes, nil, nil + authServerInfo, _ := discovery.ValidateAndDiscoverAuthServer(ctx, h.config.Issuer) + return h.config.Issuer, h.config.Scopes, authServerInfo, nil } - // Priority 2: Try to derive from realm (RFC 8414) + // Priority 2: Try to derive from realm (RFC 8414). Fetch discovery for the + // same reason as Priority 1 — the realm path skips resource metadata discovery. if authInfo.Realm != "" { derivedIssuer := discovery.DeriveIssuerFromRealm(authInfo.Realm) if derivedIssuer != "" { slog.Debug("Derived issuer from realm", "issuer", derivedIssuer) - return derivedIssuer, h.config.Scopes, nil, nil + authServerInfo, _ := discovery.ValidateAndDiscoverAuthServer(ctx, derivedIssuer) + return derivedIssuer, h.config.Scopes, authServerInfo, nil } } @@ -457,3 +497,42 @@ func (h *Handler) tryDiscoverFromWellKnown( return authServerInfo.Issuer, scopes, authServerInfo, nil } + +// shouldUseCIMD reports whether the CIMD client_id should be presented to the AS. +// The AS must advertise CIMD support and no pre-configured credentials may be set. +// Mirrors shouldDynamicallyRegisterClient in pkg/auth/discovery for consistency. +func shouldUseCIMD(authServerInfo *discovery.AuthServerInfo, flowConfig *discovery.OAuthFlowConfig) bool { + if authServerInfo == nil || !authServerInfo.ClientIDMetadataDocumentSupported { + return false + } + return flowConfig.ClientID == "" && flowConfig.ClientSecret == "" +} + +// isCIMDRejectionError returns true if err indicates the AS rejected the CIMD +// client_id. Only the RFC 6749 error codes invalid_client and unauthorized_client +// trigger a DCR retry; all other errors — including invalid_request and +// token-exchange failures — surface as-is. +// +// CIMD rejection can surface from two stages: +// - Authorization endpoint: AS redirects to callback with error=invalid_client; +// flow.go formats this as "OAuth error: - " (a plain error). +// - Token endpoint: oauth2.RetrieveError with ErrorCode set. +func isCIMDRejectionError(err error) bool { + if err == nil { + return false + } + // Token endpoint rejection — structured error from golang.org/x/oauth2. + var rerr *oauth2.RetrieveError + if errors.As(err, &rerr) { + switch rerr.ErrorCode { + case "invalid_client", "unauthorized_client": + return true + } + return false + } + // Authorization endpoint rejection — flow.go formats callback errors as + // "OAuth error: - ". Check for the code after the prefix. + msg := err.Error() + return strings.HasPrefix(msg, "OAuth error: invalid_client") || + strings.HasPrefix(msg, "OAuth error: unauthorized_client") +} diff --git a/pkg/auth/remote/handler_test.go b/pkg/auth/remote/handler_test.go index d68b465ed7..b7761964d9 100644 --- a/pkg/auth/remote/handler_test.go +++ b/pkg/auth/remote/handler_test.go @@ -6,6 +6,7 @@ package remote import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -14,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth/discovery" ) @@ -797,6 +799,88 @@ func TestAuthenticate_BearerTokenPriority(t *testing.T) { assert.Equal(t, "Bearer", token.TokenType) } +// retrieveErr constructs an *oauth2.RetrieveError with the given error code, +// matching what golang.org/x/oauth2 returns for token endpoint errors. +func retrieveErr(code string) *oauth2.RetrieveError { + return &oauth2.RetrieveError{ErrorCode: code} +} + +// TestIsCIMDRejectionError covers the isCIMDRejectionError helper used in the CIMD retry path. +func TestIsCIMDRejectionError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error returns false", + err: nil, + want: false, + }, + { + name: "invalid_client triggers retry", + err: retrieveErr("invalid_client"), + want: true, + }, + { + name: "unauthorized_client triggers retry", + err: retrieveErr("unauthorized_client"), + want: true, + }, + { + name: "invalid_request does not trigger retry", + err: retrieveErr("invalid_request"), + want: false, + }, + { + name: "access_denied does not trigger retry", + err: retrieveErr("access_denied"), + want: false, + }, + // Authorization-endpoint rejections — flow.go format: "OAuth error: - " + { + name: "auth callback invalid_client triggers retry", + err: fmt.Errorf("OAuth error: invalid_client - client not recognised"), + want: true, + }, + { + name: "auth callback unauthorized_client triggers retry", + err: fmt.Errorf("OAuth error: unauthorized_client - not allowed"), + want: true, + }, + { + name: "auth callback invalid_request does not trigger retry", + err: fmt.Errorf("OAuth error: invalid_request - missing param"), + want: false, + }, + { + name: "auth callback access_denied does not trigger retry", + err: fmt.Errorf("OAuth error: access_denied - user denied"), + want: false, + }, + // Non-OAuth errors must not trigger retry. + { + name: "network error does not trigger retry", + err: fmt.Errorf("dial tcp: connection refused"), + want: false, + }, + { + name: "timeout error does not trigger retry", + err: fmt.Errorf("OAuth flow timed out after 5m0s - user did not complete authentication"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isCIMDRejectionError(tt.err)) + }) + } +} + // TestAuthenticate_BearerTokenDiscovery tests that bearer token discovery works correctly func TestAuthenticate_BearerTokenDiscovery(t *testing.T) { t.Parallel() @@ -853,3 +937,77 @@ func TestAuthenticate_BearerTokenDiscovery(t *testing.T) { assert.Equal(t, "Bearer", token.TokenType) }) } + +// TestResolveClientCredentials verifies the credential selection priority in +// resolveClientCredentials: CachedCIMDClientID > CachedClientID (DCR) > +// statically-configured ClientID. +func TestResolveClientCredentials(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *Config + wantClientID string + wantClientSecret string + }{ + { + name: "CachedCIMDClientID takes precedence over DCR and static credentials", + config: &Config{ + ClientID: "static-client-id", + ClientSecret: "static-secret", + CachedClientID: "dcr-client-id", + CachedCIMDClientID: "https://toolhive.dev/oauth/client-metadata.json", + }, + wantClientID: "https://toolhive.dev/oauth/client-metadata.json", + wantClientSecret: "", + }, + { + name: "CachedCIMDClientID returns empty secret (token_endpoint_auth_method=none)", + config: &Config{ + CachedCIMDClientID: "https://toolhive.dev/oauth/client-metadata.json", + }, + wantClientID: "https://toolhive.dev/oauth/client-metadata.json", + wantClientSecret: "", + }, + { + // When CachedClientID is set the DCR client_id is used, but because + // CachedClientSecretRef is empty (no secret reference stored) the + // function falls through to the statically-configured ClientSecret. + name: "CachedClientID used when CachedCIMDClientID is empty", + config: &Config{ + ClientID: "static-client-id", + ClientSecret: "static-secret", + CachedClientID: "dcr-client-id", + }, + wantClientID: "dcr-client-id", + wantClientSecret: "static-secret", + }, + { + name: "static credentials used when no cached credentials exist", + config: &Config{ + ClientID: "static-client-id", + ClientSecret: "static-secret", + }, + wantClientID: "static-client-id", + wantClientSecret: "static-secret", + }, + { + name: "all empty returns empty strings", + config: &Config{}, + wantClientID: "", + wantClientSecret: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := &Handler{config: tt.config} + gotClientID, gotClientSecret := h.resolveClientCredentials(context.Background()) + + assert.Equal(t, tt.wantClientID, gotClientID, "clientID mismatch") + assert.Equal(t, tt.wantClientSecret, gotClientSecret, "clientSecret mismatch") + }) + } +} diff --git a/pkg/auth/remote/persisting_token_source.go b/pkg/auth/remote/persisting_token_source.go index 22e8e31bf6..3ba90b6253 100644 --- a/pkg/auth/remote/persisting_token_source.go +++ b/pkg/auth/remote/persisting_token_source.go @@ -12,6 +12,7 @@ import ( "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // TokenPersister is a callback function that persists OAuth refresh tokens. @@ -98,7 +99,11 @@ func CreateTokenSourceFromCached( if resource != "" { base = oauth.NewResourceTokenSource(config, token, resource) } else { - base = config.TokenSource(context.TODO(), token) + // Inject an HTTP client whose transport sets the ToolHive User-Agent + // so the oauth2 library does not fall back to Go-http-client/2.0 on + // token refresh requests. + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, oauthproto.NewHTTPClient()) + base = config.TokenSource(ctx, token) } return oauth2.ReuseTokenSource(token, base) diff --git a/pkg/auth/remote/persisting_token_source_test.go b/pkg/auth/remote/persisting_token_source_test.go index 7dba9abcf7..914abaef05 100644 --- a/pkg/auth/remote/persisting_token_source_test.go +++ b/pkg/auth/remote/persisting_token_source_test.go @@ -4,7 +4,10 @@ package remote import ( + "encoding/json" "errors" + "net/http" + "net/http/httptest" "sync/atomic" "testing" "time" @@ -12,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" ) // mockTokenSource is a test implementation of oauth2.TokenSource @@ -238,3 +243,52 @@ func TestCreateTokenSourceFromCached(t *testing.T) { assert.NotNil(t, tokenSource) } + +// TestCreateTokenSourceFromCached_SetsUserAgent verifies that token refresh +// requests carry the ToolHive User-Agent header for both the standard and +// resource-aware (RFC 8707) refresh paths. Without this, the oauth2 library +// falls back to Go-http-client/2.0, which makes ToolHive traffic indistinguishable +// from generic Go programs at the server side. +func TestCreateTokenSourceFromCached_SetsUserAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource string + }{ + {name: "standard refresh", resource: ""}, + {name: "resource-aware refresh", resource: "https://api.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var capturedUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserAgent = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + t.Cleanup(server.Close) + + cfg := &oauth2.Config{ + ClientID: "test-client", + ClientSecret: "test-secret", + Endpoint: oauth2.Endpoint{TokenURL: server.URL}, + } + + ts := CreateTokenSourceFromCached(cfg, "old-refresh-token", time.Now().Add(-time.Hour), tt.resource) + + tok, err := ts.Token() + require.NoError(t, err) + require.NotNil(t, tok) + assert.Equal(t, oauthproto.UserAgent, capturedUserAgent) + }) + } +} diff --git a/pkg/auth/secrets/secrets.go b/pkg/auth/secrets/secrets.go index 679fe782ea..e4805eeaa6 100644 --- a/pkg/auth/secrets/secrets.go +++ b/pkg/auth/secrets/secrets.go @@ -9,9 +9,11 @@ package secrets import ( "context" "crypto/rand" + "errors" "fmt" "time" + "github.com/stacklok/toolhive-core/env" "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/secrets" ) @@ -162,11 +164,8 @@ func GenerateUniqueSecretNameWithPrefix(workloadName, prefix string, secretManag // This version is testable with dependency injection func StoreSecretInManagerWithProvider(ctx context.Context, secretName, secretValue string, secretManager secrets.Provider) error { // Check if the provider supports writing secrets - if !secretManager.Capabilities().CanWrite { - configProvider := config.NewDefaultProvider() - cfg := configProvider.GetConfig() - providerType, _ := cfg.Secrets.GetProviderType() - return fmt.Errorf("secrets provider %s does not support writing secrets", providerType) + if caps := secretManager.Capabilities(); !caps.CanWrite { + return fmt.Errorf("secrets provider (%s) does not support writing secrets", caps) } // Store the secret @@ -206,15 +205,25 @@ func GetUserSecretsProvider() (secrets.Provider, error) { // GetUserSecretsProvider it does not apply UserProvider filtering, so callers // can read and delete __thv_* prefixed keys directly. func GetSystemSecretsProvider() (secrets.Provider, error) { - configProvider := config.NewDefaultProvider() - cfg := configProvider.GetConfig() + return getSystemSecretsProviderFromConfig(config.NewDefaultProvider(), &env.OSReader{}) +} - if !cfg.Secrets.SetupCompleted { - return nil, secrets.ErrSecretsNotSetup - } +// getSystemSecretsProviderFromConfig is the testable core of GetSystemSecretsProvider. +// It accepts an explicit config.Provider and env.Reader so tests can inject +// both without touching global environment state. +func getSystemSecretsProviderFromConfig(configProvider config.Provider, envReader env.Reader) (secrets.Provider, error) { + cfg := configProvider.GetConfig() - providerType, err := cfg.Secrets.GetProviderType() + // GetProviderTypeWithEnv handles the TOOLHIVE_SECRETS_PROVIDER env var and + // allows the "environment" provider even when SetupCompleted is false, so + // Kubernetes and test deployments can skip interactive setup. When no env + // var override is present and SetupCompleted is false, it returns + // ErrSecretsNotSetup, which is propagated directly to the caller. + providerType, err := cfg.Secrets.GetProviderTypeWithEnv(envReader) if err != nil { + if errors.Is(err, secrets.ErrSecretsNotSetup) { + return nil, secrets.ErrSecretsNotSetup + } return nil, fmt.Errorf("failed to get secrets provider type: %w", err) } diff --git a/pkg/auth/secrets/secrets_test.go b/pkg/auth/secrets/secrets_test.go index 09abfeadfd..0b0f4e1eb0 100644 --- a/pkg/auth/secrets/secrets_test.go +++ b/pkg/auth/secrets/secrets_test.go @@ -11,10 +11,55 @@ import ( "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" + envmocks "github.com/stacklok/toolhive-core/env/mocks" + "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/secrets/mocks" ) +// TestGetSystemSecretsProvider_EnvOverrideBypassesSetup verifies the regression +// fix: TOOLHIVE_SECRETS_PROVIDER=environment must succeed even when +// SetupCompleted is false (no config file), so Kubernetes / test deployments +// don't have to run interactive setup. +func TestGetSystemSecretsProvider_EnvOverrideBypassesSetup(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // PathProvider pointing at a non-existent file → SetupCompleted defaults to false. + cfgProvider := config.NewPathProvider(t.TempDir() + "/config.yaml") + + // Mock env reader returns "environment" for the provider env var. + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(secrets.ProviderEnvVar).Return(string(secrets.EnvironmentType)) + + provider, err := getSystemSecretsProviderFromConfig(cfgProvider, mockEnv) + assert.NoError(t, err, "environment provider should succeed without interactive setup") + assert.NotNil(t, provider, "should return a non-nil provider") +} + +// TestGetSystemSecretsProvider_NoSetupNoEnvVar verifies that without both +// SetupCompleted and a TOOLHIVE_SECRETS_PROVIDER override, the function +// returns ErrSecretsNotSetup. +func TestGetSystemSecretsProvider_NoSetupNoEnvVar(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // PathProvider pointing at a non-existent file → SetupCompleted defaults to false. + cfgProvider := config.NewPathProvider(t.TempDir() + "/config.yaml") + + // Mock env reader returns empty string → no override present. + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(secrets.ProviderEnvVar).Return("") + + _, err := getSystemSecretsProviderFromConfig(cfgProvider, mockEnv) + assert.ErrorIs(t, err, secrets.ErrSecretsNotSetup, + "should return ErrSecretsNotSetup when setup is incomplete and no env override is present") +} + func TestGenerateUniqueSecretNameWithPrefix(t *testing.T) { t.Parallel() @@ -221,6 +266,7 @@ func TestProcessSecret(t *testing.T) { assert.Contains(t, err.Error(), "unknown token type") assert.Equal(t, "", result) }) + } func TestProcessSecretWithProvider(t *testing.T) { diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 215406599a..e8a96d10d8 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -24,11 +24,10 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/stacklok/toolhive-core/env" - "github.com/stacklok/toolhive/pkg/auth/oauth" "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // TokenIntrospector defines the interface for token introspection providers @@ -124,7 +123,7 @@ func (g *GoogleProvider) IntrospectToken(ctx context.Context, token string) (jwt } req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", oauth.UserAgent) + req.Header.Set("User-Agent", oauthproto.UserAgent) // Make the request resp, err := g.client.Do(req) // #nosec G704 -- URL is the configured OIDC introspection endpoint @@ -301,7 +300,7 @@ func (r *RFC7662Provider) IntrospectToken(ctx context.Context, token string) (jw // Set headers req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", oauth.UserAgent) + req.Header.Set("User-Agent", oauthproto.UserAgent) req.Header.Set("Accept", "application/json") // Add client authentication if configured @@ -465,7 +464,7 @@ func discoverOIDCConfiguration( } // Set User-Agent header - req.Header.Set("User-Agent", oauth.UserAgent) + req.Header.Set("User-Agent", oauthproto.UserAgent) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) // #nosec G704 -- URL is the configured OIDC issuer discovery endpoint @@ -497,22 +496,6 @@ func discoverOIDCConfiguration( return &doc, nil } -// NewTokenValidatorConfig creates a new TokenValidatorConfig with the provided parameters -func NewTokenValidatorConfig(issuer, audience, jwksURL, clientID string, clientSecret string) *TokenValidatorConfig { - // Only create a config if at least one parameter is provided - if issuer == "" && audience == "" && jwksURL == "" && clientID == "" && clientSecret == "" { - return nil - } - - return &TokenValidatorConfig{ - Issuer: issuer, - Audience: audience, - JWKSURL: jwksURL, - ClientID: clientID, - ClientSecret: clientSecret, - } -} - // registerIntrospectionProviders creates and configures the provider registry // for token introspection based on the configuration. func registerIntrospectionProviders(config TokenValidatorConfig, clientSecret string) (*Registry, error) { diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go index 941abd7e61..a9d258defb 100644 --- a/pkg/auth/token_test.go +++ b/pkg/auth/token_test.go @@ -29,7 +29,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server/keys" keysmocks "github.com/stacklok/toolhive/pkg/authserver/server/keys/mocks" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( diff --git a/pkg/auth/tokenexchange/exchange.go b/pkg/auth/tokenexchange/exchange.go deleted file mode 100644 index f93396098c..0000000000 --- a/pkg/auth/tokenexchange/exchange.go +++ /dev/null @@ -1,522 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -// Package tokenexchange provides OAuth 2.0 Token Exchange (RFC 8693) support. -package tokenexchange - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "golang.org/x/oauth2" -) - -const ( - // grantTypeTokenExchange is the OAuth 2.0 Token Exchange grant type (RFC 8693) - //nolint:gosec // G101: False positive - these are OAuth2 URN identifiers, not credentials - grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" - - // tokenTypeAccessToken indicates an OAuth 2.0 access token - //nolint:gosec // G101: False positive - these are OAuth2 URN identifiers, not credentials - tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" - - // tokenTypeIDToken indicates an OpenID Connect ID Token (required by Google STS) - //nolint:gosec // G101: False positive - these are OAuth2 URN identifiers, not credentials - tokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" - - // tokenTypeJWT indicates a JSON Web Token - //nolint:gosec // G101: False positive - these are OAuth2 URN identifiers, not credentials - tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" - - // defaultHTTPTimeout is the timeout for HTTP requests - defaultHTTPTimeout = 30 * time.Second - - // maxResponseBodySize is the maximum size for reading response bodies (1 MB) - maxResponseBodySize = 1 << 20 - - // redactedPlaceholder is used to redact sensitive values in string representations - redactedPlaceholder = "[REDACTED]" - - // emptyPlaceholder is used to indicate empty/missing values in string representations - emptyPlaceholder = "" -) - -// NormalizeTokenType converts a short token type name to its full URN. -// Accepts both short forms ("access_token", "id_token", "jwt") and full URNs. -// Returns the full URN or an error if the token type is invalid. -// -// This is primarily intended for CLI/user input processing. Internal APIs -// should use full URNs directly. -func NormalizeTokenType(tokenType string) (string, error) { - // Empty string is valid (will use default) - if tokenType == "" { - return "", nil - } - - // Check if already a full URN (backward compatibility) - switch tokenType { - case tokenTypeAccessToken, tokenTypeIDToken, tokenTypeJWT: - return tokenType, nil - } - - // Convert short form to full URN - switch tokenType { - case "access_token": - return tokenTypeAccessToken, nil - case "id_token": - return tokenTypeIDToken, nil - case "jwt": - return tokenTypeJWT, nil - default: - return "", fmt.Errorf("invalid token type %q: must be one of: access_token, id_token, jwt", tokenType) - } -} - -// oAuthError represents an OAuth 2.0 error response as defined in RFC 6749 Section 5.2. -type oAuthError struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` - ErrorURI string `json:"error_uri,omitempty"` - StatusCode int `json:"-"` -} - -func (e *oAuthError) String() string { - if e.ErrorURI != "" { - return fmt.Sprintf("OAuth error %q (status %d): see %s", e.Error, e.StatusCode, e.ErrorURI) - } - return fmt.Sprintf("OAuth error %q (status %d)", e.Error, e.StatusCode) -} - -// parseOAuthError attempts to parse an OAuth error response from the given response body. -func parseOAuthError(statusCode int, body []byte) *oAuthError { - var oauthErr oAuthError - if err := json.Unmarshal(body, &oauthErr); err != nil { - return nil - } - if oauthErr.Error == "" { - return nil - } - oauthErr.StatusCode = statusCode - return &oauthErr -} - -// defaultHTTPClient is the default HTTP client used for token exchange requests. -var defaultHTTPClient = &http.Client{ - Timeout: defaultHTTPTimeout, -} - -// actingParty represents the acting party in a token exchange delegation scenario. -// When present, it indicates that the actor token holder is acting on behalf of the subject token holder. -type actingParty struct { - ActorToken string - ActorTokenType string -} - -// exchangeRequest contains fields necessary to make an OAuth 2.0 token exchange. -// Based on RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693 -type exchangeRequest struct { - // Required fields - GrantType string - SubjectToken string - SubjectTokenType string - RequestedTokenType string - - // Optional fields - Resource string - Audience string - Scope []string - ActingParty *actingParty -} - -// String implements fmt.Stringer for exchangeRequest, redacting sensitive tokens. -func (r exchangeRequest) String() string { - subjectToken := redactedPlaceholder - if r.SubjectToken == "" { - subjectToken = emptyPlaceholder - } - - actorToken := "" - if r.ActingParty != nil { - actorToken = redactedPlaceholder - if r.ActingParty.ActorToken == "" { - actorToken = emptyPlaceholder - } - } - - return fmt.Sprintf("exchangeRequest{GrantType: %s, Audience: %s, Scope: %v, SubjectToken: %s, ActorToken: %s}", - r.GrantType, r.Audience, r.Scope, subjectToken, actorToken) -} - -// response is used to decode the remote server response during an OAuth 2.0 token exchange. -type response struct { - AccessToken string `json:"access_token"` //nolint:gosec // G117: field legitimately holds sensitive data - IssuedTokenType string `json:"issued_token_type"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: field legitimately holds sensitive data -} - -// String implements fmt.Stringer for response, redacting sensitive tokens. -func (r response) String() string { - accessToken := redactedPlaceholder - if r.AccessToken == "" { - accessToken = emptyPlaceholder - } - - refreshToken := redactedPlaceholder - if r.RefreshToken == "" { - refreshToken = emptyPlaceholder - } - - return fmt.Sprintf("response{AccessToken: %s, TokenType: %s, ExpiresIn: %d, RefreshToken: %s}", - accessToken, r.TokenType, r.ExpiresIn, refreshToken) -} - -// clientAuthentication represents OAuth client credentials for token exchange. -type clientAuthentication struct { - ClientID string - ClientSecret string //nolint:gosec // G117 -} - -// String implements fmt.Stringer for clientAuthentication, redacting the client secret. -func (c clientAuthentication) String() string { - clientSecret := redactedPlaceholder - if c.ClientSecret == "" { - clientSecret = emptyPlaceholder - } - - return fmt.Sprintf("clientAuthentication{ClientID: %s, ClientSecret: %s}", - c.ClientID, clientSecret) -} - -// ExchangeConfig holds the configuration for token exchange. -type ExchangeConfig struct { - // TokenURL is the OAuth 2.0 token endpoint URL - TokenURL string - - // ClientID is the OAuth 2.0 client identifier - ClientID string - - // ClientSecret is the OAuth 2.0 client secret - ClientSecret string //nolint:gosec // G117 - - // Audience is the target audience for the exchanged token (optional per RFC 8693) - Audience string - - // Scopes is the list of scopes to request (optional per RFC 8693) - Scopes []string - - // SubjectTokenType specifies the type of the subject token being exchanged. - // Common values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT. - // If empty, defaults to tokenTypeAccessToken. - SubjectTokenType string - - // SubjectTokenProvider is a function that returns the subject token to exchange - // we use a function to allow dynamic retrieval of the token (e.g. from request context) - // and also to lazy-load the token only when needed, load from dynamic sources, etc. - SubjectTokenProvider func() (string, error) - - // HTTPClient is the HTTP client to use for token exchange requests. - // If nil, defaultHTTPClient will be used. - HTTPClient *http.Client -} - -// Validate checks if the ExchangeConfig contains all required fields. -func (c *ExchangeConfig) Validate() error { - if c.TokenURL == "" { - return fmt.Errorf("TokenURL is required") - } - - if c.SubjectTokenProvider == nil { - return fmt.Errorf("SubjectTokenProvider is required") - } - - // ClientID is optional - some token exchange endpoints (like Google STS) - // don't require client credentials and rely on the trust relationship - // configured in the identity provider (e.g., Workload Identity Federation) - - // Validate and normalize SubjectTokenType if provided - if c.SubjectTokenType != "" { - normalized, err := NormalizeTokenType(c.SubjectTokenType) - if err != nil { - return fmt.Errorf("invalid SubjectTokenType: %w", err) - } - // Update the config with the normalized value - c.SubjectTokenType = normalized - } - - // Validate URL format - _, err := url.Parse(c.TokenURL) - if err != nil { - return fmt.Errorf("TokenURL is not a valid URL: %w", err) - } - - return nil -} - -// tokenSource implements oauth2.TokenSource for token exchange. -type tokenSource struct { - ctx context.Context - conf *ExchangeConfig -} - -// Token implements oauth2.TokenSource interface. -// It performs the token exchange and returns an oauth2.Token. -func (ts *tokenSource) Token() (*oauth2.Token, error) { - conf := ts.conf - - // Validate configuration - if err := conf.Validate(); err != nil { - return nil, fmt.Errorf("invalid config: %w", err) - } - - // Get the subject token from the provider - subjectToken, err := conf.SubjectTokenProvider() - if err != nil { - return nil, fmt.Errorf("failed to get subject token: %w", err) - } - - // Determine subject token type (default to access_token if not specified) - subjectTokenType := conf.SubjectTokenType - if subjectTokenType == "" { - subjectTokenType = tokenTypeAccessToken - } - - // Build the token exchange request - request := &exchangeRequest{ - GrantType: grantTypeTokenExchange, - Audience: conf.Audience, - Scope: conf.Scopes, - RequestedTokenType: tokenTypeAccessToken, - SubjectToken: subjectToken, - SubjectTokenType: subjectTokenType, - } - - clientAuth := clientAuthentication{ - ClientID: conf.ClientID, - ClientSecret: conf.ClientSecret, - } - - // Perform the exchange - resp, err := exchangeToken(ts.ctx, conf.TokenURL, request, clientAuth, conf.HTTPClient) - if err != nil { - return nil, err - } - - // Validate required RFC 8693 response fields - if resp.AccessToken == "" { - return nil, fmt.Errorf("token exchange: server returned empty access_token") - } - if resp.TokenType == "" { - return nil, fmt.Errorf("token exchange: server returned empty token_type") - } - if resp.IssuedTokenType == "" { - return nil, fmt.Errorf("token exchange: server returned empty issued_token_type (required by RFC 8693)") - } - - // Build oauth2.Token - token := &oauth2.Token{ - AccessToken: resp.AccessToken, - TokenType: resp.TokenType, - } - - // Set expiry if provided - if resp.ExpiresIn > 0 { - token.Expiry = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) - } - - if resp.RefreshToken != "" { - token.RefreshToken = resp.RefreshToken - } - - return token, nil -} - -// TokenSource returns an oauth2.TokenSource that performs token exchange. -func (c *ExchangeConfig) TokenSource(ctx context.Context) oauth2.TokenSource { - return &tokenSource{ - ctx: ctx, - conf: c, - } -} - -// exchangeToken performs the actual HTTP request for token exchange. -// This is the internal implementation used by tokenSource.Token(). -func exchangeToken( - ctx context.Context, - endpoint string, - request *exchangeRequest, - auth clientAuthentication, - client *http.Client, -) (*response, error) { - data, err := buildTokenExchangeFormData(request) - if err != nil { - return nil, err - } - - req, err := createTokenExchangeRequest(ctx, endpoint, data, auth) - if err != nil { - return nil, err - } - - if client == nil { - client = defaultHTTPClient - } - - body, err := executeTokenExchangeRequest(client, req) - if err != nil { - return nil, err - } - - tokenResp, err := parseTokenExchangeResponse(body) - if err != nil { - return nil, err - } - - return tokenResp, nil -} - -// buildTokenExchangeFormData constructs the form data for a token exchange request according to RFC 8693. -func buildTokenExchangeFormData(request *exchangeRequest) (url.Values, error) { - data := url.Values{} - - // Grant type is always token exchange - if request.GrantType == "" { - request.GrantType = grantTypeTokenExchange - } - data.Set("grant_type", request.GrantType) - - // Subject token is required - if request.SubjectToken == "" { - return nil, fmt.Errorf("subject_token is required") - } - data.Set("subject_token", request.SubjectToken) - - // Subject token type defaults to access_token if not specified - if request.SubjectTokenType == "" { - request.SubjectTokenType = tokenTypeAccessToken - } - data.Set("subject_token_type", request.SubjectTokenType) - - // Requested token type defaults to access_token if not specified - if request.RequestedTokenType == "" { - request.RequestedTokenType = tokenTypeAccessToken - } - data.Set("requested_token_type", request.RequestedTokenType) - - addOptionalFields(data, request) - - return data, nil -} - -// addOptionalFields adds optional RFC 8693 fields to the form data. -func addOptionalFields(data url.Values, request *exchangeRequest) { - if request.Audience != "" { - data.Set("audience", request.Audience) - } - if len(request.Scope) > 0 { - data.Set("scope", strings.Join(request.Scope, " ")) - } - if request.Resource != "" { - data.Set("resource", request.Resource) - } - - // Actor token (for delegation scenarios) - if request.ActingParty != nil && request.ActingParty.ActorToken != "" { - data.Set("actor_token", request.ActingParty.ActorToken) - if request.ActingParty.ActorTokenType != "" { - data.Set("actor_token_type", request.ActingParty.ActorTokenType) - } - } -} - -// createTokenExchangeRequest creates an HTTP POST request for token exchange. -// Client credentials are sent via HTTP Basic Authentication as recommended by RFC 6749 Section 2.3.1. -func createTokenExchangeRequest( - ctx context.Context, - endpoint string, - data url.Values, - auth clientAuthentication, -) (*http.Request, error) { - encodedData := data.Encode() - req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(encodedData)) - if err != nil { - return nil, fmt.Errorf("failed to create token exchange request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Content-Length", strconv.Itoa(len(encodedData))) - - // Add client authentication via HTTP Basic Auth per RFC 6749 Section 2.3.1 - // Per RFC 6749 and Go's SetBasicAuth documentation, credentials must be URL-encoded - // before being passed to SetBasicAuth for OAuth2 compatibility - if auth.ClientID != "" && auth.ClientSecret != "" { - req.SetBasicAuth(url.QueryEscape(auth.ClientID), url.QueryEscape(auth.ClientSecret)) - } - - return req, nil -} - -// executeTokenExchangeRequest sends the HTTP request and returns the response body. -func executeTokenExchangeRequest(client *http.Client, req *http.Request) ([]byte, error) { - resp, err := client.Do(req) // #nosec G704 -- URL is the configured token exchange endpoint - if err != nil { - return nil, fmt.Errorf("token exchange request failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - // Non-fatal: response body cleanup failure - slog.Debug("Failed to close response body", "error", err) - } - }() - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) - if err != nil { - return nil, fmt.Errorf("failed to read token exchange response: %w", err) - } - - if err := validateResponseStatus(resp.StatusCode, body); err != nil { - return nil, err - } - - return body, nil -} - -// validateResponseStatus checks the HTTP status code and returns an error if not successful. -func validateResponseStatus(statusCode int, body []byte) error { - if statusCode >= 200 && statusCode <= 299 { - return nil - } - - // Try to parse as OAuth error first - if oauthErr := parseOAuthError(statusCode, body); oauthErr != nil { - //nolint:gosec // G706: OAuth error codes are standard protocol values, not user input - slog.Debug("Token exchange OAuth error", "oauth_error_code", oauthErr.Error, "description", oauthErr.ErrorDescription) - return errors.New(oauthErr.String()) - } - - //nolint:gosec // G706: status code and body length are safe diagnostic values - slog.Debug("Token exchange failed", "status", statusCode, "body_length", len(body)) - return fmt.Errorf("token exchange failed with status %d", statusCode) -} - -// parseTokenExchangeResponse parses the token exchange response body. -func parseTokenExchangeResponse(body []byte) (*response, error) { - var tokenResp response - if err := json.Unmarshal(body, &tokenResp); err != nil { - slog.Debug("Failed to parse token exchange response", "error", err) - return nil, errors.New("failed to parse token exchange response") - } - - return &tokenResp, nil -} diff --git a/pkg/auth/tokensource/preemptive_test.go b/pkg/auth/tokensource/preemptive_test.go new file mode 100644 index 0000000000..53c3228f13 --- /dev/null +++ b/pkg/auth/tokensource/preemptive_test.go @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Internal tests for the preemptive refresh chain. These live in the same +// package so they can access unexported types and constants. +package tokensource + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/auth/oauth" + "github.com/stacklok/toolhive/pkg/secrets" +) + +// fakeSecretsProvider is a minimal secrets.Provider for internal tests. +type fakeSecretsProvider struct{ val string } + +func (f *fakeSecretsProvider) GetSecret(_ context.Context, _ string) (string, error) { + if f.val == "" { + return "", fmt.Errorf("not found") + } + return f.val, nil +} +func (*fakeSecretsProvider) SetSecret(_ context.Context, _, _ string) error { return nil } +func (*fakeSecretsProvider) DeleteSecret(_ context.Context, _ string) error { return nil } +func (*fakeSecretsProvider) ListSecrets(_ context.Context) ([]secrets.SecretDescription, error) { + return nil, nil +} +func (*fakeSecretsProvider) DeleteSecrets(_ context.Context, _ []string) error { return nil } +func (*fakeSecretsProvider) Cleanup() error { return nil } +func (*fakeSecretsProvider) Capabilities() secrets.ProviderCapabilities { + return secrets.ProviderCapabilities{} +} + +// countingTokenSource counts Token() invocations and delegates to tokenFn. +// Not safe for concurrent use — caller guarantees serialisation. +type countingTokenSource struct { + calls int + tokenFn func(call int) *oauth2.Token +} + +func (c *countingTokenSource) Token() (*oauth2.Token, error) { + c.calls++ + return c.tokenFn(c.calls), nil +} + +type errTokenSource struct{ err error } + +func (e *errTokenSource) Token() (*oauth2.Token, error) { return nil, e.err } + +// ── preemptiveTokenSource ───────────────────────────────────────────────────── + +func TestPreemptiveTokenSource_ShiftsExpiry(t *testing.T) { + t.Parallel() + + realExpiry := time.Now().Add(2 * time.Minute) + inner := &staticTokenSource{tok: &oauth2.Token{AccessToken: "access", Expiry: realExpiry}} + + pts := &preemptiveTokenSource{inner: inner} + tok, err := pts.Token() + require.NoError(t, err) + + wantExpiry := realExpiry.Add(-preemptiveRefreshWindow) + assert.WithinDuration(t, wantExpiry, tok.Expiry, time.Millisecond) +} + +func TestPreemptiveTokenSource_ZeroExpiry_Unchanged(t *testing.T) { + t.Parallel() + + inner := &staticTokenSource{tok: &oauth2.Token{AccessToken: "access", Expiry: time.Time{}}} + pts := &preemptiveTokenSource{inner: inner} + tok, err := pts.Token() + require.NoError(t, err) + assert.True(t, tok.Expiry.IsZero()) +} + +func TestPreemptiveTokenSource_PropagatesError(t *testing.T) { + t.Parallel() + + pts := &preemptiveTokenSource{inner: &errTokenSource{err: fmt.Errorf("refresh failed")}} + _, err := pts.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "refresh failed") +} + +func TestPreemptiveRefreshWindow_Is30s(t *testing.T) { + t.Parallel() + assert.Equal(t, 30*time.Second, preemptiveRefreshWindow, + "preemptiveRefreshWindow must be 30 s — token helpers and proxy workers depend on this value") +} + +// ── withPreemptiveRefresh ───────────────────────────────────────────────────── + +// TestWithPreemptiveRefresh_ExactlyOneRefreshPerWindow is a regression test for +// the composition bug where an inner ReuseTokenSource inside the preemptive +// chain would return the same stale cached token on every call inside the +// preemptive window, causing the outer ReuseTokenSource to thrash indefinitely. +// +// Correct behaviour: the first call inside the preemptive window triggers exactly +// one inner Token() call returning a fresh long-lived token. The outer +// ReuseTokenSource then serves all subsequent calls from cache — zero further inner calls. +func TestWithPreemptiveRefresh_ExactlyOneRefreshPerWindow(t *testing.T) { + t.Parallel() + + fake := &countingTokenSource{ + tokenFn: func(call int) *oauth2.Token { + if call == 1 { + return &oauth2.Token{ + AccessToken: "token-short", + Expiry: time.Now().Add(preemptiveRefreshWindow / 2), + } + } + return &oauth2.Token{AccessToken: "token-fresh", Expiry: time.Now().Add(2 * time.Minute)} + }, + } + + src := withPreemptiveRefresh(fake) + + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-short", tok.AccessToken) + assert.Equal(t, 1, fake.calls) + + const iterations = 10 + for i := range iterations { + tok, err = src.Token() + require.NoError(t, err) + assert.Equal(t, "token-fresh", tok.AccessToken, "iteration %d", i) + } + assert.Equal(t, 2, fake.calls, + "inner source must be called exactly twice: once for the initial short token, "+ + "once for the preemptive refresh") +} + +// TestWithPreemptiveRefresh_NonCachingRefresher_NoResource verifies the fix +// using a real NonCachingRefresher to avoid an internal-caching thrash. +func TestWithPreemptiveRefresh_NonCachingRefresher_NoResource(t *testing.T) { + t.Parallel() + + var serverCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + call := int(serverCalls.Add(1)) + expiresIn := 120 + if call == 1 { + expiresIn = 15 // inside the preemptive window + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":"token-%d","token_type":"Bearer","expires_in":%d}`, + call, expiresIn) + })) + t.Cleanup(srv.Close) + + cfg := &oauth2.Config{ + ClientID: "test-client", + Endpoint: oauth2.Endpoint{TokenURL: srv.URL, AuthStyle: oauth2.AuthStyleInParams}, + } + ncr := oauth.NewNonCachingRefresher(cfg, "refresh-token", "") + src := withPreemptiveRefresh(ncr) + + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-1", tok.AccessToken) + assert.Equal(t, int32(1), serverCalls.Load()) + + const iterations = 10 + for i := range iterations { + tok, err = src.Token() + require.NoError(t, err) + assert.Equal(t, "token-2", tok.AccessToken, "iteration %d", i) + } + assert.Equal(t, int32(2), serverCalls.Load()) +} + +// TestWithPreemptiveRefresh_CachingInnerSource_Thrashes documents the failure +// mode when a caching inner source is used — it thrashes on every outer call. +func TestWithPreemptiveRefresh_CachingInnerSource_Thrashes(t *testing.T) { + t.Parallel() + + staleExpiry := time.Now().Add(preemptiveRefreshWindow / 2) + cachingInner := &countingTokenSource{ + tokenFn: func(_ int) *oauth2.Token { + return &oauth2.Token{AccessToken: "stale", Expiry: staleExpiry} + }, + } + + src := withPreemptiveRefresh(cachingInner) + + const iterations = 10 + for range iterations { + _, err := src.Token() + require.NoError(t, err) + } + assert.Equal(t, iterations, cachingInner.calls, + "caching inner source causes thrashing — one inner call per outer Token() call") +} + +// ── withPreemptiveRefreshFrom ───────────────────────────────────────────────── + +func TestWithPreemptiveRefreshFrom_PreSeededToken(t *testing.T) { + t.Parallel() + + fake := &countingTokenSource{ + tokenFn: func(_ int) *oauth2.Token { + return &oauth2.Token{AccessToken: "refreshed", Expiry: time.Now().Add(2 * time.Minute)} + }, + } + initial := &oauth2.Token{AccessToken: "initial", Expiry: time.Now().Add(2 * time.Minute)} + + src := withPreemptiveRefreshFrom(initial, fake) + + for i := range 5 { + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "initial", tok.AccessToken, "iteration %d", i) + } + assert.Equal(t, 0, fake.calls, "inner source must not be called while initial token is valid") +} + +func TestWithPreemptiveRefreshFrom_ShortLivedInitial_NoSeed(t *testing.T) { + t.Parallel() + + fake := &countingTokenSource{ + tokenFn: func(_ int) *oauth2.Token { + return &oauth2.Token{AccessToken: "inner-token", Expiry: time.Now().Add(2 * time.Minute)} + }, + } + initial := &oauth2.Token{ + AccessToken: "short-lived", + Expiry: time.Now().Add(preemptiveRefreshWindow / 2), + } + + src := withPreemptiveRefreshFrom(initial, fake) + + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "inner-token", tok.AccessToken) + assert.Equal(t, 1, fake.calls) +} + +func TestWithPreemptiveRefreshFrom_NilInitial(t *testing.T) { + t.Parallel() + + fake := &countingTokenSource{ + tokenFn: func(_ int) *oauth2.Token { + return &oauth2.Token{AccessToken: "inner-token", Expiry: time.Now().Add(2 * time.Minute)} + }, + } + + src := withPreemptiveRefreshFrom(nil, fake) + + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "inner-token", tok.AccessToken) + assert.Equal(t, 1, fake.calls) +} + +// staticTokenSource is a minimal oauth2.TokenSource used in internal tests. +type staticTokenSource struct{ tok *oauth2.Token } + +func (s *staticTokenSource) Token() (*oauth2.Token, error) { return s.tok, nil } + +// nonCachingRefresher sanity check for an absent refresh token. +func TestNonCachingRefresher_EmptyRefreshToken_ReturnsError(t *testing.T) { + t.Parallel() + + ncr := oauth.NewNonCachingRefresher(&oauth2.Config{ClientID: "test"}, "", "") + _, err := ncr.Token() + require.Error(t, err) + assert.Contains(t, err.Error(), "no refresh token available") +} + +// TestNonCachingRefresher_StandardPath_NoResourceParam verifies the standard OAuth 2.0 +// refresh path: no "resource" parameter, returned access token is used, and the +// previous refresh token is preserved when the IdP does not rotate one. +func TestNonCachingRefresher_StandardPath_NoResourceParam(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + assert.NotContains(t, string(raw), "resource=", + "standard path must not include a resource parameter") + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"access_token":"new-at","token_type":"Bearer","expires_in":3600}`) + })) + t.Cleanup(srv.Close) + + cfg := &oauth2.Config{ + ClientID: "test-client", + Endpoint: oauth2.Endpoint{TokenURL: srv.URL, AuthStyle: oauth2.AuthStyleInParams}, + Scopes: []string{"openid"}, + } + ncr := oauth.NewNonCachingRefresher(cfg, "my-refresh-token", "") + + tok, err := ncr.Token() + require.NoError(t, err) + assert.Equal(t, "new-at", tok.AccessToken) + assert.Equal(t, "my-refresh-token", tok.RefreshToken, + "refresh token must be preserved when IdP does not rotate it") +} + +// ── tryInMemoryToken: expired in-memory token (internal path) ──────────────── + +// When the in-memory source returns a token whose Valid() is false but no error +// (e.g. already-expired), tryInMemoryToken must clear the source and return +// errCacheMiss so the next tier is tried. +func TestTryInMemoryToken_ExpiredToken_ReturnsCacheMiss(t *testing.T) { + t.Parallel() + + expiredToken := &oauth2.Token{ + AccessToken: "stale", + Expiry: time.Now().Add(-time.Minute), + } + ts := &OAuthTokenSource{ + opts: Options{KeyProvider: func() string { return "k" }, FallbackErr: errors.New("fallback")}, + tokenSource: &staticTokenSource{tok: expiredToken}, + } + + tok, err := ts.tryInMemoryToken() + assert.Empty(t, tok) + assert.ErrorIs(t, err, errCacheMiss) + assert.Nil(t, ts.tokenSource, "expired in-memory source must be cleared") +} + +// ── tryCachedToken: token endpoint error propagates (internal path) ────────── + +// When tryRestoreFromCache succeeds (refresh token found) but the token endpoint +// returns an error during the exchange, tryCachedToken propagates that error. +func TestTryCachedToken_TokenEndpointError_PropagatesError(t *testing.T) { + t.Parallel() + + // Serve OIDC discovery so buildOAuth2Config succeeds, but fail the token exchange. + var srv *httptest.Server + mux := http.NewServeMux() + oidcDoc := func(w http.ResponseWriter, _ *http.Request) { + doc := map[string]string{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + } + mux.HandleFunc("/.well-known/openid-configuration", oidcDoc) + mux.HandleFunc("/.well-known/oauth-authorization-server", oidcDoc) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ts := &OAuthTokenSource{ + opts: Options{ + OIDC: OIDCParams{Issuer: srv.URL, ClientID: "c"}, + KeyProvider: func() string { return "k" }, + FallbackErr: errors.New("fallback"), + SecretsProvider: &fakeSecretsProvider{val: "stored-refresh-token"}, + }, + } + + tok, err := ts.tryCachedToken(context.Background()) + assert.Empty(t, tok) + require.Error(t, err) + assert.NotErrorIs(t, err, errCacheMiss) + assert.Nil(t, ts.tokenSource, "tokenSource must be cleared on error") +} + +// ── tryInMemoryToken: inner source returns a real error ─────────────────────── + +// When t.tokenSource.Token() itself returns an error (rather than just an +// expired/invalid token), tryInMemoryToken must clear the source and propagate +// the error so the caller knows a real problem occurred. +func TestTryInMemoryToken_InnerError_PropagatesError(t *testing.T) { + t.Parallel() + + innerErr := fmt.Errorf("token endpoint unavailable") + ts := &OAuthTokenSource{ + opts: Options{KeyProvider: func() string { return "k" }, FallbackErr: errors.New("fallback")}, + tokenSource: &errTokenSource{err: innerErr}, + } + + tok, err := ts.tryInMemoryToken() + assert.Empty(t, tok) + require.ErrorIs(t, err, innerErr) + assert.Nil(t, ts.tokenSource, "erroring in-memory source must be cleared") +} + +// TestToken_InMemoryError_SetsLastErr verifies that a real error from the +// in-memory tier (tier 1) is surfaced as lastErr in non-interactive mode +// rather than being silently replaced by FallbackErr. +// +// Set-up: tokenSource is pre-seeded with a source that always errors. The +// secrets provider returns "not found" for all keys, so tier 1.5 and tier 2 +// both miss cleanly — lastErr from tier 1 is the only non-cacheMiss error. +func TestToken_InMemoryError_SetsLastErr(t *testing.T) { + t.Parallel() + + innerErr := fmt.Errorf("token endpoint unavailable") + ts := &OAuthTokenSource{ + opts: Options{ + KeyProvider: func() string { return "k" }, + FallbackErr: errors.New("fallback"), + SecretsProvider: &fakeSecretsProvider{val: ""}, // "not found" for all keys + }, + tokenSource: &errTokenSource{err: innerErr}, + } + + _, err := ts.Token(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, innerErr, "in-memory tier error must be surfaced as lastErr, not the fallback") +} diff --git a/pkg/auth/tokensource/tokensource.go b/pkg/auth/tokensource/tokensource.go new file mode 100644 index 0000000000..c5a1dd64f3 --- /dev/null +++ b/pkg/auth/tokensource/tokensource.go @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package tokensource provides a shared OIDC-backed OAuth token source used by +// both the LLM gateway client and the registry authentication client. +// +// The shared OAuthTokenSource implements a four-tier token strategy: +// +// 1. In-memory cached oauth2.TokenSource (auto-refreshes transparently) +// 2. Secrets-provider cached access token (cross-process reuse to avoid racing) +// 3. Refresh token stored in the secrets provider (restores across CLI invocations) +// 4. Browser-based OIDC+PKCE flow (only when interactive is true) +// +// Callers parameterise the source via Options: an OIDC config struct, a key +// provider function (which determines the secrets-provider key and its prefix), +// and a ConfigPersister callback that persists the token reference into the +// application config. +package tokensource + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/auth/oauth" + "github.com/stacklok/toolhive/pkg/auth/remote" + "github.com/stacklok/toolhive/pkg/secrets" +) + +// errCacheMiss is a sentinel used internally to distinguish "no token in cache" +// from real backend errors. It is never exposed to callers. +var errCacheMiss = errors.New("no cached refresh token") + +// preemptiveRefreshWindow is how far before actual expiry a token is treated as +// expired, triggering a proactive refresh before the upstream rejects it. +const preemptiveRefreshWindow = 30 * time.Second + +// OIDCParams holds the OIDC connection parameters for a token source. +type OIDCParams struct { + Issuer string + ClientID string + Scopes []string // "offline_access" is appended automatically if absent + Audience string + CallbackPort int +} + +// ConfigPersister is called when the refresh token key or expiry changes — +// after a successful browser flow (initial login) or when the OIDC provider +// rotates the refresh token during a refresh. Callers wire this to their config +// persistence layer. It is NOT called on routine access-token refreshes where +// the refresh token is unchanged. +type ConfigPersister func(refreshTokenKey string, expiry time.Time) + +// Options configures an OAuthTokenSource. +type Options struct { + OIDC OIDCParams + // SecretsProvider is used to persist and restore refresh and access tokens. + // May be nil; in that case tier 2/3 return an actionable error rather than + // the FallbackErr, so the caller sees the real cause. + SecretsProvider secrets.Provider + // Interactive controls whether the browser OIDC+PKCE flow is allowed. + Interactive bool + // KeyProvider returns the secrets-provider key for the refresh token. + // May be called multiple times per Token() invocation; must be deterministic + // and free of side effects. Typically returns a cached config ref if set, + // otherwise derives a key from url+issuer. + KeyProvider func() string + // ConfigPersister is called when a new refresh token is persisted (login or + // rotation). May be nil to skip config persistence. + ConfigPersister ConfigPersister + // FallbackErr is returned in non-interactive mode when no cached credentials + // exist and no actionable lastErr is available. Defaults to a generic error. + FallbackErr error +} + +// OAuthTokenSource provides OIDC-backed access tokens via a four-tier strategy. +// It is safe for concurrent use. +type OAuthTokenSource struct { + opts Options + mu sync.Mutex + tokenSource oauth2.TokenSource +} + +// New creates an OAuthTokenSource from the given Options. +// It panics if opts.KeyProvider is nil, as this is a required field. +// If opts.OIDC.CallbackPort is zero, it defaults to remote.DefaultCallbackPort. +func New(opts Options) *OAuthTokenSource { + if opts.KeyProvider == nil { + panic("tokensource.New: Options.KeyProvider must not be nil") + } + if opts.FallbackErr == nil { + opts.FallbackErr = errors.New("authentication required: no cached credentials found; complete an interactive login first") + } + if opts.OIDC.CallbackPort == 0 { + opts.OIDC.CallbackPort = remote.DefaultCallbackPort + } + return &OAuthTokenSource{opts: opts} +} + +// Token returns a valid access token string. +// It is safe for concurrent use. +func (t *OAuthTokenSource) Token(ctx context.Context) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + // lastErr tracks the most recent actionable error from tiers 1/2. In + // non-interactive mode it is returned instead of FallbackErr so the caller + // sees the real cause (e.g. invalid_grant, keyring locked). + var lastErr error + + if tok, err := t.tryInMemoryToken(); err == nil { + return tok, nil + } else if !errors.Is(err, errCacheMiss) { + lastErr = err + } + + // Tier 1.5: secrets-cached access token — avoids IdP exchange when the + // access token is still fresh. Concurrent short-lived processes share this + // cached value instead of all racing to exchange the same refresh token, + // preventing invalid_grant from OIDC providers that rotate refresh tokens. + if tok, found := t.tryAccessTokenCache(ctx); found { + return tok, nil + } + + if tok, err := t.tryCachedToken(ctx); err == nil { + return tok, nil + } else if !errors.Is(err, errCacheMiss) { + lastErr = err + } + + // Tier 3: browser OIDC+PKCE flow — only in interactive mode. + if !t.opts.Interactive { + if lastErr != nil { + return "", lastErr + } + return "", t.opts.FallbackErr + } + if err := t.performBrowserFlow(ctx); err != nil { + return "", fmt.Errorf("OIDC browser flow failed: %w", err) + } + tok, err := t.tokenSource.Token() + if err != nil { + return "", fmt.Errorf("failed to get token after browser flow: %w", err) + } + t.cacheAccessToken(ctx, tok.AccessToken, tok.Expiry) + return tok.AccessToken, nil +} + +// tryInMemoryToken tries the in-memory token source (tier 1). +// Returns (token, nil) on success, ("", errCacheMiss) when no source is set, +// or ("", err) for a real token-endpoint error. +func (t *OAuthTokenSource) tryInMemoryToken() (string, error) { + if t.tokenSource == nil { + return "", errCacheMiss + } + tok, err := t.tokenSource.Token() + if err == nil && tok.Valid() { + return tok.AccessToken, nil + } + t.tokenSource = nil + if err != nil { + return "", err + } + return "", errCacheMiss +} + +// tryCachedToken restores a token source from the secrets provider (tier 2/3) +// and tries to obtain a valid token from it. +// Returns (token, nil) on success, ("", errCacheMiss) on a cache miss, +// or ("", err) for a real error. +func (t *OAuthTokenSource) tryCachedToken(ctx context.Context) (string, error) { + if err := t.tryRestoreFromCache(ctx); err != nil { + return "", err // errCacheMiss propagates transparently + } + tok, err := t.tokenSource.Token() + if err == nil && tok.Valid() { + t.cacheAccessToken(ctx, tok.AccessToken, tok.Expiry) + return tok.AccessToken, nil + } + t.tokenSource = nil + if err != nil { + return "", err + } + return "", errCacheMiss +} + +// tryRestoreFromCache attempts to build a token source from the cached refresh +// token stored in the secrets provider. +func (t *OAuthTokenSource) tryRestoreFromCache(ctx context.Context) error { + if t.opts.SecretsProvider == nil { + return fmt.Errorf("no secrets provider available") + } + key := t.opts.KeyProvider() + refreshToken, err := t.opts.SecretsProvider.GetSecret(ctx, key) + if err != nil { + if secrets.IsNotFoundError(err) { + return errCacheMiss + } + return fmt.Errorf("reading cached refresh token: %w", err) + } + if refreshToken == "" { + return errCacheMiss + } + + oauth2Cfg, err := t.buildOAuth2Config(ctx) + if err != nil { + return fmt.Errorf("building oauth2 config for cache restore: %w", err) + } + + // Use a non-caching refresher as the innermost source. + // + // oauth2Cfg.TokenSource caches internally and only refreshes when the real + // expiry passes. When the outer ReuseTokenSource enters the preemptive window + // (30 s before real expiry) it calls preemptiveTokenSource, which calls the + // inner source; the inner source sees the real token as still valid and returns + // it unchanged; preemptiveTokenSource shifts the expiry back by 30 s, producing + // an already-expired token; the outer ReuseTokenSource then re-enters the chain + // on every subsequent call — an infinite non-refreshing loop. + // + // A non-caching refresher always performs a network round-trip when called, so + // the first call inside the preemptive window obtains a fresh token with a + // real-future expiry. The outer ReuseTokenSource caches the shifted result and + // serves it until the next window — exactly one refresh per window. + // + // Target stacking: ReuseTokenSource(nil, preemptive{PersistingTokenSource{nonCachingRefresher}}) + rawRefresher := oauth.NewNonCachingRefresher(oauth2Cfg, refreshToken, t.opts.OIDC.Audience) + + // Persist rotated refresh tokens back to the secrets provider so future + // invocations can still restore the session if the provider invalidates the + // old token on refresh (common with OIDC providers that rotate refresh tokens). + base := remote.NewPersistingTokenSource(rawRefresher, t.makeTokenPersister(key)) + + // Wrap with preemptive refresh so tokens are renewed 30 s before real expiry. + t.tokenSource = withPreemptiveRefresh(base) + return nil +} + +// performBrowserFlow runs the interactive OIDC+PKCE browser flow and persists +// the resulting refresh token for future non-interactive use. +func (t *OAuthTokenSource) performBrowserFlow(ctx context.Context) error { + flowCfg, err := t.buildFlowConfig(ctx) + if err != nil { + return err + } + + flow, err := oauth.NewFlow(flowCfg) + if err != nil { + return fmt.Errorf("creating OAuth flow: %w", err) + } + + tokenResult, err := flow.Start(ctx, false) + if err != nil { + return fmt.Errorf("OAuth flow start failed: %w", err) + } + + // Build a non-caching refresher as the innermost source (see tryRestoreFromCache + // for a detailed explanation of why caching inner sources cause an infinite loop + // inside the preemptive window). Reuse the already-discovered flowCfg to avoid + // a second OIDC round-trip. + oauth2Cfg := oauth2ConfigFrom(flowCfg) + initialToken := &oauth2.Token{ + AccessToken: tokenResult.AccessToken, + RefreshToken: tokenResult.RefreshToken, + Expiry: tokenResult.Expiry, + TokenType: tokenResult.TokenType, + } + + var base oauth2.TokenSource = oauth.NewNonCachingRefresher(oauth2Cfg, initialToken.RefreshToken, flowCfg.Resource) + + if t.opts.SecretsProvider != nil { + key := t.opts.KeyProvider() + base = remote.NewPersistingTokenSource(base, t.makeTokenPersister(key)) + if tokenResult.RefreshToken != "" { + if err := t.opts.SecretsProvider.SetSecret(ctx, key, tokenResult.RefreshToken); err != nil { + slog.Warn("failed to persist initial refresh token", "error", err) + } else { + t.persistConfig(key, tokenResult.Expiry) + } + } else { + slog.Debug("OIDC provider did not return a refresh token; token will not be persisted") + } + } + + // Pre-seed the outer ReuseTokenSource with the shifted initial token so the + // just-obtained access token is served without an immediate network round-trip. + t.tokenSource = withPreemptiveRefreshFrom(initialToken, base) + return nil +} + +// buildFlowConfig creates an oauth.Config for the interactive browser flow. +// PKCE (S256) is always enabled per OAuth 2.1 requirements for public clients. +func (t *OAuthTokenSource) buildFlowConfig(ctx context.Context) (*oauth.Config, error) { + return oauth.CreateOAuthConfigFromOIDC( + ctx, + t.opts.OIDC.Issuer, + t.opts.OIDC.ClientID, + "", // public client — no client secret + EnsureOfflineAccess(t.opts.OIDC.Scopes), + true, // always use PKCE + t.opts.OIDC.CallbackPort, + t.opts.OIDC.Audience, + ) +} + +// buildOAuth2Config creates a minimal oauth2.Config suitable for token refresh +// via the cached refresh token path (no browser required). +func (t *OAuthTokenSource) buildOAuth2Config(ctx context.Context) (*oauth2.Config, error) { + flowCfg, err := t.buildFlowConfig(ctx) + if err != nil { + return nil, err + } + return oauth2ConfigFrom(flowCfg), nil +} + +// oauth2ConfigFrom converts an oauth.Config (from OIDC discovery) into the +// minimal oauth2.Config used for headless token refresh. +func oauth2ConfigFrom(flowCfg *oauth.Config) *oauth2.Config { + return &oauth2.Config{ + ClientID: flowCfg.ClientID, + Scopes: flowCfg.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: flowCfg.AuthURL, + TokenURL: flowCfg.TokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + } +} + +// makeTokenPersister returns a remote.TokenPersister that stores the refresh +// token in the secrets provider and calls ConfigPersister. It also invalidates +// the access-token cache so the next Token() call does not serve a token minted +// against the now-rotated refresh token. +func (t *OAuthTokenSource) makeTokenPersister(key string) remote.TokenPersister { + return func(refreshToken string, expiry time.Time) error { + ctx := context.Background() + if err := t.opts.SecretsProvider.SetSecret(ctx, key, refreshToken); err != nil { + return fmt.Errorf("persisting refresh token: %w", err) + } + t.persistConfig(key, expiry) + // Invalidate the access-token cache so the next call does not serve a + // token minted against the now-rotated refresh token. Order is intentional: + // the refresh token (durable) is written first, then the AT cache is cleared. + // Use key+"_AT" directly rather than accessTokenCacheKey() to guarantee we + // clear the same key that was just persisted — ConfigPersister may have + // updated CachedRefreshTokenRef, which would change KeyProvider's return + // value and cause accessTokenCacheKey() to resolve a different key. + // We write "" rather than calling DeleteSecret for provider compatibility. + if err := t.opts.SecretsProvider.SetSecret(ctx, key+"_AT", ""); err != nil { + slog.Warn("failed to invalidate access-token cache after refresh token rotation", + "error", err) + } + return nil + } +} + +// persistConfig calls the ConfigPersister (if set) to record the token ref and expiry. +func (t *OAuthTokenSource) persistConfig(key string, expiry time.Time) { + if t.opts.ConfigPersister != nil { + t.opts.ConfigPersister(key, expiry) + } +} + +// accessTokenCacheKey returns the secrets-provider key for the cached access token. +func (t *OAuthTokenSource) accessTokenCacheKey() string { + return t.opts.KeyProvider() + "_AT" +} + +// tryAccessTokenCache reads a previously cached access token from the secrets +// provider and returns it if still valid. +func (t *OAuthTokenSource) tryAccessTokenCache(ctx context.Context) (string, bool) { + if t.opts.SecretsProvider == nil { + return "", false + } + raw, err := t.opts.SecretsProvider.GetSecret(ctx, t.accessTokenCacheKey()) + if err != nil || raw == "" { + return "", false + } + idx := strings.LastIndex(raw, "|") + if idx <= 0 { + return "", false + } + expiry, err := time.Parse(time.RFC3339, raw[idx+1:]) + if err != nil { + return "", false + } + if time.Now().Before(expiry) { + return raw[:idx], true + } + return "", false +} + +// cacheAccessToken writes the access token and its expiry to the secrets +// provider so concurrent short-lived processes can reuse it. Errors are logged +// at debug level and suppressed — a write failure degrades gracefully to a full +// refresh on the next call. +func (t *OAuthTokenSource) cacheAccessToken(ctx context.Context, token string, expiry time.Time) { + if t.opts.SecretsProvider == nil || token == "" || expiry.IsZero() { + return + } + val := token + "|" + expiry.UTC().Format(time.RFC3339) + if err := t.opts.SecretsProvider.SetSecret(ctx, t.accessTokenCacheKey(), val); err != nil { + slog.Debug("failed to cache access token", "error", err) + } +} + +// DeriveSecretKey computes a secrets-provider key for an OAuth refresh token. +// The formula is: <8 hex chars> where the hex is derived from +// sha256(resourceURL + "\x00" + issuer)[:4]. +func DeriveSecretKey(prefix, resourceURL, issuer string) string { + h := sha256.Sum256([]byte(resourceURL + "\x00" + issuer)) + return prefix + hex.EncodeToString(h[:4]) +} + +// LLMAccessTokenEnvVar returns the environment-variable name under which the +// environment secrets provider expects a cached LLM access token for the given +// gateway and issuer URLs. The format is: +// +// TOOLHIVE_SECRET___thv_llm__AT +// +// This is the canonical source of truth for the key construction used by both +// the proxy/token commands and the e2e tests that inject fake tokens. +func LLMAccessTokenEnvVar(gatewayURL, issuerURL string) string { + key := DeriveSecretKey("LLM_OAUTH_", gatewayURL, issuerURL) + scopedKey := secrets.SystemKeyPrefix + string(secrets.ScopeLLM) + "_" + key + "_AT" + return secrets.EnvVarPrefix + scopedKey +} + +// EnsureOfflineAccess returns scopes with "offline_access" appended if absent. +// This scope is required for the provider to return a refresh token. +func EnsureOfflineAccess(scopes []string) []string { + if slices.Contains(scopes, "offline_access") { + return scopes + } + return append(scopes[:len(scopes):len(scopes)], "offline_access") +} + +// preemptiveTokenSource wraps an oauth2.TokenSource and shifts each returned +// token's expiry back by preemptiveRefreshWindow. +type preemptiveTokenSource struct { + inner oauth2.TokenSource +} + +func (p *preemptiveTokenSource) Token() (*oauth2.Token, error) { + tok, err := p.inner.Token() + if err != nil { + return nil, err + } + if !tok.Expiry.IsZero() { + shifted := *tok + shifted.Expiry = tok.Expiry.Add(-preemptiveRefreshWindow) + return &shifted, nil + } + return tok, nil +} + +// withPreemptiveRefresh wraps src so tokens appear expired preemptiveRefreshWindow +// before they actually expire, then re-wraps with ReuseTokenSource so the refresh +// is only triggered once per window. +func withPreemptiveRefresh(src oauth2.TokenSource) oauth2.TokenSource { + return withPreemptiveRefreshFrom(nil, src) +} + +// withPreemptiveRefreshFrom is like withPreemptiveRefresh but pre-seeds the outer +// ReuseTokenSource with a shifted copy of initial (if non-nil and valid). +func withPreemptiveRefreshFrom(initial *oauth2.Token, src oauth2.TokenSource) oauth2.TokenSource { + var seeded *oauth2.Token + if initial != nil && initial.Valid() && !initial.Expiry.IsZero() { + shifted := *initial + shifted.Expiry = initial.Expiry.Add(-preemptiveRefreshWindow) + if shifted.Valid() { + seeded = &shifted + } + } + return oauth2.ReuseTokenSource(seeded, &preemptiveTokenSource{inner: src}) +} diff --git a/pkg/auth/tokensource/tokensource_test.go b/pkg/auth/tokensource/tokensource_test.go new file mode 100644 index 0000000000..807d14c39f --- /dev/null +++ b/pkg/auth/tokensource/tokensource_test.go @@ -0,0 +1,626 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tokensource_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/auth/tokensource" + secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const ( + testGatewayURL = "https://llm.example.com" + testIssuer = "https://auth.example.com" + testClientID = "test-client" + testKeyPrefix = "TEST_OAUTH_" + testRefreshToken = "stored-rt" +) + +var errTestFallback = errors.New("test: authentication required") + +func minimalOpts(sp *secretsmocks.MockProvider) tokensource.Options { + return tokensource.Options{ + OIDC: tokensource.OIDCParams{ + Issuer: testIssuer, + ClientID: testClientID, + }, + SecretsProvider: sp, + Interactive: false, + KeyProvider: func() string { return tokensource.DeriveSecretKey(testKeyPrefix, testGatewayURL, testIssuer) }, + FallbackErr: errTestFallback, + } +} + +// ── DeriveSecretKey ─────────────────────────────────────────────────────────── + +func TestDeriveSecretKey(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + resourceURL string + issuer string + }{ + {"deterministic", "P_", testGatewayURL, testIssuer}, + {"different prefix", "Q_", testGatewayURL, testIssuer}, + {"different url", "P_", "https://other.example.com", testIssuer}, + } + + key00 := tokensource.DeriveSecretKey("P_", testGatewayURL, testIssuer) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := tokensource.DeriveSecretKey(tc.prefix, tc.resourceURL, tc.issuer) + assert.True(t, strings.HasPrefix(got, tc.prefix), "key must start with prefix") + if tc.prefix == "P_" && tc.resourceURL == testGatewayURL && tc.issuer == testIssuer { + assert.Equal(t, key00, got, "same inputs must produce same key") + } else { + assert.NotEqual(t, key00, got, "different inputs must produce different keys") + } + }) + } +} + +func TestDeriveSecretKey_NullByteIsolatesSegments(t *testing.T) { + t.Parallel() + // "ab"+"c" and "a"+"bc" must not collide. + k1 := tokensource.DeriveSecretKey("P_", "ab", "c") + k2 := tokensource.DeriveSecretKey("P_", "a", "bc") + assert.NotEqual(t, k1, k2, "null byte separator must prevent segment conflation") +} + +// ── EnsureOfflineAccess ─────────────────────────────────────────────────────── + +func TestEnsureOfflineAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + expect []string + }{ + {"already present", []string{"openid", "offline_access"}, []string{"openid", "offline_access"}}, + {"not present", []string{"openid"}, []string{"openid", "offline_access"}}, + {"empty", []string{}, []string{"offline_access"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := tokensource.EnsureOfflineAccess(tc.input) + assert.Equal(t, tc.expect, got) + }) + } +} + +// ── non-interactive / no cache ──────────────────────────────────────────────── + +func TestToken_NonInteractive_NoCache_ReturnsFallbackErr(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", errors.New("not found")).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + _, err := ts.Token(context.Background()) + require.ErrorIs(t, err, errTestFallback) +} + +func TestToken_NonInteractive_BackendError_SurfacesLastErr(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + backendErr := errors.New("keyring is locked") + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", backendErr).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + _, err := ts.Token(context.Background()) + require.Error(t, err) + assert.False(t, errors.Is(err, errTestFallback), + "backend error must surface, not the generic fallback") + assert.ErrorContains(t, err, "keyring is locked") +} + +func TestToken_NonInteractive_NilSecrets_ReturnsActionableError(t *testing.T) { + t.Parallel() + + opts := minimalOpts(nil) + opts.SecretsProvider = nil + ts := tokensource.New(opts) + _, err := ts.Token(context.Background()) + require.Error(t, err) + assert.False(t, errors.Is(err, errTestFallback), + "nil secrets provider must return an actionable error, not the generic fallback") +} + +// ── in-memory tier (tier 1) ─────────────────────────────────────────────────── + +// TestToken_InMemoryCache_ServesWithoutSecretsLookup verifies that after a token +// is obtained via the refresh-token cache, subsequent calls are served from the +// in-memory cache without hitting the token endpoint again. +func TestToken_InMemoryCache_ServesWithoutSecretsLookup(t *testing.T) { + t.Parallel() + + var tokenEndpointCalls atomic.Int32 + srv := fakeOIDCServer(t, &tokenEndpointCalls, "cached-access-token", + time.Now().Add(10*time.Minute), "rt-rotated") + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + // AT cache miss; refresh token present → tier 2 path. + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return testRefreshToken, nil + }).AnyTimes() + mock.EXPECT().SetSecret(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + ts := tokensource.New(optsWithFakeOIDC(srv, mock)) + + // First call restores from refresh token cache → hits token endpoint. + tok1, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "cached-access-token", tok1) + calls1 := tokenEndpointCalls.Load() + + // Second call must be served from in-memory cache — no new token-endpoint calls. + tok2, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, tok1, tok2) + assert.Equal(t, calls1, tokenEndpointCalls.Load(), + "second call must not hit token endpoint again") +} + +// ── access-token cache (tier 1.5) ──────────────────────────────────────────── + +func TestToken_AccessTokenCache_ValidToken_Served(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + cachedAT := "cached-at|" + expiry + + // First GetSecret for the AT cache key → cached token. + // Key ending in _AT is the access-token cache. + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return cachedAT, nil + } + return "", errors.New("not found") + }).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + tok, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "cached-at", tok) +} + +func TestToken_AccessTokenCache_Expired_FallsThrough(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + expiry := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) // expired + cachedAT := "stale-at|" + expiry + + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return cachedAT, nil + } + return "", errors.New("not found") + }).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + _, err := ts.Token(context.Background()) + // Falls through to FallbackErr because expired AT and no refresh token. + require.ErrorIs(t, err, errTestFallback) +} + +// When the cached AT entry has no "|" separator (malformed), tryAccessTokenCache +// must treat it as a miss and fall through rather than panicking or returning +// a partial value. +func TestToken_AccessTokenCache_MalformedEntry_FallsThrough(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "no-pipe-separator", nil // malformed: no "|" + } + return "", errors.New("not found") + }).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + _, err := ts.Token(context.Background()) + require.ErrorIs(t, err, errTestFallback, "malformed AT cache must be treated as a miss") +} + +// ── refresh-token cache (tier 2/3) ─────────────────────────────────────────── + +func TestToken_RefreshTokenCache_UsesPersistedToken(t *testing.T) { + t.Parallel() + + srv := fakeOIDCServerSimple(t, "fresh-access-token", time.Now().Add(time.Hour), "rt-rotated") + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + // AT cache miss; refresh token present for the base key. + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return "stored-refresh-token", nil + }).AnyTimes() + mock.EXPECT().SetSecret(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + opts := optsWithFakeOIDC(srv, mock) + ts := tokensource.New(opts) + + tok, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "fresh-access-token", tok) +} + +func TestToken_RefreshTokenCache_EmptyValue_TreatedAsMiss(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + // Provider returns success but empty string — treated as "no token". + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + + ts := tokensource.New(minimalOpts(mock)) + _, err := ts.Token(context.Background()) + require.ErrorIs(t, err, errTestFallback) +} + +// ── KeyProvider is honoured ─────────────────────────────────────────────────── + +func TestToken_KeyProvider_CachedRefUsed(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + const cachedKey = "my-persisted-key" + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.Eq(cachedKey+"_AT")). + Return("", errors.New("not found")).AnyTimes() + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.Eq(cachedKey)). + Return("", errors.New("not found")).AnyTimes() + + opts := minimalOpts(mock) + opts.KeyProvider = func() string { return cachedKey } + + ts := tokensource.New(opts) + _, _ = ts.Token(context.Background()) + // The mock expectations verify that cachedKey was used — no assertion needed here. +} + +// ── ConfigPersister is called on refresh-token rotation ────────────────────── + +// TestToken_ConfigPersister_CalledOnRotation verifies that when the OIDC provider +// rotates the refresh token during a Token() call, the ConfigPersister callback +// is invoked. This exercises the PersistingTokenSource → makeTokenPersister → +// persistConfig chain. +func TestToken_ConfigPersister_CalledOnRotation(t *testing.T) { + t.Parallel() + + // Token server returns a "rotated" refresh token on each call. + srv := fakeOIDCServerSimple(t, "at", time.Now().Add(time.Hour), "rotated-rt") + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + // AT cache miss; refresh token present. + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return "initial-rt", nil + }).AnyTimes() + mock.EXPECT().SetSecret(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + var persisted bool + opts := optsWithFakeOIDC(srv, mock) + opts.ConfigPersister = func(_ string, _ time.Time) { persisted = true } + + ts := tokensource.New(opts) + _, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.True(t, persisted, "ConfigPersister must be called when the refresh token is rotated") +} + +// ── fakeTokenServer helpers ─────────────────────────────────────────────────── + +// fakeOIDCServer creates a minimal OIDC discovery + token server. It counts +// token-endpoint calls via callCount and returns at/rt on each call. +func fakeOIDCServer(t *testing.T, callCount *atomic.Int32, at string, expiry time.Time, rt string) *httptest.Server { + t.Helper() + var srv *httptest.Server + mux := http.NewServeMux() + + oidcHandler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, + `{"issuer":%q,"authorization_endpoint":%q,"token_endpoint":%q,"jwks_uri":%q,"response_types_supported":["code"]}`, + srv.URL, srv.URL+"/auth", srv.URL+"/token", srv.URL+"/jwks") + } + mux.HandleFunc("/.well-known/openid-configuration", oidcHandler) + mux.HandleFunc("/.well-known/oauth-authorization-server", oidcHandler) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + callCount.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":%q,"refresh_token":%q,"expires_in":%d,"token_type":"Bearer"}`, + at, rt, int(time.Until(expiry).Seconds())) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// fakeOIDCServerSimple is like fakeOIDCServer but without call counting. +func fakeOIDCServerSimple(t *testing.T, at string, expiry time.Time, rt string) *httptest.Server { + t.Helper() + var n atomic.Int32 + return fakeOIDCServer(t, &n, at, expiry, rt) +} + +// optsWithFakeOIDC builds Options pointing at the given fake OIDC server. +func optsWithFakeOIDC(srv *httptest.Server, sp *secretsmocks.MockProvider) tokensource.Options { + return tokensource.Options{ + OIDC: tokensource.OIDCParams{ + Issuer: srv.URL, + ClientID: testClientID, + }, + SecretsProvider: sp, + Interactive: false, + KeyProvider: func() string { return "test-key" }, + FallbackErr: errTestFallback, + } +} + +// ── oauth2ConfigFrom sanity ─────────────────────────────────────────────────── + +// TestEnsureOfflineAccess_DoesNotMutateInput ensures the input slice is not +// modified when offline_access is appended. +func TestEnsureOfflineAccess_DoesNotMutateInput(t *testing.T) { + t.Parallel() + + input := []string{"openid"} + got := tokensource.EnsureOfflineAccess(input) + assert.Len(t, input, 1, "input slice must not be mutated") + assert.Len(t, got, 2) +} + +// TestNew_DefaultFallbackErr verifies the default error message when no +// FallbackErr is set. +func TestNew_DefaultFallbackErr(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()).Return("", errors.New("not found")).AnyTimes() + + opts := minimalOpts(mock) + opts.FallbackErr = nil // let New() set the default + ts := tokensource.New(opts) + _, err := ts.Token(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication required") +} + +// TestNew_NilKeyProvider_Panics verifies that New panics early when +// KeyProvider is nil rather than producing a hard-to-diagnose nil-deref inside +// Token(). +func TestNew_NilKeyProvider_Panics(t *testing.T) { + t.Parallel() + + opts := minimalOpts(nil) + opts.KeyProvider = nil + assert.Panics(t, func() { tokensource.New(opts) }) +} + +// ── cacheAccessToken: skips when expiry is zero ────────────────────────────── + +// When the token endpoint omits expires_in (zero Expiry), cacheAccessToken must +// not write to the AT cache — there is no expiry to store. +func TestToken_CacheAccessToken_ZeroExpiry_Skipped(t *testing.T) { + t.Parallel() + + var atCacheWrites int + var srv *httptest.Server + mux := http.NewServeMux() + oidcHandler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":%q,"token_endpoint":%q,"jwks_uri":%q}`, + srv.URL, srv.URL+"/auth", srv.URL+"/token", srv.URL+"/jwks") + } + mux.HandleFunc("/.well-known/openid-configuration", oidcHandler) + mux.HandleFunc("/.well-known/oauth-authorization-server", oidcHandler) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // expires_in omitted → zero Expiry → cacheAccessToken must skip. + _, _ = fmt.Fprintf(w, `{"access_token":"tok","refresh_token":"rt","token_type":"Bearer"}`) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return testRefreshToken, nil + }).AnyTimes() + mock.EXPECT(). + SetSecret(gomock.Any(), gomock.AssignableToTypeOf(""), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key, val string) error { + // Count only non-empty AT writes — empty writes are invalidations from + // makeTokenPersister, not cacheAccessToken writes. + if strings.HasSuffix(key, "_AT") && val != "" { + atCacheWrites++ + } + return nil + }).AnyTimes() + + opts := optsWithFakeOIDC(srv, mock) + ts := tokensource.New(opts) + tok, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "tok", tok) + assert.Zero(t, atCacheWrites, "cacheAccessToken must not write to AT cache when expiry is zero") +} + +// ── cacheAccessToken: SetSecret failure degrades silently ──────────────────── + +// When the secrets provider fails to write the AT cache, cacheAccessToken must +// suppress the error (logged at debug level) and return the token normally. +func TestToken_CacheAccessToken_SetSecretFails_DegradesSilently(t *testing.T) { + t.Parallel() + + srv := fakeOIDCServerSimple(t, "access-tok", time.Now().Add(time.Hour), "rt-new") + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return testRefreshToken, nil + }).AnyTimes() + // SetSecret fails for the _AT key — cacheAccessToken must not propagate the error. + mock.EXPECT(). + SetSecret(gomock.Any(), gomock.AssignableToTypeOf(""), gomock.AssignableToTypeOf("")). + Return(errors.New("keyring write failed")).AnyTimes() + + opts := optsWithFakeOIDC(srv, mock) + ts := tokensource.New(opts) + tok, err := ts.Token(context.Background()) + // Token is still returned despite the write failure — graceful degradation. + require.NoError(t, err) + assert.Equal(t, "access-tok", tok) +} + +// ── buildOAuth2Config: OIDC discovery failure propagates ───────────────────── + +// When OIDC discovery fails (bad issuer), tryRestoreFromCache must surface the +// error rather than returning a generic cache-miss or fallback error. +func TestToken_OIDCDiscoveryFails_PropagatesError(t *testing.T) { + t.Parallel() + + // Server that always returns 500 — OIDC well-known endpoints will fail. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return testRefreshToken, nil + }).AnyTimes() + + opts := minimalOpts(mock) + opts.OIDC.Issuer = srv.URL + ts := tokensource.New(opts) + + _, err := ts.Token(context.Background()) + require.Error(t, err) + // OIDC failure must propagate as lastErr, not the generic FallbackErr. + assert.False(t, errors.Is(err, errTestFallback), "OIDC discovery failure must not collapse to FallbackErr") +} + +// ── performBrowserFlow: OIDC discovery failure in interactive mode ──────────── + +// When interactive mode is enabled but OIDC discovery fails, Token() must +// return the discovery error wrapped as "OIDC browser flow failed", not the +// generic FallbackErr. +func TestToken_Interactive_OIDCDiscoveryFails_ReturnsError(t *testing.T) { + t.Parallel() + + // Server that always returns 500 — OIDC well-known endpoints will fail. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + opts := tokensource.Options{ + OIDC: tokensource.OIDCParams{Issuer: srv.URL, ClientID: testClientID}, + Interactive: true, + KeyProvider: func() string { return "test-key" }, + FallbackErr: errTestFallback, + } + ts := tokensource.New(opts) + + _, err := ts.Token(context.Background()) + require.Error(t, err) + assert.False(t, errors.Is(err, errTestFallback), + "browser flow OIDC failure must not collapse to FallbackErr") + assert.Contains(t, err.Error(), "OIDC browser flow failed") +} diff --git a/pkg/auth/utils.go b/pkg/auth/utils.go index 10137afb9c..990fba6be4 100644 --- a/pkg/auth/utils.go +++ b/pkg/auth/utils.go @@ -60,7 +60,10 @@ func ExtractBearerToken(r *http.Request) (string, error) { } // GetAuthenticationMiddleware returns the appropriate authentication middleware based on the configuration. -// If OIDC config is provided, it returns JWT middleware. Otherwise, it returns local user middleware. +// If OIDC config is provided, it returns JWT validation middleware. Otherwise it returns local-user +// middleware and logs a WARNING: with no OIDC config, the proxy accepts every request and forwards it +// under a synthetic local-user identity with no token or credential check. This unauthenticated fallback +// is intended for local/development use; configure an OIDC provider to enforce authentication per request. func GetAuthenticationMiddleware(ctx context.Context, oidcConfig *TokenValidatorConfig, opts ...TokenValidatorOption, ) (func(http.Handler) http.Handler, http.Handler, error) { if oidcConfig != nil { @@ -76,7 +79,9 @@ func GetAuthenticationMiddleware(ctx context.Context, oidcConfig *TokenValidator return jwtValidator.Middleware, authInfoHandler, nil } - slog.Debug("oidc validation disabled, using local user authentication") + slog.Warn("no authentication configured: the proxy will accept every request unauthenticated and " + + "assign each a synthetic local-user identity with no token or credential check; " + + "configure an OIDC provider to enforce authentication per request") // Get current OS user currentUser, err := user.Current() diff --git a/pkg/auth/well_known.go b/pkg/auth/well_known.go index 7505f40e41..0dba98d7d1 100644 --- a/pkg/auth/well_known.go +++ b/pkg/auth/well_known.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // NewWellKnownHandler creates an HTTP handler that routes requests under the diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index a4a3eb3ada..b5b3bc8634 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -71,6 +71,17 @@ type RunConfig struct { // If empty, defaults to registration.DefaultScopes (["openid", "profile", "email", "offline_access"]). ScopesSupported []string `json:"scopes_supported,omitempty" yaml:"scopes_supported,omitempty"` + // BaselineClientScopes is a baseline set of OAuth 2.0 scopes unioned into every + // DCR registration. All values must appear in ScopesSupported; the auth server + // rejects this RunConfig at startup otherwise. Empty means current behavior is + // preserved (registered scope = client-requested, or DefaultScopes if empty). + // When ScopesSupported is empty, the subset check uses registration.DefaultScopes + // (the same set applyDefaults would substitute at startup) — so + // BaselineClientScopes containing standard OIDC scopes works without enumerating + // ScopesSupported explicitly. + //nolint:lll // field tags require full JSON+YAML names + BaselineClientScopes []string `json:"baseline_client_scopes,omitempty" yaml:"baseline_client_scopes,omitempty"` + // AllowedAudiences is the list of valid resource URIs that tokens can be issued for. // Per RFC 8707, the "resource" parameter in authorization and token requests is // validated against this list. Required for MCP compliance. @@ -79,6 +90,78 @@ type RunConfig struct { // Storage configures the storage backend for the auth server. // If nil, defaults to in-memory storage. Storage *storage.RunConfig `json:"storage,omitempty" yaml:"storage,omitempty"` + + // CIMD controls client_id metadata document support. When enabled, the + // embedded authorization server accepts HTTPS URLs as client_id values + // and resolves them via the CIMD protocol instead of requiring DCR. + CIMD *CIMDRunConfig `json:"cimd,omitempty" yaml:"cimd,omitempty"` +} + +// Validate checks that the on-disk RunConfig is internally consistent. Called +// by the runner before resolving secrets and building the runtime Config; it +// catches operator-supplied misconfiguration early so server startup fails +// loudly instead of degrading silently at runtime. +func (c *RunConfig) Validate() error { + if c.CIMD != nil { + if err := c.CIMD.Validate(); err != nil { + return fmt.Errorf("cimd: %w", err) + } + } + return c.validateBaselineClientScopes() +} + +// validateBaselineClientScopes ensures every entry in BaselineClientScopes is +// also present in ScopesSupported. If a baseline scope is not advertised by +// ScopesSupported, the embedded DCR handler would later try to register a +// client with a scope the server does not support, which fosite rejects at +// /oauth/authorize with invalid_scope. +// +// When ScopesSupported is empty, the check uses registration.DefaultScopes as +// the superset (matching what applyDefaults would substitute at startup), so +// operators can omit ScopesSupported and still configure standard OIDC scopes +// as baseline without error. +func (c *RunConfig) validateBaselineClientScopes() error { + effective := c.ScopesSupported + if len(effective) == 0 { + effective = registration.DefaultScopes + } + return registration.ValidateScopeSubset(c.BaselineClientScopes, effective, "baseline_client_scopes") +} + +// CIMDRunConfig controls client_id metadata document (CIMD) support. +type CIMDRunConfig struct { + // Enabled activates CIMD client lookup when true. + Enabled bool `json:"enabled" yaml:"enabled"` + + // CacheMaxSize is the maximum number of CIMD documents held in the LRU cache. + // Defaults to 256 when Enabled is true and this field is zero. + CacheMaxSize int `json:"cache_max_size,omitempty" yaml:"cache_max_size,omitempty"` + + // CacheFallbackTTL is the fixed TTL applied to every cached CIMD document. + // Cache-Control header parsing is not yet implemented; all entries use this value. + // Format: Go duration string (e.g. "5m", "10m", "1h"). + // Defaults to 5 minutes when Enabled is true and this field is omitted. + CacheFallbackTTL string `json:"cache_fallback_ttl,omitempty" yaml:"cache_fallback_ttl,omitempty" example:"5m"` +} + +// Validate checks that the CIMDRunConfig fields are internally consistent. +func (c *CIMDRunConfig) Validate() error { + if !c.Enabled { + return nil + } + if c.CacheMaxSize < 0 { + return fmt.Errorf("cache_max_size must be non-negative when CIMD is enabled, got %d", c.CacheMaxSize) + } + if c.CacheFallbackTTL != "" { + d, err := time.ParseDuration(c.CacheFallbackTTL) + if err != nil { + return fmt.Errorf("cache_fallback_ttl: %w", err) + } + if d <= 0 { + return fmt.Errorf("cache_fallback_ttl must be positive when CIMD is enabled, got %s", c.CacheFallbackTTL) + } + } + return nil } // SigningKeyRunConfig configures where to load signing keys from. @@ -211,6 +294,8 @@ type OAuth2UpstreamRunConfig struct { TokenEndpoint string `json:"token_endpoint" yaml:"token_endpoint"` // ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. + // Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained + // at runtime via RFC 7591 Dynamic Client Registration and must be left empty. ClientID string `json:"client_id" yaml:"client_id"` // ClientSecretFile is the path to a file containing the OAuth 2.0 client secret. @@ -228,8 +313,12 @@ type OAuth2UpstreamRunConfig struct { // Scopes are the OAuth scopes to request from the upstream IDP. Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` - // UserInfo contains configuration for fetching user information (required for OAuth2). - UserInfo *UserInfoRunConfig `json:"userinfo" yaml:"userinfo"` + // UserInfo contains configuration for fetching user information. + // Optional: when nil, the upstream OAuth2 provider derives a deterministic + // subject by SHA-256-hashing the access token (with a "tk-" prefix) instead + // of calling a userinfo endpoint. OIDC providers always derive Subject from + // the ID token and are unaffected. + UserInfo *UserInfoRunConfig `json:"userinfo,omitempty" yaml:"userinfo,omitempty"` // TokenResponseMapping configures custom field extraction from non-standard token responses. // When set, the token exchange bypasses golang.org/x/oauth2 and extracts fields using @@ -237,11 +326,99 @@ type OAuth2UpstreamRunConfig struct { //nolint:lll // field tags require full JSON+YAML names TokenResponseMapping *TokenResponseMappingRunConfig `json:"token_response_mapping,omitempty" yaml:"token_response_mapping,omitempty"` + // IdentityFromToken extracts user identity (subject, name, email) directly from the + // OAuth2 token-endpoint response body using gjson dot-notation paths. When set, the + // embedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type + // (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative + // trust-model and uniqueness documentation lives there. + //nolint:lll // field tags require full JSON+YAML names + IdentityFromToken *IdentityFromTokenRunConfig `json:"identity_from_token,omitempty" yaml:"identity_from_token,omitempty"` + // AdditionalAuthorizationParams are extra query parameters to include in // authorization requests. Useful for provider-specific parameters like // Google's access_type=offline. //nolint:lll // field tags require full JSON+YAML names AdditionalAuthorizationParams map[string]string `json:"additional_authorization_params,omitempty" yaml:"additional_authorization_params,omitempty"` + + // DCRConfig enables RFC 7591 Dynamic Client Registration against the + // upstream authorization server. When set, the client credentials are + // obtained at runtime rather than being pre-provisioned via ClientID / + // ClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty. + // Mutually exclusive with ClientID. + DCRConfig *DCRUpstreamConfig `json:"dcr_config,omitempty" yaml:"dcr_config,omitempty"` +} + +// DCRUpstreamConfig configures RFC 7591 Dynamic Client Registration for an +// upstream authorization server. When present on an OAuth2 upstream, the +// authserver performs registration at runtime to obtain client credentials, +// replacing the need to pre-provision a ClientID. +// +// Exactly one of DiscoveryURL or RegistrationEndpoint must be set. DiscoveryURL +// points at RFC 8414 / OIDC Discovery metadata from which the registration +// endpoint is resolved; RegistrationEndpoint is used directly when the upstream +// does not publish discovery metadata. +// +// Trust assumption: DiscoveryURL and RegistrationEndpoint are operator-supplied +// URLs validated only for HTTPS-or-loopback. The DCR resolver will issue +// outbound HTTP requests — possibly carrying the RFC 7591 initial access token +// as a bearer header — to whatever address those URLs resolve to. There is +// currently no allowlist or RFC1918 / link-local / cloud-metadata-service +// guard, because the operator role is fully trusted today. If the trust +// boundary ever changes (e.g. a multi-tenant operator deployment, or a less- +// privileged role gains write access to this struct via a CRD or YAML +// surface), this field becomes a confused-deputy SSRF vector. Hardening is +// tracked in https://github.com/stacklok/toolhive/issues/5135. +type DCRUpstreamConfig struct { + // DiscoveryURL is the exact RFC 8414 / OIDC Discovery document URL to + // fetch at runtime. The resolver issues a single GET against this URL + // (no well-known-path fallback) and reads registration_endpoint, + // authorization_endpoint, token_endpoint, + // token_endpoint_auth_methods_supported, and scopes_supported from the + // response. Per RFC 8414 §3.3, the document's "issuer" field must + // exactly match the upstream issuer configured on the parent + // run-config. + // + // Use this field when the upstream publishes discovery metadata at a + // path that differs from the issuer-derived well-known paths — for + // example a multi-tenant IdP whose metadata lives at + // https://idp.example.com/tenants/acme/.well-known/openid-configuration. + // + // Mutually exclusive with RegistrationEndpoint. + DiscoveryURL string `json:"discovery_url,omitempty" yaml:"discovery_url,omitempty"` + + // RegistrationEndpoint is the RFC 7591 registration endpoint URL used + // directly, bypassing discovery. Because no discovery is performed, + // server-capability fields (token_endpoint_auth_methods_supported, + // scopes_supported) are unavailable on this code path; the caller is + // expected to also supply AuthorizationEndpoint, TokenEndpoint, and an + // explicit Scopes list on the parent OAuth2UpstreamRunConfig. Auth + // method falls back to the resolver's default (client_secret_basic). + // + // Mutually exclusive with DiscoveryURL. + RegistrationEndpoint string `json:"registration_endpoint,omitempty" yaml:"registration_endpoint,omitempty"` + + // InitialAccessTokenFile is the path to a file containing the RFC 7591 + // initial access token presented to the registration endpoint. Mutually + // exclusive with InitialAccessTokenEnvVar. Both may be omitted for open + // registration endpoints. + //nolint:lll // field tags require full JSON+YAML names + InitialAccessTokenFile string `json:"initial_access_token_file,omitempty" yaml:"initial_access_token_file,omitempty"` + + // InitialAccessTokenEnvVar is the name of an environment variable + // containing the RFC 7591 initial access token. Mutually exclusive with + // InitialAccessTokenFile. + //nolint:lll // field tags require full JSON+YAML names + InitialAccessTokenEnvVar string `json:"initial_access_token_env_var,omitempty" yaml:"initial_access_token_env_var,omitempty"` + + // SoftwareID is the RFC 7591 "software_id" registration metadata value, + // identifying the client software independent of any particular + // registration instance. + SoftwareID string `json:"software_id,omitempty" yaml:"software_id,omitempty"` + + // SoftwareStatement is the RFC 7591 "software_statement" JWT asserting + // metadata about the client software, signed by a party the authorization + // server trusts. + SoftwareStatement string `json:"software_statement,omitempty" yaml:"software_statement,omitempty"` } // TokenResponseMappingRunConfig maps non-standard token response fields to standard fields. @@ -260,6 +437,22 @@ type TokenResponseMappingRunConfig struct { ExpiresInPath string `json:"expires_in_path,omitempty" yaml:"expires_in_path,omitempty"` } +// IdentityFromTokenRunConfig configures extracting user identity claims directly from +// the token-endpoint response body. Mirrors the CRD type +// (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative +// trust-model and uniqueness documentation lives there. +type IdentityFromTokenRunConfig struct { + // SubjectPath is the dot-notation path to the subject (user ID) field. + // Required when IdentityFromToken is set. + SubjectPath string `json:"subject_path" yaml:"subject_path"` + + // NamePath is the dot-notation path to the display name field. + NamePath string `json:"name_path,omitempty" yaml:"name_path,omitempty"` + + // EmailPath is the dot-notation path to the email address field. + EmailPath string `json:"email_path,omitempty" yaml:"email_path,omitempty"` +} + // UserInfoRunConfig contains UserInfo endpoint configuration. // This supports both standard OIDC UserInfo endpoints and custom provider-specific endpoints. type UserInfoRunConfig struct { @@ -370,6 +563,16 @@ type Config struct { // /.well-known/oauth-authorization-server discovery endpoints. ScopesSupported []string + // BaselineClientScopes is a baseline set of OAuth 2.0 scopes the embedded + // DCR handler unions into every newly registered client's scope set. Empty + // means current behavior is preserved (DCR registers exactly what the client + // requested, or registration.DefaultScopes if the client requested none). + // All entries must also be present in ScopesSupported. When ScopesSupported + // is empty, the validation gate uses registration.DefaultScopes as the + // superset — so standard OIDC scopes (e.g. "offline_access") work without + // enumerating ScopesSupported explicitly. + BaselineClientScopes []string + // AllowedAudiences is the list of valid resource URIs that tokens can be issued for. // Per RFC 8707, the "resource" parameter in authorization and token requests is // validated against this list. MCP clients are required to include the resource @@ -380,6 +583,20 @@ type Config struct { // When empty, any request with a "resource" parameter will be rejected with // "invalid_target". Configure this for proper MCP specification compliance. AllowedAudiences []string + + // CIMDEnabled enables the CIMD storage decorator so the authorization server + // accepts HTTPS URLs as client_id values without prior DCR registration. + CIMDEnabled bool + + // CIMDCacheMaxSize is the maximum number of CIMD documents held in the LRU + // cache. Zero is replaced by a default (256) in applyDefaults when CIMDEnabled + // is true. + CIMDCacheMaxSize int + + // CIMDCacheFallbackTTL is the fixed TTL applied to all cached CIMD documents + // (Cache-Control header parsing is not yet implemented). Zero is replaced by + // a default (5 minutes) in applyDefaults when CIMDEnabled is true. + CIMDCacheFallbackTTL time.Duration } // Validate checks that the Config is valid. @@ -416,6 +633,29 @@ func (c *Config) Validate() error { return fmt.Errorf("at least one allowed audience is required for MCP compliance (RFC 8707 resource parameter validation)") } + // BaselineClientScopes must be a subset of ScopesSupported. RunConfig.Validate + // catches this for the YAML-loaded path, but a caller that constructs Config + // directly bypasses that; failing here gives them a clearer call stack than + // the inner validateParams in the provider layer. + // When ScopesSupported is empty, use DefaultScopes as the superset (matching + // what applyDefaults substitutes at startup). + { + effective := c.ScopesSupported + if len(effective) == 0 { + effective = registration.DefaultScopes + } + if err := registration.ValidateScopeSubset(c.BaselineClientScopes, effective, "baseline_client_scopes"); err != nil { + return err + } + } + + if c.CIMDEnabled && c.CIMDCacheMaxSize < 1 { + return fmt.Errorf("cimd.cache_max_size must be >= 1 when CIMD is enabled") + } + if c.CIMDEnabled && c.CIMDCacheFallbackTTL < 0 { + return fmt.Errorf("cimd.cache_fallback_ttl must be non-negative when CIMD is enabled") + } + slog.Debug("authserver config validation passed", "issuer", c.Issuer, "upstream_count", len(c.Upstreams), @@ -423,6 +663,107 @@ func (c *Config) Validate() error { return nil } +// Validate checks that the OAuth2UpstreamRunConfig is internally consistent. +// It enforces the mutual exclusivity of ClientID and DCRConfig: exactly one must +// be set. A ClientID is required for pre-provisioned clients; a DCRConfig is +// required when client credentials are obtained at runtime via RFC 7591 +// Dynamic Client Registration. When DCRConfig is present, its own validity is +// also checked via DCRUpstreamConfig.Validate. +// +// Validate intentionally does not verify fields handled by the shared +// CommonOAuthConfig or upstream.OAuth2Config validators — it only covers the +// run-config surface area unique to OAuth2UpstreamRunConfig. +// +// Called from buildPureOAuth2Config at the RunConfig → upstream.OAuth2Config +// conversion boundary so that DCR-specific fields are validated before they +// are dropped during conversion. +func (c *OAuth2UpstreamRunConfig) Validate() error { + hasClientID := c.ClientID != "" + hasDCR := c.DCRConfig != nil + switch { + case !hasClientID && !hasDCR: + return fmt.Errorf("oauth2 upstream: either client_id or dcr_config is required") + case hasClientID && hasDCR: + return fmt.Errorf("oauth2 upstream: client_id and dcr_config are mutually exclusive") + } + + if hasDCR { + if err := c.DCRConfig.Validate(); err != nil { + return fmt.Errorf("oauth2 upstream: invalid dcr_config: %w", err) + } + + // When the operator configures DCRConfig.RegistrationEndpoint, the + // resolver bypasses discovery and therefore cannot populate + // AuthorizationEndpoint or TokenEndpoint from server metadata. The + // run-config must supply both explicitly or the upstream is + // unusable: registration would succeed and the first authorize or + // token-exchange call would silently fail with empty endpoints. + // Discovery flow (DCRConfig.DiscoveryURL) is unaffected — those + // fields populate from metadata. + if c.DCRConfig.RegistrationEndpoint != "" { + if c.AuthorizationEndpoint == "" || c.TokenEndpoint == "" { + return fmt.Errorf( + "oauth2 upstream: authorization_endpoint and token_endpoint are required " + + "when dcr_config.registration_endpoint is set (no discovery to populate them)") + } + } + } + + if c.IdentityFromToken != nil && c.IdentityFromToken.SubjectPath == "" { + return fmt.Errorf("oauth2 upstream: identity_from_token.subject_path must not be empty when identity_from_token is configured") + } + + return nil +} + +// Validate checks that the DCRUpstreamConfig specifies exactly one of +// DiscoveryURL or RegistrationEndpoint, that the configured URL is well-formed +// and uses HTTPS (or http on a loopback host for local development), and that +// the two initial-access-token sources (InitialAccessTokenFile and +// InitialAccessTokenEnvVar) are not both set. +// +// DiscoveryURL triggers runtime resolution of the registration endpoint via +// RFC 8414 / OIDC Discovery; RegistrationEndpoint bypasses discovery for +// providers that do not publish metadata. Requiring exactly one prevents +// ambiguity about which URL the authserver should contact for registration. +// +// URL well-formedness and HTTPS are enforced here at the schema-validation +// boundary so misconfiguration fails fast at startup rather than at first DCR +// attempt; the runtime callers (pkg/oauthproto/discovery.go and +// pkg/oauthproto/dcr.go) defend in depth, but this is the natural fail-fast +// point. +// +// Rejecting a config that supplies both an InitialAccessTokenFile and an +// InitialAccessTokenEnvVar prevents a credential-rotation footgun: if both +// were accepted, an operator updating the env-var value would not realize +// the file source still wins (or vice versa) and would silently keep +// presenting a stale token at registration. +func (c *DCRUpstreamConfig) Validate() error { + hasDiscovery := c.DiscoveryURL != "" + hasRegistration := c.RegistrationEndpoint != "" + switch { + case !hasDiscovery && !hasRegistration: + return fmt.Errorf("dcr_config: either discovery_url or registration_endpoint is required") + case hasDiscovery && hasRegistration: + return fmt.Errorf("dcr_config: discovery_url and registration_endpoint are mutually exclusive") + case hasDiscovery: + if err := networking.ValidateEndpointURL(c.DiscoveryURL); err != nil { + return fmt.Errorf("dcr_config: invalid discovery_url: %w", err) + } + case hasRegistration: + if err := networking.ValidateEndpointURL(c.RegistrationEndpoint); err != nil { + return fmt.Errorf("dcr_config: invalid registration_endpoint: %w", err) + } + } + + if c.InitialAccessTokenFile != "" && c.InitialAccessTokenEnvVar != "" { + return fmt.Errorf( + "dcr_config: initial_access_token_file and initial_access_token_env_var are mutually exclusive") + } + + return nil +} + // validateUpstreams validates the upstream configurations. func (c *Config) validateUpstreams() error { if len(c.Upstreams) == 0 { @@ -545,6 +886,14 @@ func (c *Config) applyDefaults() error { c.ScopesSupported = registration.DefaultScopes slog.Debug("applied default scopes_supported", "scopes", c.ScopesSupported) } + if c.CIMDEnabled && c.CIMDCacheMaxSize == 0 { + c.CIMDCacheMaxSize = 256 + slog.Debug("applied default cimd cache_max_size", "size", c.CIMDCacheMaxSize) + } + if c.CIMDEnabled && c.CIMDCacheFallbackTTL == 0 { + c.CIMDCacheFallbackTTL = 5 * time.Minute + slog.Debug("applied default cimd cache_fallback_ttl", "ttl", c.CIMDCacheFallbackTTL) + } return nil } diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index a3c665afec..3c270771a4 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -9,8 +9,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/upstream" ) @@ -106,6 +109,18 @@ func TestConfigValidate(t *testing.T) { {name: "OIDC with oauth2_config set rejects", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "test", Type: UpstreamProviderTypeOIDC, OIDCConfig: validOIDCUpstream, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}}, wantErr: true, errMsg: "oauth2_config must not be set"}, {name: "OAuth2 with oidc_config set rejects", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "test", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream, OIDCConfig: validOIDCUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}}, wantErr: true, errMsg: "oidc_config must not be set"}, + // BaselineClientScopes subset gate (mirrors RunConfig.Validate but on the + // runtime Config — catches direct constructors that bypass YAML loading). + {name: "baseline scope not in scopes_supported", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, ScopesSupported: []string{"openid"}, BaselineClientScopes: []string{"offline_access"}}, wantErr: true, errMsg: `baseline_client_scopes contains "offline_access"`}, + {name: "nil supported with baseline in DefaultScopes passes", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, ScopesSupported: nil, BaselineClientScopes: []string{"offline_access"}}}, + + // CIMD validation + {name: "CIMD enabled zero cache_max_size rejected", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, CIMDEnabled: true, CIMDCacheMaxSize: 0}, wantErr: true, errMsg: "cache_max_size must be >= 1"}, + {name: "CIMD enabled negative cache_max_size rejected", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, CIMDEnabled: true, CIMDCacheMaxSize: -1}, wantErr: true, errMsg: "cache_max_size must be >= 1"}, + {name: "CIMD enabled negative cache_fallback_ttl rejected", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, CIMDEnabled: true, CIMDCacheMaxSize: 256, CIMDCacheFallbackTTL: -time.Second}, wantErr: true, errMsg: "cache_fallback_ttl must be non-negative"}, + {name: "CIMD disabled ignores invalid cache fields", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, CIMDEnabled: false, CIMDCacheMaxSize: -1, CIMDCacheFallbackTTL: -time.Second}}, + {name: "CIMD enabled with valid bounds passes", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, CIMDEnabled: true, CIMDCacheMaxSize: 256, CIMDCacheFallbackTTL: 5 * time.Minute}}, + // Valid configs {name: "valid minimal", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, {name: "valid nil key provider", config: Config{Issuer: "https://example.com", HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, @@ -239,3 +254,399 @@ func assertError(t *testing.T, err error, wantErr bool, errMsg string) { t.Errorf("unexpected error: %v", err) } } + +func TestOAuth2UpstreamRunConfigValidate(t *testing.T) { + t.Parallel() + + validDCR := &DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + } + + tests := []struct { + name string + config OAuth2UpstreamRunConfig + wantErr bool + errMsg string + }{ + // Four ClientID x DCRConfig combinations. + { + name: "empty ClientID and nil DCRConfig rejects", + config: OAuth2UpstreamRunConfig{}, + wantErr: true, + errMsg: "either client_id or dcr_config is required", + }, + { + name: "non-empty ClientID and non-nil DCRConfig rejects", + config: OAuth2UpstreamRunConfig{ClientID: "c", DCRConfig: validDCR}, + wantErr: true, + errMsg: "client_id and dcr_config are mutually exclusive", + }, + { + name: "non-empty ClientID and nil DCRConfig is valid", + config: OAuth2UpstreamRunConfig{ClientID: "c"}, + }, + { + name: "empty ClientID and non-nil DCRConfig is valid", + config: OAuth2UpstreamRunConfig{DCRConfig: validDCR}, + }, + + // DCRConfig exactly-one-of rule propagates. + { + name: "DCRConfig with both discovery_url and registration_endpoint rejects", + config: OAuth2UpstreamRunConfig{ + DCRConfig: &DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + wantErr: true, + errMsg: "discovery_url and registration_endpoint are mutually exclusive", + }, + { + name: "DCRConfig with neither discovery_url nor registration_endpoint rejects", + config: OAuth2UpstreamRunConfig{ + DCRConfig: &DCRUpstreamConfig{}, + }, + wantErr: true, + errMsg: "either discovery_url or registration_endpoint is required", + }, + { + name: "DCRConfig with only registration_endpoint is valid when authorization_endpoint and token_endpoint are also set", + config: OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + DCRConfig: &DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + }, + + // registration_endpoint requires explicit authorize/token endpoints. + // Discovery would have populated them; bypassing discovery means the + // run-config must supply them or the upstream is unusable. + { + name: "DCRConfig.registration_endpoint without authorization_endpoint rejects", + config: OAuth2UpstreamRunConfig{ + TokenEndpoint: "https://idp.example.com/token", + DCRConfig: &DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + wantErr: true, + errMsg: "authorization_endpoint and token_endpoint are required", + }, + { + name: "DCRConfig.registration_endpoint without token_endpoint rejects", + config: OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://idp.example.com/authorize", + DCRConfig: &DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + wantErr: true, + errMsg: "authorization_endpoint and token_endpoint are required", + }, + { + name: "DCRConfig.discovery_url is valid without explicit endpoints (discovery populates them)", + config: OAuth2UpstreamRunConfig{ + DCRConfig: &DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + }, + }, + }, + + // IdentityFromToken subject_path requirement. + { + name: "IdentityFromToken with empty SubjectPath rejects", + config: OAuth2UpstreamRunConfig{ + ClientID: "c", + IdentityFromToken: &IdentityFromTokenRunConfig{}, + }, + wantErr: true, + errMsg: "identity_from_token.subject_path must not be empty", + }, + { + name: "IdentityFromToken with non-empty SubjectPath is valid", + config: OAuth2UpstreamRunConfig{ + ClientID: "c", + IdentityFromToken: &IdentityFromTokenRunConfig{ + SubjectPath: "username", + }, + }, + }, + { + name: "nil IdentityFromToken is valid", + config: OAuth2UpstreamRunConfig{ + ClientID: "c", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.config.Validate() + assertError(t, err, tt.wantErr, tt.errMsg) + }) + } +} + +func TestRunConfigValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config RunConfig + wantErr bool + errMsg string + }{ + { + name: "nil baseline scopes passes", + config: RunConfig{ScopesSupported: []string{"openid", "profile"}, BaselineClientScopes: nil}, + }, + { + name: "empty baseline scopes passes", + config: RunConfig{ScopesSupported: []string{"openid", "profile"}, BaselineClientScopes: []string{}}, + }, + { + name: "single baseline entry in supported set passes", + config: RunConfig{ScopesSupported: []string{"openid", "profile", "email"}, BaselineClientScopes: []string{"openid"}}, + }, + { + name: "all baseline entries in supported set passes", + config: RunConfig{ScopesSupported: []string{"openid", "profile", "email", "offline_access"}, BaselineClientScopes: []string{"openid", "offline_access"}}, + }, + { + name: "baseline contains scope not in supported rejects with specific error", + config: RunConfig{ScopesSupported: []string{"openid"}, BaselineClientScopes: []string{"openid", "offline_access"}}, + wantErr: true, + errMsg: `"offline_access" which is not in scopes_supported`, + }, + { + name: "nil supported with baseline in DefaultScopes passes", + config: RunConfig{ScopesSupported: nil, BaselineClientScopes: []string{"offline_access"}}, + }, + { + name: "nil supported with baseline outside DefaultScopes rejects", + config: RunConfig{ScopesSupported: nil, BaselineClientScopes: []string{"custom_scope"}}, + wantErr: true, + errMsg: `"custom_scope"`, + }, + { + name: "first missing scope is reported when multiple are missing", + config: RunConfig{ScopesSupported: []string{"openid"}, BaselineClientScopes: []string{"foo", "bar"}}, + wantErr: true, + errMsg: "foo", + }, + // CIMD RunConfig validation + {name: "CIMD nil passes", config: RunConfig{CIMD: nil}}, + {name: "CIMD disabled passes even with invalid fields", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: false, CacheMaxSize: -1, CacheFallbackTTL: "-1s"}}}, + {name: "CIMD enabled negative cache_max_size rejected", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheMaxSize: -1}}, wantErr: true, errMsg: "cache_max_size"}, + {name: "CIMD enabled invalid TTL string rejected", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheFallbackTTL: "not-a-duration"}}, wantErr: true, errMsg: "cache_fallback_ttl"}, + {name: "CIMD enabled negative TTL rejected", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheFallbackTTL: "-5m"}}, wantErr: true, errMsg: "cache_fallback_ttl"}, + {name: "CIMD enabled valid passes", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheMaxSize: 64, CacheFallbackTTL: "5m"}}}, + {name: "CIMD enabled omitted optional fields pass", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.config.Validate() + assertError(t, err, tt.wantErr, tt.errMsg) + }) + } +} + +func TestDCRUpstreamConfigValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config DCRUpstreamConfig + wantErr bool + errMsg string + }{ + { + name: "neither discovery_url nor registration_endpoint rejects", + config: DCRUpstreamConfig{}, + wantErr: true, + errMsg: "either discovery_url or registration_endpoint is required", + }, + { + name: "both discovery_url and registration_endpoint rejects", + config: DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + RegistrationEndpoint: "https://idp.example.com/register", + }, + wantErr: true, + errMsg: "discovery_url and registration_endpoint are mutually exclusive", + }, + { + name: "only discovery_url is valid", + config: DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + }, + }, + { + name: "only registration_endpoint is valid", + config: DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + { + name: "software metadata and a single token source pass validation", + config: DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + InitialAccessTokenFile: "/var/run/secrets/dcr-token", + SoftwareID: "toolhive", + SoftwareStatement: "eyJhbGciOi...", + }, + }, + { + name: "both initial_access_token_file and initial_access_token_env_var rejects", + config: DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + InitialAccessTokenFile: "/var/run/secrets/dcr-token", + InitialAccessTokenEnvVar: "DCR_TOKEN", + }, + wantErr: true, + errMsg: "initial_access_token_file and initial_access_token_env_var are mutually exclusive", + }, + { + name: "malformed discovery_url rejects", + config: DCRUpstreamConfig{DiscoveryURL: "://broken"}, + wantErr: true, + errMsg: "invalid discovery_url", + }, + { + name: "non-loopback http discovery_url rejects", + config: DCRUpstreamConfig{DiscoveryURL: "http://idp.example.com/.well-known/oauth-authorization-server"}, + wantErr: true, + errMsg: "invalid discovery_url", + }, + { + name: "non-loopback http registration_endpoint rejects", + config: DCRUpstreamConfig{RegistrationEndpoint: "http://idp.example.com/register"}, + wantErr: true, + errMsg: "invalid registration_endpoint", + }, + { + name: "loopback http discovery_url is valid", + config: DCRUpstreamConfig{ + DiscoveryURL: "http://localhost:8080/.well-known/oauth-authorization-server", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.config.Validate() + assertError(t, err, tt.wantErr, tt.errMsg) + }) + } +} + +func TestConfigApplyDefaults_BaselineClientScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopesSupported []string + baselineClientScopes []string + wantErr bool + errMsg string + wantDefaultScopes bool + }{ + { + name: "empty scopes_supported and empty baseline — defaults substituted", + wantDefaultScopes: true, + }, + { + name: "scopes_supported set and empty baseline — no substitution", + scopesSupported: []string{"openid", "profile"}, + }, + { + name: "scopes_supported set and baseline non-empty — no substitution no error", + scopesSupported: []string{"openid", "profile"}, + baselineClientScopes: []string{"openid"}, + }, + { + name: "empty scopes_supported with non-empty baseline applies DefaultScopes", + baselineClientScopes: []string{"openid"}, + wantDefaultScopes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &Config{ + ScopesSupported: tt.scopesSupported, + BaselineClientScopes: tt.baselineClientScopes, + } + + err := cfg.applyDefaults() + + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + return + } + + require.NoError(t, err) + if tt.wantDefaultScopes { + require.Equal(t, registration.DefaultScopes, cfg.ScopesSupported) + } else { + require.Equal(t, tt.scopesSupported, cfg.ScopesSupported) + } + }) + } +} + +func TestConfigApplyDefaults_CIMD(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + wantMaxSize int + wantFallbackTTL time.Duration + }{ + { + name: "CIMD enabled with zero fields applies defaults", + cfg: Config{Issuer: "https://example.com", CIMDEnabled: true}, + wantMaxSize: 256, + wantFallbackTTL: 5 * time.Minute, + }, + { + name: "CIMD enabled preserves non-zero values", + cfg: Config{ + Issuer: "https://example.com", + CIMDEnabled: true, + CIMDCacheMaxSize: 128, + CIMDCacheFallbackTTL: 10 * time.Minute, + }, + wantMaxSize: 128, + wantFallbackTTL: 10 * time.Minute, + }, + { + name: "CIMD disabled leaves zero fields unchanged", + cfg: Config{Issuer: "https://example.com", CIMDEnabled: false}, + wantMaxSize: 0, + wantFallbackTTL: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := tt.cfg + err := cfg.applyDefaults() + require.NoError(t, err) + require.Equal(t, tt.wantMaxSize, cfg.CIMDCacheMaxSize) + require.Equal(t, tt.wantFallbackTTL, cfg.CIMDCacheFallbackTTL) + }) + } +} diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 051cf0748e..b1611ec57f 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -4,23 +4,28 @@ package authserver import ( + "bytes" "context" "crypto/rand" "crypto/rsa" "encoding/json" "fmt" "io" + "net" "net/http" "net/http/httptest" "net/url" "strings" + "sync/atomic" "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "github.com/oauth2-proxy/mockoidc" "github.com/ory/fosite" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,11 +49,29 @@ const ( ) // testServer bundles all test server components together. +// +// The mr field is non-nil only when the test server was constructed +// with withRedisBackedStorage(); otherwise it is nil and the in-memory +// backend is used. Tests that need to advance Redis time call Miniredis(t) +// rather than dereferencing this field directly so a misconfigured test +// fails loudly instead of nil-panicking. type testServer struct { Server *httptest.Server PrivateKey *rsa.PrivateKey authServer Server storage storage.UpstreamTokenStorage + mr *miniredis.Miniredis +} + +// Miniredis returns the miniredis instance backing this test server. Fails +// the test loudly with a useful message if the server was not constructed +// with withRedisBackedStorage(). Use this rather than dereferencing the +// field directly. +func (ts *testServer) Miniredis(t *testing.T) *miniredis.Miniredis { + t.Helper() + require.NotNil(t, ts.mr, + "testServer was not constructed with withRedisBackedStorage(); call setupTestServer*(..., withRedisBackedStorage()) to enable miniredis access") + return ts.mr } // testServerOptions configures the test server setup. @@ -56,6 +79,13 @@ type testServerOptions struct { upstream upstream.OAuth2Provider scopes []string accessTokenLifespan time.Duration + // storageFactory, when non-nil, supplies the storage backend instead of + // the default in-memory implementation. The factory may also return a + // *miniredis.Miniredis instance; when it does, the setup helper stashes + // it on testServer.mr (accessed via testServer.Miniredis()) so tests can drive its clock. A nil + // miniredis return value is valid (e.g., for non-Redis alternative + // backends). + storageFactory func(t *testing.T) (storage.Storage, *miniredis.Miniredis) } // testServerOption is a functional option for test server setup. @@ -82,6 +112,31 @@ func withAccessTokenLifespan(d time.Duration) testServerOption { } } +// withRedisBackedStorage swaps the default in-memory storage for a +// miniredis-backed *RedisStorage. This exercises the same Lua scripts and +// Redis-shape key layout used in production, while remaining hermetic and +// fast: each test gets its own miniredis instance with no external +// dependencies. The {ns:test} hash tag in the key prefix matches the +// production-shape cluster routing so multi-key Lua operations target the +// same hash slot. +// +// The setup helper stashes the *miniredis.Miniredis on testServer.mr (accessed via testServer.Miniredis()) +// so tests can call FastForward(d) to advance Redis-side TTLs without +// real-world sleeping. +func withRedisBackedStorage() testServerOption { + return func(opts *testServerOptions) { + opts.storageFactory = func(t *testing.T) (storage.Storage, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + _ = client.Close() + }) + return storage.NewRedisStorageWithClient(client, "test:auth:{ns:test}:"), mr + } + } +} + // testKeyProvider is a simple KeyProvider for tests that uses a pre-generated RSA key. type testKeyProvider struct { key *rsa.PrivateKey @@ -128,8 +183,17 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { _, err = rand.Read(secret) require.NoError(t, err) - // 3. Create storage - stor := storage.NewMemoryStorage() + // 3. Create storage. Tests opting into withRedisBackedStorage() get a + // miniredis-backed *RedisStorage; everyone else keeps the default + // in-memory backend. The mr return value is preserved so tests can + // FastForward Redis-side TTLs. + var ( + stor storage.Storage = storage.NewMemoryStorage() + mr *miniredis.Miniredis + ) + if options.storageFactory != nil { + stor, mr = options.storageFactory(t) + } // 4. Register test client (public client for PKCE) err = stor.RegisterClient(ctx, &fosite.DefaultClient{ @@ -196,6 +260,7 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { PrivateKey: privateKey, authServer: srv, storage: srv.IDPTokenStorage(), + mr: mr, } } @@ -840,10 +905,19 @@ func TestIntegration_FullPKCEFlow_DefaultAudience(t *testing.T) { // - UpstreamConfig{Type: OIDC, OIDCConfig: ...} // - defaultUpstreamFactory dispatching to NewOIDCProvider // - OIDCProviderImpl with OIDC discovery, ID token validation, and nonce support -func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC) *testServerWithUpstream { +// +// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage); +// the upstream is fixed because this helper exists specifically to exercise the +// real OIDC factory path. Other testServerOptions are silently ignored. +func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC, opts ...testServerOption) *testServerWithUpstream { t.Helper() ctx := context.Background() + options := &testServerOptions{} + for _, opt := range opts { + opt(options) + } + cfg := m.Config() // 1. Generate RSA key for our auth server's signing @@ -855,8 +929,14 @@ func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC) *testSe _, err = rand.Read(secret) require.NoError(t, err) - // 3. Create storage - stor := storage.NewMemoryStorage() + // 3. Create storage. See setupTestServer for the factory contract. + var ( + stor storage.Storage = storage.NewMemoryStorage() + mr *miniredis.Miniredis + ) + if options.storageFactory != nil { + stor, mr = options.storageFactory(t) + } // 4. Register test client (public client for PKCE) err = stor.RegisterClient(ctx, &fosite.DefaultClient{ @@ -915,6 +995,7 @@ func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC) *testSe PrivateKey: privateKey, authServer: srv, storage: srv.IDPTokenStorage(), + mr: mr, }, mockOIDC: m, } @@ -1329,6 +1410,72 @@ func TestIntegration_UpstreamTokenService_RefreshExpiredTokens(t *testing.T) { _ = originalAccessToken // used only to confirm the flow completed } +// TestIntegration_UpstreamTokenService_NonExpiringToken verifies that a token with +// a zero ExpiresAt is treated as non-expiring: GetValidTokens must return the +// stored access token unchanged and must not attempt a refresh. If a refresh were +// triggered, mockoidc would return an error because no user is queued — that +// outcome is the failure signal for this test. +func TestIntegration_UpstreamTokenService_NonExpiringToken(t *testing.T) { + t.Parallel() + + m := startMockOIDC(t) + ts := setupTestServerWithMockOIDC(t, m) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "upstream-nonexpiring-test", + Challenge: challenge, + Scope: "openid profile offline_access", + ResponseType: "code", + }) + + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + tsid := extractTSID(t, accessToken, ts.PrivateKey.Public()) + + stor := ts.authServer.IDPTokenStorage() + + // Read the tokens stored during the OAuth callback. + original, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, original) + + // Overwrite storage with a copy where ExpiresAt is zero (non-expiring). + // No mockoidc user is queued — if a refresh is attempted, the test will fail. + nonExpiring := &storage.UpstreamTokens{ + ProviderID: original.ProviderID, + AccessToken: original.AccessToken, + RefreshToken: original.RefreshToken, + IDToken: original.IDToken, + ExpiresAt: time.Time{}, + UserID: original.UserID, + UpstreamSubject: original.UpstreamSubject, + ClientID: original.ClientID, + } + require.NoError(t, stor.StoreUpstreamTokens(context.Background(), tsid, "default", nonExpiring)) + + svc := upstreamtoken.NewInProcessService(stor, ts.authServer.UpstreamTokenRefresher()) + + cred, err := svc.GetValidTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, cred) + assert.NotEmpty(t, cred.AccessToken) + + // Confirm the token in storage still has a zero ExpiresAt — no refresh occurred. + refreshed, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + assert.True(t, refreshed.ExpiresAt.IsZero(), + "non-expiring token must not gain an ExpiresAt after GetValidTokens") + assert.Equal(t, original.AccessToken, cred.AccessToken, + "access token must be unchanged — no refresh occurred") +} + // TestIntegration_UpstreamTokenService_SessionNotFound verifies that the service // returns ErrSessionNotFound for a non-existent session. func TestIntegration_UpstreamTokenService_SessionNotFound(t *testing.T) { @@ -1566,10 +1713,19 @@ func TestIntegration_RefreshPreservesUpstreamTokenBinding(t *testing.T) { // configured as sequential upstream providers. This exercises the multi-upstream // authorization chain where the callback handler redirects to the next upstream // after each successful code exchange. -func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC) *testServer { +// +// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage) +// without affecting the rest of the chain wiring. Upstream-related options are +// silently ignored because the helper hard-wires the two providers itself. +func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC, opts ...testServerOption) *testServer { t.Helper() ctx := context.Background() + options := &testServerOptions{} + for _, opt := range opts { + opt(options) + } + // 1. Generate RSA key for signing privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) @@ -1579,8 +1735,14 @@ func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC) *t _, err = rand.Read(secret) require.NoError(t, err) - // 3. Create storage - stor := storage.NewMemoryStorage() + // 3. Create storage. See setupTestServer for the factory contract. + var ( + stor storage.Storage = storage.NewMemoryStorage() + mr *miniredis.Miniredis + ) + if options.storageFactory != nil { + stor, mr = options.storageFactory(t) + } // 4. Register test client (public client for PKCE) err = stor.RegisterClient(ctx, &fosite.DefaultClient{ @@ -1682,7 +1844,9 @@ func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC) *t return &testServer{ Server: httpServer, PrivateKey: privateKey, + authServer: srv, storage: srv.IDPTokenStorage(), + mr: mr, } } @@ -1718,92 +1882,15 @@ func TestIntegration_MultiUpstreamSequentialChain(t *testing.T) { }) ts := setupTestServerWithTwoUpstreams(t, m1, m2) - client := noRedirectClient() verifier := servercrypto.GeneratePKCEVerifier() challenge := servercrypto.ComputePKCEChallenge(verifier) clientState := "multi-upstream-client-state" - parsedServerURL, err := url.Parse(ts.Server.URL) - require.NoError(t, err) - - // === Leg 1: Client -> /authorize -> provider-1 === - - // Step 1: Start authorization flow on our server - authorizeURL := ts.Server.URL + "/oauth/authorize?" + url.Values{ - "client_id": {testClientID}, - "redirect_uri": {testRedirectURI}, - "state": {clientState}, - "code_challenge": {challenge}, - "code_challenge_method": {"S256"}, - "response_type": {"code"}, - "scope": {"openid profile"}, - }.Encode() - - resp, err := client.Get(authorizeURL) - require.NoError(t, err) - require.Equal(t, http.StatusFound, resp.StatusCode, "expected redirect to provider-1") - m1Location, err := resp.Location() - require.NoError(t, err) - resp.Body.Close() - - // Step 2: Follow redirect to provider-1's authorization endpoint (mockoidc auto-approves) - resp, err = client.Get(m1Location.String()) - require.NoError(t, err) - require.Equal(t, http.StatusFound, resp.StatusCode, "expected redirect from provider-1 to our callback") - callbackFromM1, err := resp.Location() - require.NoError(t, err) - resp.Body.Close() - - // Step 3: Rewrite callback URL to use actual test server (mockoidc redirects to localhost) - callbackFromM1.Scheme = parsedServerURL.Scheme - callbackFromM1.Host = parsedServerURL.Host - - // Step 4: Call our /callback with provider-1's code - // This should NOT redirect to the client yet — it should redirect to provider-2 - resp, err = client.Get(callbackFromM1.String()) - require.NoError(t, err) - require.Equal(t, http.StatusFound, resp.StatusCode, - "expected 302 redirect to provider-2 (chain continues), not 303 to client") - m2Location, err := resp.Location() - require.NoError(t, err) - resp.Body.Close() - - // === Leg 2: provider-2 === - - // Step 5: Follow redirect to provider-2's authorization endpoint (mockoidc auto-approves) - resp, err = client.Get(m2Location.String()) - require.NoError(t, err) - require.Equal(t, http.StatusFound, resp.StatusCode, "expected redirect from provider-2 to our callback") - callbackFromM2, err := resp.Location() - require.NoError(t, err) - resp.Body.Close() - - // Step 6: Rewrite callback URL to use actual test server - callbackFromM2.Scheme = parsedServerURL.Scheme - callbackFromM2.Host = parsedServerURL.Host - - // Step 7: Call our /callback with provider-2's code - // All upstreams are now satisfied, so this should redirect to the client with an auth code (303) - resp, err = client.Get(callbackFromM2.String()) - require.NoError(t, err) - require.Equal(t, http.StatusSeeOther, resp.StatusCode, - "expected 303 redirect to client with auth code (all upstreams satisfied)") - clientLocation, err := resp.Location() - require.NoError(t, err) - resp.Body.Close() - - // === Verify the final redirect to the client === - - // Verify the original client state was preserved through the entire chain - returnedState := clientLocation.Query().Get("state") - assert.Equal(t, clientState, returnedState, "client state should be preserved through the multi-upstream chain") - - // Extract authorization code - authCode := clientLocation.Query().Get("code") - require.NotEmpty(t, authCode, "authorization code should be present in the final redirect") - - // === Exchange code for tokens === + // runChainFlow drives the two-leg chain (authorize → provider-1 callback → + // provider-2 callback → 303 to client) and asserts the chain-level invariants: + // each leg returns the expected redirect, and client state is preserved end-to-end. + authCode := runChainFlow(t, ts.Server.URL, challenge, clientState) tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) @@ -1866,3 +1953,833 @@ func TestIntegration_MultiUpstreamSequentialChain(t *testing.T) { assert.NotEqual(t, tokens1.UpstreamSubject, tokens2.UpstreamSubject, "upstream subjects should differ (different IDPs)") } + +// ============================================================================ +// Non-Expiring Upstream Token Regression Tests +// ============================================================================ + +// captureResponseWriter buffers a handler's HTTP response so a middleware can +// inspect or rewrite it before flushing to the real ResponseWriter. +type captureResponseWriter struct { + header http.Header + body bytes.Buffer + status int +} + +func newCaptureResponseWriter() *captureResponseWriter { + return &captureResponseWriter{header: http.Header{}, status: http.StatusOK} +} + +func (c *captureResponseWriter) Header() http.Header { return c.header } + +func (c *captureResponseWriter) WriteHeader(status int) { c.status = status } + +func (c *captureResponseWriter) Write(b []byte) (int, error) { return c.body.Write(b) } + +// stripExpiresInMiddleware returns a middleware that, for token-endpoint +// responses, removes the `expires_in` field from the JSON body. This emulates +// upstream IDPs that issue non-expiring access tokens (e.g., long-lived PATs) +// without requiring a fork of mockoidc. +// +// Important: this exercises the same wire shape a real provider would emit, so +// our auth server's `convertOAuth2Token` runs with `oauth2.Token.Expiry == +// time.Time{}`. A regression that re-introduces a synthetic expiry there would +// produce a non-zero `ExpiresAt` in storage and is caught by callers of this +// helper. +func stripExpiresInMiddleware(tokenPath string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != tokenPath { + next.ServeHTTP(rw, req) + return + } + + capture := newCaptureResponseWriter() + next.ServeHTTP(capture, req) + + // Only rewrite successful JSON token responses. Errors and + // non-JSON bodies are passed through unchanged. + body := capture.body.Bytes() + contentType := capture.header.Get("Content-Type") + if capture.status == http.StatusOK && strings.Contains(contentType, "json") { + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err == nil { + delete(payload, "expires_in") + if rewritten, err := json.Marshal(payload); err == nil { + body = rewritten + capture.header.Set("Content-Length", fmt.Sprintf("%d", len(body))) + } + } + } + + for k, v := range capture.header { + rw.Header()[k] = v + } + rw.WriteHeader(capture.status) + _, _ = rw.Write(body) + }) + } +} + +// startMockOIDCNoExpiresIn starts a mockoidc instance whose token endpoint +// responses have `expires_in` stripped, so the upstream OAuth2 client parses +// `Expiry == time.Time{}`. The test still gets a fully working OIDC server +// for the rest of the flow (authorize, userinfo, JWKS). +// +// No default user is queued; callers must call QueueUser themselves so the +// failure mode for an unintended refresh (mockoidc returning an error because +// no user is queued) is preserved. +func startMockOIDCNoExpiresIn(t *testing.T) *mockoidc.MockOIDC { + t.Helper() + + m, err := mockoidc.NewServer(nil) + require.NoError(t, err) + + require.NoError(t, m.AddMiddleware(stripExpiresInMiddleware(mockoidc.TokenEndpoint))) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + require.NoError(t, m.Start(ln, nil)) + + t.Cleanup(func() { + require.NoError(t, m.Shutdown()) + }) + + return m +} + +// TestIntegration_FullFlow_NonExpiringUpstreamToken drives the full HTTP flow +// (authorize -> callback -> token) against an upstream IDP whose token +// endpoint omits `expires_in`. It asserts that the upstream tokens reach +// storage with `ExpiresAt.IsZero()` and that GetValidTokens does not trigger +// a refresh on a non-expiring token. +// +// This pins the end-to-end behavior of `convertOAuth2Token`: a regression +// that re-introduces a synthetic expiry (e.g., defaulting to time.Hour) would +// fail the IsZero assertion below. The single queued mockoidc user is +// consumed during /authorize; if GetValidTokens accidentally triggered a +// refresh, mockoidc would error because no further user is queued — that +// outcome is the failure signal. +func TestIntegration_FullFlow_NonExpiringUpstreamToken(t *testing.T) { + t.Parallel() + + m := startMockOIDCNoExpiresIn(t) + m.QueueUser(&mockoidc.MockUser{ + Subject: "non-expiring-user", + Email: "non-expiring@example.com", + }) + + ts := setupTestServerWithMockOIDC(t, m) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "non-expiring-full-flow", + Challenge: challenge, + Scope: "openid profile offline_access", + ResponseType: "code", + }) + + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + tsid := extractTSID(t, accessToken, ts.PrivateKey.Public()) + + stor := ts.authServer.IDPTokenStorage() + + // The upstream tokens written during /callback must carry a zero ExpiresAt: + // the upstream response had no expires_in, so convertOAuth2Token must + // preserve the zero value all the way into storage. + original, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, original) + require.NotEmpty(t, original.AccessToken) + assert.True(t, original.ExpiresAt.IsZero(), + "upstream ExpiresAt must be zero for a token response without expires_in (got %v)", + original.ExpiresAt) + + // GetValidTokens on a non-expiring token must return the stored access + // token unchanged. No refresh user is queued — a refresh attempt would + // cause mockoidc to return an error and fail the assertion below. + svc := upstreamtoken.NewInProcessService(stor, ts.authServer.UpstreamTokenRefresher()) + cred, err := svc.GetValidTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, cred) + assert.Equal(t, original.AccessToken, cred.AccessToken, + "non-expiring access token must be returned unchanged (no refresh)") + + // Re-read storage: ExpiresAt must still be zero, confirming no refresh + // side effect rewrote the row. + after, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + assert.True(t, after.ExpiresAt.IsZero(), + "non-expiring token must keep zero ExpiresAt after GetValidTokens (got %v)", + after.ExpiresAt) + assert.Equal(t, original.AccessToken, after.AccessToken, + "access token in storage must be unchanged after GetValidTokens") +} + +// runChainFlow performs the two-leg multi-upstream authorization chain and +// returns the final authorization code redirected to the client. It mirrors +// the hand-crafted flow in TestIntegration_MultiUpstreamSequentialChain but +// is reused by the mixed-expiry orderings test. The PKCE verifier is the +// caller's responsibility — it's only needed at the final /token exchange. +func runChainFlow( + t *testing.T, + serverURL string, + challenge string, + clientState string, +) string { + t.Helper() + client := noRedirectClient() + + parsedServerURL, err := url.Parse(serverURL) + require.NoError(t, err) + + // Leg 1: client -> /authorize -> first upstream + authorizeURL := serverURL + "/oauth/authorize?" + url.Values{ + "client_id": {testClientID}, + "redirect_uri": {testRedirectURI}, + "state": {clientState}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "response_type": {"code"}, + "scope": {"openid profile"}, + }.Encode() + + resp, err := client.Get(authorizeURL) + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode) + firstUpstreamLocation, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + + resp, err = client.Get(firstUpstreamLocation.String()) + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode) + firstCallback, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + firstCallback.Scheme = parsedServerURL.Scheme + firstCallback.Host = parsedServerURL.Host + + resp, err = client.Get(firstCallback.String()) + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode, + "expected redirect to second upstream, not 303 to client") + secondUpstreamLocation, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + + // Leg 2: second upstream -> callback -> client + resp, err = client.Get(secondUpstreamLocation.String()) + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode) + secondCallback, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + secondCallback.Scheme = parsedServerURL.Scheme + secondCallback.Host = parsedServerURL.Host + + resp, err = client.Get(secondCallback.String()) + require.NoError(t, err) + require.Equal(t, http.StatusSeeOther, resp.StatusCode, + "expected 303 to client after both upstreams satisfied") + clientLocation, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + + // Client state must be preserved through the entire chain — universal + // invariant of the authorization chain, asserted here so every caller benefits. + require.Equal(t, clientState, clientLocation.Query().Get("state"), + "client state should be preserved through the multi-upstream chain") + + authCode := clientLocation.Query().Get("code") + require.NotEmpty(t, authCode) + return authCode +} + +// TestIntegration_MultiUpstreamChain_MixedExpiryOrderings exercises the two-leg +// authorization chain with one upstream returning expires_in and the other +// omitting it. Both orderings must succeed and both providers' tokens must be +// retrievable via GetAllValidTokens. +// +// This pins the chain handler's per-leg storage write and the +// convertOAuth2Token zero-Expiry path through the full HTTP flow, in both +// orderings. It does NOT exercise the Redis Lua TTL inversion fix +// (commit fec89b040) because the in-process integration harness uses +// storage.NewMemoryStorage(); the Lua semantics are covered directly by +// pkg/authserver/storage/redis_test.go via miniredis. +func TestIntegration_MultiUpstreamChain_MixedExpiryOrderings(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + firstUpstreamExpires bool // true => provider-1 returns expires_in; false => provider-1 omits it + }{ + {name: "non_expiring_then_expiring", firstUpstreamExpires: false}, + {name: "expiring_then_non_expiring", firstUpstreamExpires: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Build provider-1 and provider-2 mockoidc instances so the + // non-expiring one matches tc.firstUpstreamExpires. + var m1, m2 *mockoidc.MockOIDC + if tc.firstUpstreamExpires { + m1 = startMockOIDC(t) + m2 = startMockOIDCNoExpiresIn(t) + } else { + m1 = startMockOIDCNoExpiresIn(t) + m2 = startMockOIDC(t) + } + + // Each mockoidc consumes one queued user during /authorize. + // startMockOIDC queues a default user; startMockOIDCNoExpiresIn + // does not, so we queue explicitly. Use distinct emails so the + // per-provider UpstreamSubject is observably different. + m1.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-m1-" + tc.name, + Email: "u1-" + tc.name + "@m1.example.com", + }) + m2.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-m2-" + tc.name, + Email: "u2-" + tc.name + "@m2.example.com", + }) + + ts := setupTestServerWithTwoUpstreams(t, m1, m2) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode := runChainFlow(t, ts.Server.URL, challenge, "mixed-expiry-"+tc.name) + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + tsid := extractTSID(t, accessToken, ts.PrivateKey.Public()) + + ctx := context.Background() + stor := ts.authServer.IDPTokenStorage() + + // Both providers' tokens must be in storage. The non-expiring + // one must have a zero ExpiresAt; the expiring one must have a + // future ExpiresAt. This pins the per-leg storage write path. + tokens1, err := stor.GetUpstreamTokens(ctx, tsid, "provider-1") + require.NoError(t, err, "provider-1 tokens must be retrievable") + require.NotNil(t, tokens1) + tokens2, err := stor.GetUpstreamTokens(ctx, tsid, "provider-2") + require.NoError(t, err, "provider-2 tokens must be retrievable") + require.NotNil(t, tokens2) + + if tc.firstUpstreamExpires { + assert.False(t, tokens1.ExpiresAt.IsZero(), + "provider-1 (expiring) must carry a non-zero ExpiresAt") + assert.True(t, tokens2.ExpiresAt.IsZero(), + "provider-2 (non-expiring) must carry a zero ExpiresAt") + } else { + assert.True(t, tokens1.ExpiresAt.IsZero(), + "provider-1 (non-expiring) must carry a zero ExpiresAt") + assert.False(t, tokens2.ExpiresAt.IsZero(), + "provider-2 (expiring) must carry a non-zero ExpiresAt") + } + + // GetAllValidTokens must return both providers' access tokens. + // Before the Lua TTL fix, the non-expiring provider's index could + // be evicted prematurely depending on chain ordering, so this + // would have returned an empty or incomplete map for one + // ordering. + svc := upstreamtoken.NewInProcessService(stor, ts.authServer.UpstreamTokenRefresher()) + all, err := svc.GetAllValidTokens(ctx, tsid) + require.NoError(t, err) + require.Len(t, all, 2, "GetAllValidTokens must return both providers regardless of expiry ordering") + assert.NotEmpty(t, all["provider-1"], "provider-1 access token must be present") + assert.NotEmpty(t, all["provider-2"], "provider-2 access token must be present") + }) + } +} + +// ============================================================================ +// Redis-Backed Integration Variants +// ============================================================================ +// +// These tests run the same end-to-end flows as their in-memory counterparts +// but against a miniredis-backed *RedisStorage. They are smoke tests for the +// chain handler ↔ Redis storage path: the Redis backend executes Lua scripts, +// performs JSON round-trips, and maintains the per-session index set — none of +// which the in-memory backend exercises. A regression where the chain handler +// invokes Redis with the wrong inputs (wrong key, wrong serialization, wrong +// index update) would surface here. +// +// The harness (withRedisBackedStorage(), testServer.Miniredis(t)) is reusable +// for any future test that needs to drive the full HTTP flow against Redis or +// advance Redis-side time via FastForward without real-world sleeping. +// +// What these tests do NOT cover: the Lua TTL inversion regression (commit +// fec89b040). That bug only fires when marshalUpstreamTokensWithTTL produces +// ttlMs == 0, which requires both ExpiresAt and SessionExpiresAt to be zero. +// Since callback.go unconditionally sets SessionExpiresAt = now + +// RefreshTokenLifespan (commit 1b3bc81e2), the integration flow always +// produces ttlMs > 0 and the buggy Lua branch is unreachable. The Lua +// invariant is locked down at unit level by +// pkg/authserver/storage/redis_test.go, which can construct UpstreamTokens +// with both fields zero directly. + +// TestIntegration_FullFlow_NonExpiringUpstreamToken_Redis is the Redis-backed +// twin of TestIntegration_FullFlow_NonExpiringUpstreamToken. The unit-level +// Redis test of Store/GetUpstreamTokens already covers the round-trip; this +// subtest exists for symmetry — it confirms convertOAuth2Token's zero-Expiry +// preservation reaches Redis storage when the request originates from the +// real HTTP chain. +func TestIntegration_FullFlow_NonExpiringUpstreamToken_Redis(t *testing.T) { + t.Parallel() + + m := startMockOIDCNoExpiresIn(t) + m.QueueUser(&mockoidc.MockUser{ + Subject: "non-expiring-user-redis", + Email: "non-expiring-redis@example.com", + }) + + ts := setupTestServerWithMockOIDC(t, m, withRedisBackedStorage()) + ts.Miniredis(t) // assert harness was wired with withRedisBackedStorage + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "non-expiring-redis", + Challenge: challenge, + Scope: "openid profile offline_access", + ResponseType: "code", + }) + + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + tsid := extractTSID(t, accessToken, ts.PrivateKey.Public()) + + stor := ts.authServer.IDPTokenStorage() + + // Same invariants as the memory-backed twin: zero ExpiresAt in storage, + // no refresh on read, no rewrite of the stored row. + original, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, original) + require.NotEmpty(t, original.AccessToken) + assert.True(t, original.ExpiresAt.IsZero(), + "upstream ExpiresAt must be zero for a token response without expires_in (got %v)", + original.ExpiresAt) + + svc := upstreamtoken.NewInProcessService(stor, ts.authServer.UpstreamTokenRefresher()) + cred, err := svc.GetValidTokens(context.Background(), tsid, "default") + require.NoError(t, err) + require.NotNil(t, cred) + assert.Equal(t, original.AccessToken, cred.AccessToken, + "non-expiring access token must be returned unchanged (no refresh)") + + after, err := stor.GetUpstreamTokens(context.Background(), tsid, "default") + require.NoError(t, err) + assert.True(t, after.ExpiresAt.IsZero(), + "non-expiring token must keep zero ExpiresAt after GetValidTokens (got %v)", + after.ExpiresAt) + assert.Equal(t, original.AccessToken, after.AccessToken, + "access token in storage must be unchanged after GetValidTokens") +} + +// TestIntegration_MultiUpstreamChain_MixedExpiryOrderings_Redis is the Redis- +// backed smoke test for the two-leg chain with one upstream returning expires_in +// and the other omitting it. It exercises the chain handler ↔ Redis storage +// path through real Redis Lua execution in both orderings: a regression where +// the chain handler invokes Redis with the wrong inputs (wrong key, wrong +// serialization, wrong index update) would surface here. +// +// This test does NOT cover the Lua TTL inversion regression (commit fec89b040) +// at integration level. That bug only fires when marshalUpstreamTokensWithTTL +// produces ttlMs == 0, which requires both ExpiresAt and SessionExpiresAt to +// be zero on the UpstreamTokens. Since callback.go unconditionally sets +// SessionExpiresAt = now + RefreshTokenLifespan (commit 1b3bc81e2), the +// integration flow always produces ttlMs > 0 and the buggy Lua branch is +// unreachable from a real auth chain. The Lua invariant is locked down at +// unit level by pkg/authserver/storage/redis_test.go. +func TestIntegration_MultiUpstreamChain_MixedExpiryOrderings_Redis(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + firstUpstreamExpires bool + }{ + {name: "non_expiring_then_expiring", firstUpstreamExpires: false}, + {name: "expiring_then_non_expiring", firstUpstreamExpires: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Build provider-1 and provider-2 mockoidc instances so the + // non-expiring one matches tc.firstUpstreamExpires. + var m1, m2 *mockoidc.MockOIDC + if tc.firstUpstreamExpires { + m1 = startMockOIDC(t) + m2 = startMockOIDCNoExpiresIn(t) + } else { + m1 = startMockOIDCNoExpiresIn(t) + m2 = startMockOIDC(t) + } + + m1.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-m1-redis-" + tc.name, + Email: "u1-redis-" + tc.name + "@m1.example.com", + }) + m2.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-m2-redis-" + tc.name, + Email: "u2-redis-" + tc.name + "@m2.example.com", + }) + + ts := setupTestServerWithTwoUpstreams(t, m1, m2, withRedisBackedStorage()) + ts.Miniredis(t) // assert harness was wired with withRedisBackedStorage + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode := runChainFlow(t, ts.Server.URL, challenge, "mixed-expiry-redis-"+tc.name) + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + tsid := extractTSID(t, accessToken, ts.PrivateKey.Public()) + + ctx := context.Background() + stor := ts.authServer.IDPTokenStorage() + + // Sanity: both providers' tokens must be in storage immediately + // after the chain, with the expected ExpiresAt shapes. This is + // the same per-leg storage invariant the memory-backed twin + // asserts; replicated here so a Redis-only divergence in the + // chain handler's storage write would surface. + tokens1, err := stor.GetUpstreamTokens(ctx, tsid, "provider-1") + require.NoError(t, err, "provider-1 tokens must be retrievable") + require.NotNil(t, tokens1) + tokens2, err := stor.GetUpstreamTokens(ctx, tsid, "provider-2") + require.NoError(t, err, "provider-2 tokens must be retrievable") + require.NotNil(t, tokens2) + + if tc.firstUpstreamExpires { + assert.False(t, tokens1.ExpiresAt.IsZero(), + "provider-1 (expiring) must carry a non-zero ExpiresAt") + assert.True(t, tokens2.ExpiresAt.IsZero(), + "provider-2 (non-expiring) must carry a zero ExpiresAt") + } else { + assert.True(t, tokens1.ExpiresAt.IsZero(), + "provider-1 (non-expiring) must carry a zero ExpiresAt") + assert.False(t, tokens2.ExpiresAt.IsZero(), + "provider-2 (expiring) must carry a non-zero ExpiresAt") + } + + svc := upstreamtoken.NewInProcessService(stor, ts.authServer.UpstreamTokenRefresher()) + + // Both providers' tokens must be retrievable through Redis after the chain. + // This is a smoke test for the chain-handler ↔ Redis storage path: a + // regression where the chain handler invokes Redis storage with the wrong + // inputs (wrong key, wrong serialization, wrong index update) would fail here. + // + // Note: this test does NOT exercise the Lua TTL inversion bug. That bug + // only manifests when marshalUpstreamTokensWithTTL produces ttlMs == 0, + // which requires both ExpiresAt and SessionExpiresAt to be zero. Since + // the callback unconditionally sets SessionExpiresAt = now + RefreshTokenLifespan + // (commit 1b3bc81e2), the integration flow always produces ttlMs > 0 and + // the buggy Lua branch is unreachable from a real auth chain. The Lua + // invariant is exercised at unit level by pkg/authserver/storage/redis_test.go. + tokensMap, err := svc.GetAllValidTokens(ctx, tsid) + require.NoError(t, err) + require.Len(t, tokensMap, 2, "GetAllValidTokens must return both providers after chain") + assert.NotEmpty(t, tokensMap["provider-1"], "provider-1 access token must be present") + assert.NotEmpty(t, tokensMap["provider-2"], "provider-2 access token must be present") + }) + } +} + +// ============================================================================ +// Callback Refresh-Token Carry-Forward Integration Tests +// ============================================================================ + +// rtStrippingProxy wraps a mockoidc server and intercepts its token endpoint. +// When stripRT is true the proxy omits the "refresh_token" field from every +// token-endpoint response, replicating the common IdP behavior where the RT +// is only issued on the first authorization (e.g. Google without prompt=consent). +// +// All other endpoints (authorize, userinfo, jwks, discovery) are forwarded +// verbatim so that the real mockoidc can still sign tokens and serve user info. +// +// We use this proxy instead of the real mockoidc token endpoint for the second +// authorize → callback leg because mockoidc's setTokens() always generates a +// refresh_token for authorization_code grants and offers no API to suppress it. +type rtStrippingProxy struct { + stripRT atomic.Bool + target string // real mockoidc base URL (e.g. "http://127.0.0.1:PORT") +} + +func (p *rtStrippingProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Forward the request to the real mockoidc. + proxyURL := p.target + r.URL.RequestURI() + proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body) + if err != nil { + http.Error(w, "proxy: failed to build request", http.StatusInternalServerError) + return + } + proxyReq.Header = r.Header.Clone() + + resp, err := http.DefaultTransport.RoundTrip(proxyReq) + if err != nil { + http.Error(w, "proxy: upstream request failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, "proxy: failed to read response", http.StatusInternalServerError) + return + } + + // Strip refresh_token when the flag is set and this is the token endpoint. + if p.stripRT.Load() && r.URL.Path == "/oidc/token" { + var m map[string]json.RawMessage + if jsonErr := json.Unmarshal(body, &m); jsonErr == nil { + delete(m, "refresh_token") + if rewritten, jsonErr := json.Marshal(m); jsonErr == nil { + body = rewritten + } + } + } + + // Copy response headers and status code. + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + // Drop the upstream Content-Length; the body may have been rewritten (RT + // stripped) so the original length is stale. net/http will set it correctly + // from the buffered body. + w.Header().Del("Content-Length") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) +} + +// setupTestServerWithRTProxy creates a test server backed by a real mockoidc but +// with the upstream OAuth2 provider pointing to the rtStrippingProxy instead of +// the mockoidc server directly. This allows toggling RT suppression mid-test. +func setupTestServerWithRTProxy(t *testing.T, m *mockoidc.MockOIDC, proxy *rtStrippingProxy) *testServerWithUpstream { + t.Helper() + + proxyServer := httptest.NewServer(proxy) + t.Cleanup(proxyServer.Close) + + cfg := m.Config() + + upstreamCfg := &upstream.OAuth2Config{ + CommonOAuthConfig: upstream.CommonOAuthConfig{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Scopes: []string{"openid", "profile", "email"}, + // RedirectURI must point at our auth server (resolved below after httptest.NewServer). + RedirectURI: testIssuer + "/oauth/callback", + }, + // Authorization and token go through the proxy; userinfo comes from the proxy too. + AuthorizationEndpoint: proxyServer.URL + "/oidc/authorize", + TokenEndpoint: proxyServer.URL + "/oidc/token", + UserInfo: &upstream.UserInfoConfig{ + EndpointURL: proxyServer.URL + "/oidc/userinfo", + FieldMapping: &upstream.UserInfoFieldMapping{ + SubjectFields: []string{"sub", "email"}, + }, + }, + } + upstreamProvider, err := upstream.NewOAuth2Provider(upstreamCfg) + require.NoError(t, err) + + ts := setupTestServer(t, + withUpstream(upstreamProvider), + withScopes(registration.DefaultScopes), + ) + + return &testServerWithUpstream{ + testServer: ts, + mockOIDC: m, + upstreamProvider: upstreamProvider, + } +} + +// TestIntegration_Callback_PreservesRefreshTokenOnReauth verifies the carry-forward +// behavior introduced in the CallbackHandler: when an upstream IdP omits refresh_token +// on re-authorization, the callback must copy the RT from the most-recent prior row +// for the same (userID, providerID) pair rather than leaving it empty. +// +// Flow summary: +// 1. First authorize → callback: IdP issues an RT → stored normally. +// 2. Second authorize → callback (same user): IdP omits RT → callback carries forward +// the RT from the first row. Canonical regression assertion: new row's RT == priorRT. +// 3. Third authorize → callback (different user): no prior row exists for the new user → +// new row has empty RT. This exercises the ErrNotFound branch of the guard +// (GetLatestUpstreamTokensForUser returns ErrNotFound → nothing to carry). +// Note: the handler-level unit tests in callback_handler_test.go cover the +// UpstreamSubject mismatch guard directly; this leg covers the natural not-found path. +func TestIntegration_Callback_PreservesRefreshTokenOnReauth(t *testing.T) { + t.Parallel() + + const providerName = "default" + const userSubject = "reauth-user-001" + const userEmail = "reauth@example.com" + + // Step 1: Stand up a real mockoidc server. + m, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m.Shutdown()) }) + + // Step 2: Build the RT-stripping proxy (initially pass-through). + proxy := &rtStrippingProxy{ + target: m.Addr(), + } + + // Step 3: Stand up the auth server with the upstream pointing at the proxy. + ts := setupTestServerWithRTProxy(t, m, proxy) + + ctx := context.Background() + + // ========================================================================= + // Leg 1: First authorize → callback (normal — IdP issues a refresh_token) + // ========================================================================= + + // Queue the test user for the first flow. + m.QueueUser(&mockoidc.MockUser{ + Subject: userSubject, + Email: userEmail, + }) + + verifier1 := servercrypto.GeneratePKCEVerifier() + challenge1 := servercrypto.ComputePKCEChallenge(verifier1) + + authCode1, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "rt-carry-leg1", + Challenge: challenge1, + Scope: "openid profile", + ResponseType: "code", + }) + + tokenData1 := exchangeCodeForTokens(t, ts.Server.URL, authCode1, verifier1, testAudience) + accessToken1, ok := tokenData1["access_token"].(string) + require.True(t, ok, "leg 1: access_token should be present") + + sessionID1 := extractTSID(t, accessToken1, ts.PrivateKey.Public()) + + // Verify the first leg stored a non-empty RT (mockoidc always issues one). + tokens1, err := ts.storage.GetUpstreamTokens(ctx, sessionID1, providerName) + require.NoError(t, err, "leg 1: upstream tokens should be stored") + require.NotEmpty(t, tokens1.RefreshToken, "leg 1: RT must be non-empty (sanity check)") + + priorRT := tokens1.RefreshToken + + // ========================================================================= + // Leg 2: Second authorize → callback (re-auth, same user, IdP omits RT) + // ========================================================================= + + // Enable RT stripping: the proxy will now remove "refresh_token" from the + // token endpoint JSON before the oauth2 library sees it. This replicates + // the real-world behavior of IdPs that do not re-issue refresh_tokens on + // subsequent authorizations (e.g. Google without prompt=consent). + proxy.stripRT.Store(true) + + // Queue the same user again so the auth server resolves the same internal UserID. + m.QueueUser(&mockoidc.MockUser{ + Subject: userSubject, + Email: userEmail, + }) + + verifier2 := servercrypto.GeneratePKCEVerifier() + challenge2 := servercrypto.ComputePKCEChallenge(verifier2) + + authCode2, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "rt-carry-leg2", + Challenge: challenge2, + Scope: "openid profile", + ResponseType: "code", + }) + + tokenData2 := exchangeCodeForTokens(t, ts.Server.URL, authCode2, verifier2, testAudience) + accessToken2, ok := tokenData2["access_token"].(string) + require.True(t, ok, "leg 2: access_token should be present") + + sessionID2 := extractTSID(t, accessToken2, ts.PrivateKey.Public()) + + // The second authorize flow must create a new session (new TSID). + require.NotEqual(t, sessionID1, sessionID2, "leg 2: must use a distinct session ID") + + // Canonical regression assertion: the new row's RT was carried forward from + // the prior session even though the IdP omitted it in the token response. + tokens2, err := ts.storage.GetUpstreamTokens(ctx, sessionID2, providerName) + require.NoError(t, err, "leg 2: upstream tokens should be stored") + assert.Equal(t, priorRT, tokens2.RefreshToken, + "leg 2: RT must be carried forward from the prior session (regression assertion)") + + // ========================================================================= + // Leg 3: Third authorize → callback (different user — no prior row) + // ========================================================================= + // A different upstream subject causes ResolveUser to create a NEW internal user + // (new UserID). GetLatestUpstreamTokensForUser returns ErrNotFound for this new + // user, so the carry-forward guard is not triggered and the new row's RT is empty. + // + // This leg exercises the ErrNotFound branch in maybeCarryForwardRefreshToken. + // The UpstreamSubject mismatch guard is covered by handler-level unit tests. + + const otherUserSubject = "reauth-other-user-999" + const otherUserEmail = "other@example.com" + + // Keep RT stripping enabled for the third leg as well. + m.QueueUser(&mockoidc.MockUser{ + Subject: otherUserSubject, + Email: otherUserEmail, + }) + + verifier3 := servercrypto.GeneratePKCEVerifier() + challenge3 := servercrypto.ComputePKCEChallenge(verifier3) + + authCode3, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "rt-carry-leg3", + Challenge: challenge3, + Scope: "openid profile", + ResponseType: "code", + }) + + tokenData3 := exchangeCodeForTokens(t, ts.Server.URL, authCode3, verifier3, testAudience) + accessToken3, ok := tokenData3["access_token"].(string) + require.True(t, ok, "leg 3: access_token should be present") + + sessionID3 := extractTSID(t, accessToken3, ts.PrivateKey.Public()) + require.NotEqual(t, sessionID2, sessionID3, "leg 3: must use a distinct session ID from leg 2") + require.NotEqual(t, sessionID1, sessionID3, "leg 3: must use a distinct session ID from leg 1") + + // No carry-forward: RT stripping is still on, so storageTokens.RefreshToken is + // empty when we enter maybeCarryForwardRefreshToken. The new user has no prior + // row either, so GetLatestUpstreamTokensForUser returns ErrNotFound and the + // guard returns early — the stored RT stays empty (ErrNotFound branch). + tokens3, err := ts.storage.GetUpstreamTokens(ctx, sessionID3, providerName) + require.NoError(t, err, "leg 3: upstream tokens should be stored") + assert.Empty(t, tokens3.RefreshToken, + "leg 3: no RT carry-forward for a new user with no prior row (ErrNotFound path)") +} diff --git a/pkg/authserver/refresher.go b/pkg/authserver/refresher.go index 17d8b6bd0d..4fe477e1fc 100644 --- a/pkg/authserver/refresher.go +++ b/pkg/authserver/refresher.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "time" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" @@ -18,9 +19,16 @@ import ( // UpstreamTokenStorage (for persisting the refreshed tokens). On each refresh // call it dispatches to the correct provider based on the expired token's // ProviderID. +// +// refreshTokenLifespan is used as the defensive re-anchor value for +// SessionExpiresAt when the persisted row pre-dates the unconditional callback +// write (legacy data) and the upstream provider drops expires_in on the +// refresh response. Without this, both bounds would be zero and the row would +// be persisted with no TTL — see RefreshAndStore. type upstreamTokenRefresher struct { - providers map[string]upstream.OAuth2Provider - storage storage.UpstreamTokenStorage + providers map[string]upstream.OAuth2Provider + storage storage.UpstreamTokenStorage + refreshTokenLifespan time.Duration } // Compile-time check that upstreamTokenRefresher implements storage.UpstreamTokenRefresher. @@ -57,16 +65,36 @@ func (r *upstreamTokenRefresher) RefreshAndStore( return nil, fmt.Errorf("upstream token refresh failed: %w", err) } + // Defensive re-anchor of SessionExpiresAt: the post-PR callback write sets + // SessionExpiresAt unconditionally so it can be carried forward here as a + // storage TTL bound. Pre-PR rows persisted without that field decode as + // zero. If such a legacy row is refreshed and the upstream rotation drops + // expires_in, both ExpiresAt and SessionExpiresAt would be zero, the row + // would be stored without any TTL bound, and the Memory backend would + // retain it indefinitely. Re-anchor to now+RefreshTokenLifespan to restore + // the invariant. The Redis 30-day per-key TTL also caps the legacy + // behavior, but Memory has no such backstop. + sessionExpiresAt := expired.SessionExpiresAt + if sessionExpiresAt.IsZero() && newTokens.ExpiresAt.IsZero() { + sessionExpiresAt = time.Now().Add(r.refreshTokenLifespan) + slog.Debug("re-anchored zero SessionExpiresAt on refresh of legacy upstream token row", + "session_id", sessionID, + "provider_id", expired.ProviderID, + "refresh_token_lifespan", r.refreshTokenLifespan, + ) + } + // Build updated storage tokens preserving binding fields from the original updated := &storage.UpstreamTokens{ - ProviderID: expired.ProviderID, - AccessToken: newTokens.AccessToken, - RefreshToken: newTokens.RefreshToken, - IDToken: newTokens.IDToken, - ExpiresAt: newTokens.ExpiresAt, - UserID: expired.UserID, - UpstreamSubject: expired.UpstreamSubject, - ClientID: expired.ClientID, + ProviderID: expired.ProviderID, + AccessToken: newTokens.AccessToken, + RefreshToken: newTokens.RefreshToken, + IDToken: newTokens.IDToken, + ExpiresAt: newTokens.ExpiresAt, + SessionExpiresAt: sessionExpiresAt, + UserID: expired.UserID, + UpstreamSubject: expired.UpstreamSubject, + ClientID: expired.ClientID, } // If the provider didn't rotate the refresh token, keep the original diff --git a/pkg/authserver/refresher_test.go b/pkg/authserver/refresher_test.go index 2d61dc951c..4dce4b4124 100644 --- a/pkg/authserver/refresher_test.go +++ b/pkg/authserver/refresher_test.go @@ -23,16 +23,18 @@ func TestUpstreamTokenRefresher_RefreshAndStore(t *testing.T) { t.Parallel() newExpiry := time.Now().Add(1 * time.Hour) + sessionBound := time.Now().Add(7 * 24 * time.Hour) baseExpired := &storage.UpstreamTokens{ - ProviderID: "github", - AccessToken: "old-access", - RefreshToken: "old-refresh", - IDToken: "old-id-token", - ExpiresAt: time.Now().Add(-1 * time.Hour), - UserID: "user-123", - UpstreamSubject: "upstream-sub-456", - ClientID: "client-abc", + ProviderID: "github", + AccessToken: "old-access", + RefreshToken: "old-refresh", + IDToken: "old-id-token", + ExpiresAt: time.Now().Add(-1 * time.Hour), + SessionExpiresAt: sessionBound, + UserID: "user-123", + UpstreamSubject: "upstream-sub-456", + ClientID: "client-abc", } tests := []struct { @@ -113,6 +115,89 @@ func TestUpstreamTokenRefresher_RefreshAndStore(t *testing.T) { assert.Equal(t, "old-refresh", result.RefreshToken) }, }, + { + // Regression for the refresh-path bound. SessionExpiresAt must be carried + // forward unchanged so a refresh that returns a token with zero ExpiresAt + // (provider stops asserting expires_in) still has a storage TTL bound. + // Without this, the row would be stored with no TTL and leak indefinitely. + name: "preserves SessionExpiresAt when provider omits expires_in", + sessionID: "session-bound", + expired: baseExpired, + setupProvider: func(_ *testing.T, p *upstreammocks.MockOAuth2Provider) { + p.EXPECT().RefreshTokens(gomock.Any(), "old-refresh", "upstream-sub-456"). + Return(&upstream.Tokens{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + // ExpiresAt intentionally zero — provider omitted expires_in. + }, nil) + }, + setupStorage: func(_ *testing.T, s *storagemocks.MockUpstreamTokenStorage) { + s.EXPECT().StoreUpstreamTokens(gomock.Any(), "session-bound", "github", gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, tokens *storage.UpstreamTokens) error { + assert.Equal(t, sessionBound, tokens.SessionExpiresAt, + "refresher must carry SessionExpiresAt forward unchanged") + assert.True(t, tokens.ExpiresAt.IsZero(), + "new ExpiresAt should be zero (provider omitted expires_in)") + return nil + }) + }, + checkResult: func(t *testing.T, result *storage.UpstreamTokens) { + t.Helper() + assert.Equal(t, sessionBound, result.SessionExpiresAt, + "returned tokens must also carry SessionExpiresAt forward") + assert.True(t, result.ExpiresAt.IsZero()) + }, + }, + { + // Defensive re-anchor for legacy data. Pre-PR Redis rows decode + // SessionExpiresAt as zero (the field was not persisted). If such a + // row is refreshed and the upstream rotation also drops expires_in, + // both bounds are zero — the row would be stored without any TTL, + // and the Memory backend would retain it indefinitely. The refresher + // must re-anchor SessionExpiresAt to now+RefreshTokenLifespan so the + // row carries a storage TTL bound forward. + name: "re-anchors SessionExpiresAt when legacy row and provider both omit expiry", + sessionID: "session-legacy", + expired: &storage.UpstreamTokens{ + ProviderID: "github", + AccessToken: "old-access", + RefreshToken: "old-refresh", + IDToken: "old-id-token", + ExpiresAt: time.Time{}, // legacy row decoded with zero expiry + SessionExpiresAt: time.Time{}, // legacy row missing the field entirely + UserID: "user-123", + UpstreamSubject: "upstream-sub-456", + ClientID: "client-abc", + }, + setupProvider: func(_ *testing.T, p *upstreammocks.MockOAuth2Provider) { + p.EXPECT().RefreshTokens(gomock.Any(), "old-refresh", "upstream-sub-456"). + Return(&upstream.Tokens{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + IDToken: "new-id-token", + // ExpiresAt intentionally zero — provider also omitted expires_in. + }, nil) + }, + setupStorage: func(_ *testing.T, s *storagemocks.MockUpstreamTokenStorage) { + s.EXPECT().StoreUpstreamTokens(gomock.Any(), "session-legacy", "github", gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, tokens *storage.UpstreamTokens) error { + assert.False(t, tokens.SessionExpiresAt.IsZero(), + "refresher must re-anchor SessionExpiresAt for legacy zero/zero rows") + assert.True(t, tokens.ExpiresAt.IsZero(), + "new ExpiresAt should be zero (provider omitted expires_in)") + // The re-anchor uses the configured lifespan (24h in this test). + assert.WithinDuration(t, time.Now().Add(24*time.Hour), tokens.SessionExpiresAt, time.Minute, + "re-anchored SessionExpiresAt should be ~now+RefreshTokenLifespan") + return nil + }) + }, + checkResult: func(t *testing.T, result *storage.UpstreamTokens) { + t.Helper() + assert.False(t, result.SessionExpiresAt.IsZero(), + "returned tokens must also carry the re-anchored SessionExpiresAt") + assert.True(t, result.ExpiresAt.IsZero()) + }, + }, { name: "nil expired tokens returns error", sessionID: "session-3", @@ -212,8 +297,9 @@ func TestUpstreamTokenRefresher_RefreshAndStore(t *testing.T) { tt.setupStorage(t, mockStorage) refresher := &upstreamTokenRefresher{ - providers: map[string]upstream.OAuth2Provider{"github": mockProvider}, - storage: mockStorage, + providers: map[string]upstream.OAuth2Provider{"github": mockProvider}, + storage: mockStorage, + refreshTokenLifespan: 24 * time.Hour, } result, err := refresher.RefreshAndStore(context.Background(), tt.sessionID, tt.expired) diff --git a/pkg/authserver/runner/dcr_adapter.go b/pkg/authserver/runner/dcr_adapter.go new file mode 100644 index 0000000000..37dfd54507 --- /dev/null +++ b/pkg/authserver/runner/dcr_adapter.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "fmt" + + "github.com/stacklok/toolhive/pkg/auth/dcr" + "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/authserver/upstream" +) + +// This file contains the embedded-authserver-specific adapter layer between +// pkg/auth/dcr's profile-neutral API and the authserver's domain types +// (authserver.OAuth2UpstreamRunConfig, upstream.OAuth2Config). Each helper +// here: +// +// - needsDCR reports whether an OAuth2UpstreamRunConfig requires DCR. +// - newDCRRequest converts an OAuth2UpstreamRunConfig into the +// profile-neutral dcr.Request the resolver expects, including resolving +// the file-or-env InitialAccessToken into an inline string. +// - consumeResolution / applyResolutionToOAuth2Config fold the returned +// dcr.Resolution back into the run-config and built OAuth2Config. +// Both are value-in / value-out: the caller's original is never +// observably mutated, and the no-mutation contract is compile-time +// enforced by the signatures rather than a prose discipline. +// +// Keeping these helpers in the runner package (not pkg/auth/dcr) is the +// architectural split the dcr package's profile-agnostic charter requires: +// dcr cannot import authserver, but the embedded authserver freely depends +// on dcr. + +// needsDCR reports whether rc requires runtime Dynamic Client Registration. +// A run-config needs DCR exactly when ClientID is empty and DCRConfig is +// non-nil (the mutually-exclusive constraint is enforced by +// OAuth2UpstreamRunConfig.Validate; this helper is a convenience check). +func needsDCR(rc *authserver.OAuth2UpstreamRunConfig) bool { + if rc == nil { + return false + } + return rc.ClientID == "" && rc.DCRConfig != nil +} + +// newDCRRequest builds the profile-neutral dcr.Request from an +// OAuth2UpstreamRunConfig and this auth server's local issuer. The caller +// has already validated the run-config (Validate() enforces +// ClientID xor DCRConfig and the DCRConfig-internal one-of constraint), so +// this function does not re-check those invariants — it only resolves the +// file-or-env InitialAccessToken reference into an inline string. +// +// localIssuer is *this* auth server's issuer identifier, used by the +// resolver for cache keying and to default the redirect URI to +// {localIssuer}/oauth/callback when rc.RedirectURI is empty. The upstream +// issuer is recovered separately from rc.DCRConfig.DiscoveryURL inside the +// resolver and is used solely for RFC 8414 §3.3 metadata verification. +func newDCRRequest(rc *authserver.OAuth2UpstreamRunConfig, localIssuer string) (*dcr.Request, error) { + if rc == nil { + return nil, fmt.Errorf("oauth2 upstream run-config is required") + } + if rc.DCRConfig == nil { + return nil, fmt.Errorf("dcr: oauth2 upstream has no dcr_config") + } + + initialAccessToken, err := resolveSecret( + rc.DCRConfig.InitialAccessTokenFile, + rc.DCRConfig.InitialAccessTokenEnvVar, + ) + if err != nil { + return nil, fmt.Errorf("dcr: resolve initial access token: %w", err) + } + + return &dcr.Request{ + Issuer: localIssuer, + RedirectURI: rc.RedirectURI, + Scopes: rc.Scopes, + DiscoveryURL: rc.DCRConfig.DiscoveryURL, + RegistrationEndpoint: rc.DCRConfig.RegistrationEndpoint, + AuthorizationEndpoint: rc.AuthorizationEndpoint, + TokenEndpoint: rc.TokenEndpoint, + InitialAccessToken: initialAccessToken, + }, nil +} + +// consumeResolution returns a copy of rc with the resolved credentials and +// endpoints from res copied in and DCRConfig consumed (set to nil), +// transitioning the run-config from "DCR-pending" (ClientID == "" && +// DCRConfig != nil) to "DCR-resolved" (ClientID populated && DCRConfig +// == nil). The "consume" name is deliberate: a second call on the +// returned value is a no-op only because the first cleared DCRConfig — +// this is a one-shot state transition, not an idempotent default-fill. +// +// rc is taken by value and the modified copy is returned. The caller's +// original is never observably mutated; the value-in / value-out shape +// makes the no-mutation contract compile-time enforced rather than a +// prose discipline the caller is required to remember. Pointer-typed +// fields (DCRConfig) share storage with the caller's copy via the struct +// shallow-copy, but the only mutation here is to assign nil to the +// copy's DCRConfig — nil-assignment to the local field does not reach +// back through the original pointer. +// +// Why DCRConfig is cleared: OAuth2UpstreamRunConfig.Validate enforces +// ClientID xor DCRConfig — a resolved copy that left DCRConfig set would +// fail the validator that runs downstream in buildPureOAuth2Config. +// +// ClientID, the endpoints, and RedirectURI are written only when rc leaves +// them empty — explicit caller configuration always wins. The conditional +// ClientID write is defence-in-depth against future call sites that bypass +// the needsDCR precondition; an unconditional overwrite would silently +// clobber a pre-provisioned ClientID with no error. +// +// The defaulted RedirectURI write closes the loop on resolver-side defaulting: +// when the caller's run-config left RedirectURI empty, the resolver derived +// issuer + /oauth/callback and persisted it on the resolution; copying it +// back here means the downstream upstream.OAuth2Config has a non-empty +// RedirectURI, which authserver.Config validation requires. +// +// Note on ClientSecret: consumeResolution does NOT write the resolved +// secret because OAuth2UpstreamRunConfig models secrets as file-or-env +// references only. To propagate the DCR-resolved secret into the final +// upstream.OAuth2Config, callers must pair this call with +// applyResolutionToOAuth2Config once the config has been built. Keeping +// the two helpers side-by-side localises the DCR-specific application +// logic. +func consumeResolution(rc authserver.OAuth2UpstreamRunConfig, res *dcr.Resolution) authserver.OAuth2UpstreamRunConfig { + if res == nil { + return rc + } + if rc.ClientID == "" { + rc.ClientID = res.ClientID + } + rc.DCRConfig = nil + if rc.AuthorizationEndpoint == "" { + rc.AuthorizationEndpoint = res.AuthorizationEndpoint + } + if rc.TokenEndpoint == "" { + rc.TokenEndpoint = res.TokenEndpoint + } + if rc.RedirectURI == "" { + rc.RedirectURI = res.RedirectURI + } + return rc +} + +// applyResolutionToOAuth2Config returns a copy of cfg with the DCR- +// resolved ClientSecret overlaid onto it. This is the companion to +// consumeResolution: where that function writes fields representable in +// the file-or-env run-config model, this one writes the inline-only +// ClientSecret directly on the runtime config. +// +// cfg is taken by value and the modified copy is returned, mirroring +// consumeResolution. The no-mutation contract is compile-time enforced +// by the signature rather than a prose discipline. +// +// The split exists because buildPureOAuth2Config intentionally retains a +// narrow file-or-env contract (no DCR awareness) and because OAuth2's +// ClientSecret on the run-config is modelled as a reference rather than +// an inline string. Any future output path from OAuth2UpstreamRunConfig +// to upstream.OAuth2Config must call BOTH consumeResolution (run-config +// side) AND applyResolutionToOAuth2Config (built-config side) to get a +// fully-resolved DCR client. Forgetting the second call leaves +// ClientSecret empty and produces silent auth failures at request time — +// the type system does not enforce the pair, so the invariant lives here. +func applyResolutionToOAuth2Config(cfg upstream.OAuth2Config, res *dcr.Resolution) upstream.OAuth2Config { + if res == nil { + return cfg + } + cfg.ClientSecret = res.ClientSecret + return cfg +} diff --git a/pkg/authserver/runner/dcr_adapter_test.go b/pkg/authserver/runner/dcr_adapter_test.go new file mode 100644 index 0000000000..3a4e7815c1 --- /dev/null +++ b/pkg/authserver/runner/dcr_adapter_test.go @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth/dcr" + "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/authserver/upstream" +) + +// Tests in this file pin the embedded-authserver-side adapter that bridges +// pkg/auth/dcr's profile-neutral API to authserver-specific domain types. +// They were lifted from pkg/auth/dcr's resolver_test.go when sub-issue 4b +// neutralised the resolver inputs — the helpers moved to runner/dcr_adapter.go +// so the dcr package no longer imports authserver types, and the tests +// follow. + +func TestNeedsDCR(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rc *authserver.OAuth2UpstreamRunConfig + expected bool + }{ + {name: "nil", rc: nil, expected: false}, + {name: "empty client_id and dcr_config", rc: &authserver.OAuth2UpstreamRunConfig{ + DCRConfig: &authserver.DCRUpstreamConfig{}, + }, expected: true}, + {name: "client_id without dcr", rc: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "x", + }, expected: false}, + {name: "client_id wins over dcr_config (defensive AND semantic)", rc: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "x", + DCRConfig: &authserver.DCRUpstreamConfig{}, + }, expected: false}, + {name: "both empty", rc: &authserver.OAuth2UpstreamRunConfig{}, expected: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, needsDCR(tc.rc)) + }) + } +} + +func TestConsumeResolution_RespectsExplicitEndpoints(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://explicit/authorize", + TokenEndpoint: "https://explicit/token", + } + res := &dcr.Resolution{ + ClientID: "got-client", + AuthorizationEndpoint: "https://discovered/authorize", + TokenEndpoint: "https://discovered/token", + } + rc = consumeResolution(rc, res) + assert.Equal(t, "got-client", rc.ClientID) + assert.Equal(t, "https://explicit/authorize", rc.AuthorizationEndpoint) + assert.Equal(t, "https://explicit/token", rc.TokenEndpoint) +} + +func TestConsumeResolution_FillsMissingEndpoints(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{} + res := &dcr.Resolution{ + ClientID: "got-client", + AuthorizationEndpoint: "https://discovered/authorize", + TokenEndpoint: "https://discovered/token", + } + rc = consumeResolution(rc, res) + assert.Equal(t, "got-client", rc.ClientID) + assert.Equal(t, "https://discovered/authorize", rc.AuthorizationEndpoint) + assert.Equal(t, "https://discovered/token", rc.TokenEndpoint) +} + +// TestConsumeResolution_ClearsDCRConfig pins the contract that +// consumeResolution clears DCRConfig on the returned run-config copy +// after writing the resolved ClientID. Without this, +// OAuth2UpstreamRunConfig.Validate (run by buildPureOAuth2Config +// downstream) trips its ClientID-xor-DCRConfig rule on the resolved copy +// and rejects the upstream at boot. +func TestConsumeResolution_ClearsDCRConfig(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{ + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + } + res := &dcr.Resolution{ + ClientID: "dcr-issued-client", + } + + rc = consumeResolution(rc, res) + + assert.Equal(t, "dcr-issued-client", rc.ClientID) + assert.Nil(t, rc.DCRConfig, + "consumeResolution must clear DCRConfig so the resolved copy satisfies the ClientID-xor-DCRConfig invariant") +} + +// TestConsumeResolution_DoesNotOverwritePreProvisionedClientID verifies +// the defence-in-depth in consumeResolution: a caller that bypasses +// needsDCR and invokes consumeResolution directly with a pre-provisioned +// ClientID does not have it silently clobbered. +func TestConsumeResolution_DoesNotOverwritePreProvisionedClientID(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{ + ClientID: "pre-provisioned", + } + res := &dcr.Resolution{ + ClientID: "would-be-overwrite", + } + rc = consumeResolution(rc, res) + assert.Equal(t, "pre-provisioned", rc.ClientID, + "consumeResolution must not overwrite a non-empty ClientID") +} + +// TestConsumeResolution_NilResolutionIsNoop verifies that passing a nil +// resolution returns the input unchanged, mirroring the resolver-side +// convention. This avoids forcing every call site to nil-check before +// invocation when DCR was not required. +func TestConsumeResolution_NilResolutionIsNoop(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{ + ClientID: "pre-provisioned", + } + out := consumeResolution(rc, nil) + assert.Equal(t, rc, out) +} + +// TestConsumeResolution_DoesNotMutateCaller verifies the value-in / +// value-out contract: the caller's local variable is not observably +// mutated after the call, even when the function clears DCRConfig on the +// returned copy. +func TestConsumeResolution_DoesNotMutateCaller(t *testing.T) { + t.Parallel() + + original := authserver.OAuth2UpstreamRunConfig{ + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + } + res := &dcr.Resolution{ClientID: "dcr-issued"} + + out := consumeResolution(original, res) + + assert.Equal(t, "", original.ClientID, "caller's ClientID must remain empty") + assert.NotNil(t, original.DCRConfig, "caller's DCRConfig must remain set") + assert.Equal(t, "dcr-issued", out.ClientID) + assert.Nil(t, out.DCRConfig) +} + +// TestApplyResolutionToOAuth2Config covers the ClientSecret overlay onto +// the built upstream.OAuth2Config. The split between consumeResolution +// (run-config fields) and applyResolutionToOAuth2Config (inline-only +// ClientSecret) is documented in dcr_adapter.go; both must be paired to +// produce a fully-resolved DCR client. +func TestApplyResolutionToOAuth2Config(t *testing.T) { + t.Parallel() + + cfg := upstream.OAuth2Config{} + res := &dcr.Resolution{ + ClientSecret: "dcr-issued-secret", + } + out := applyResolutionToOAuth2Config(cfg, res) + assert.Equal(t, "dcr-issued-secret", out.ClientSecret) + assert.Equal(t, "", cfg.ClientSecret, "caller's cfg must not be mutated") + + // nil resolution is a no-op rather than a crash. + unchanged := applyResolutionToOAuth2Config(cfg, nil) + assert.Equal(t, cfg, unchanged) +} + +// TestConsumeResolution_AloneLeavesClientSecretEmpty is a tripwire +// regression test for the two-call invariant documented on +// applyResolutionToOAuth2Config. The contract is: +// +// consumeResolution writes the run-config fields representable in the +// file-or-env model; applyResolutionToOAuth2Config writes the +// inline-only ClientSecret onto the built upstream.OAuth2Config. +// +// Both must be paired — calling only consumeResolution leaves +// ClientSecret empty and produces silent auth failures at request time. +// The type system does not enforce the pair, so this test pins the +// failure mode: a future refactor that accidentally folds the two helpers +// in a way that drops the ClientSecret write will fail here with a +// clear signal rather than at upstream auth time on a deployed binary. +func TestConsumeResolution_AloneLeavesClientSecretEmpty(t *testing.T) { + t.Parallel() + + rc := authserver.OAuth2UpstreamRunConfig{ + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + } + res := &dcr.Resolution{ + ClientID: "dcr-issued-client", + ClientSecret: "dcr-issued-secret", + } + + rc = consumeResolution(rc, res) + + // run-config side fields are populated. + assert.Equal(t, "dcr-issued-client", rc.ClientID) + // But the ClientSecret destination — the built upstream.OAuth2Config — + // is untouched. The default-constructed OAuth2Config below stands in + // for "the built config that buildPureOAuth2Config produces": this + // path does NOT include applyResolutionToOAuth2Config, so the + // ClientSecret remains empty. A future regression that hides the + // applyResolutionToOAuth2Config call inside consumeResolution would + // flip this assertion. + builtCfg := upstream.OAuth2Config{} + assert.Equal(t, "", builtCfg.ClientSecret, + "consumeResolution alone must not write ClientSecret; that is "+ + "applyResolutionToOAuth2Config's responsibility (see dcr_adapter.go)") +} + +// TestNewDCRRequest covers the OAuth2UpstreamRunConfig → dcr.Request +// translation, including the file-based InitialAccessToken resolution that +// previously lived inside the resolver. +func TestNewDCRRequest(t *testing.T) { + t.Parallel() + + tokenPath := filepath.Join(t.TempDir(), "iat") + require.NoError(t, os.WriteFile(tokenPath, []byte("iat-value\n"), 0o600)) + + tests := []struct { + name string + rc *authserver.OAuth2UpstreamRunConfig + localIssuer string + wantErrSub string + wantIssuer string + wantInitialAccessToken string + wantDiscoveryURL string + wantRegistration string + }{ + { + name: "discovery_url branch resolves file-based initial access token", + rc: &authserver.OAuth2UpstreamRunConfig{ + Scopes: []string{"openid"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + InitialAccessTokenFile: tokenPath, + }, + }, + localIssuer: "https://thv.example.com", + wantIssuer: "https://thv.example.com", + wantInitialAccessToken: "iat-value", + wantDiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + }, + { + name: "registration_endpoint branch propagates without secret", + rc: &authserver.OAuth2UpstreamRunConfig{ + Scopes: []string{"openid"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://idp.example.com/register", + }, + }, + localIssuer: "https://thv.example.com", + wantIssuer: "https://thv.example.com", + wantRegistration: "https://idp.example.com/register", + }, + { + name: "nil run-config rejected", + rc: nil, + localIssuer: "https://thv.example.com", + wantErrSub: "run-config is required", + }, + { + name: "missing dcr_config rejected", + rc: &authserver.OAuth2UpstreamRunConfig{}, + localIssuer: "https://thv.example.com", + wantErrSub: "no dcr_config", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req, err := newDCRRequest(tc.rc, tc.localIssuer) + if tc.wantErrSub != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSub) + return + } + require.NoError(t, err) + require.NotNil(t, req) + assert.Equal(t, tc.wantIssuer, req.Issuer) + assert.Equal(t, tc.wantInitialAccessToken, req.InitialAccessToken) + assert.Equal(t, tc.wantDiscoveryURL, req.DiscoveryURL) + assert.Equal(t, tc.wantRegistration, req.RegistrationEndpoint) + }) + } +} + +// TestNewDCRRequest_EnvVarInitialAccessToken pins the env-var sibling of +// the file-based InitialAccessToken branch covered above. Without this, +// a refactor that swapped the argument order on resolveSecret or read +// the wrong field on DCRConfig would slip past the file-based test and +// only fail in production, where the env-var path is exercised by +// operators who pass credentials via Kubernetes Secrets / env injection. +// Kept as a standalone test (not a table case) because t.Setenv is +// incompatible with t.Parallel. +func TestNewDCRRequest_EnvVarInitialAccessToken(t *testing.T) { + const envVar = "TEST_DCR_IAT_ENVVAR_BRANCH" + t.Setenv(envVar, "iat-from-env") + + rc := &authserver.OAuth2UpstreamRunConfig{ + Scopes: []string{"openid"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: "https://idp.example.com/.well-known/oauth-authorization-server", + InitialAccessTokenEnvVar: envVar, + }, + } + + req, err := newDCRRequest(rc, "https://thv.example.com") + require.NoError(t, err) + require.NotNil(t, req) + assert.Equal(t, "iat-from-env", req.InitialAccessToken, + "env-var InitialAccessToken must flow through newDCRRequest → resolveSecret") +} diff --git a/pkg/authserver/runner/dcr_testhelpers_test.go b/pkg/authserver/runner/dcr_testhelpers_test.go new file mode 100644 index 0000000000..495dae7414 --- /dev/null +++ b/pkg/authserver/runner/dcr_testhelpers_test.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "testing" + + "github.com/stacklok/toolhive/pkg/auth/dcr" + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +// newMemoryDCRStore is a test-only convenience constructor wrapping +// storage.NewMemoryStorage in the dcr-package adapter. Production deployments +// do NOT reach this constructor — NewEmbeddedAuthServer type-asserts the +// shared authserver storage to storage.DCRCredentialStore and passes it to +// dcr.NewStorageBackedStore directly. +// +// The caller's *testing.T is required because storage.NewMemoryStorage +// launches a background cleanup goroutine on construction; the helper +// registers t.Cleanup(stor.Close) so each test releases the goroutine when +// it finishes. Without this every test that built a fresh store would leak a +// cleanupLoop goroutine for the duration of the test process. +// +// A near-identical helper lives in pkg/auth/dcr/testhelpers_test.go for +// tests inside that package; the duplication is intentional because Go test +// helpers cannot be shared across packages without exporting them, and the +// cleanup-aware shape is too narrow to justify a public API surface. +func newMemoryDCRStore(t *testing.T) dcr.CredentialStore { + t.Helper() + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + return dcr.NewStorageBackedStore(stor) +} diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 03d8309edb..3d07a2d943 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -11,9 +11,12 @@ import ( "log/slog" "net/http" "os" + "slices" "sync" "time" + tcredis "github.com/stacklok/toolhive-core/redis" + "github.com/stacklok/toolhive/pkg/auth/dcr" "github.com/stacklok/toolhive/pkg/authserver" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" @@ -36,6 +39,11 @@ const ( // EmbeddedAuthServer wraps the authorization server for integration with the proxy runner. // It handles configuration transformation from authserver.RunConfig to authserver.Config, // manages resource lifecycle, and provides HTTP handlers for OAuth/OIDC endpoints. +// +// The DCR credential store is owned by the underlying authserver.Server and +// reached via DCRStore(); see that accessor's doc for SECURITY and lifecycle +// notes. Storing it twice on this struct would create a drift window with +// the server's copy, so we delegate through e.server.DCRStore() instead. type EmbeddedAuthServer struct { server authserver.Server keyProvider keys.KeyProvider @@ -55,6 +63,74 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb return nil, fmt.Errorf("config is required") } + // Register gjson modifiers used by IdentityFromToken configs (e.g. @upstreamjwt). + // Without this, modifier-bearing paths silently fail to resolve. + upstream.RegisterModifiers() + + // Fail loudly on operator-supplied misconfiguration (e.g. a baseline + // scope absent from scopes_supported) BEFORE touching storage or any + // other side-effecting work, so a bad config never reaches the network + // or filesystem. + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid run config: %w", err) + } + + // Create the storage backend FIRST so the DCR resolver and the auth + // server share the same persistence. Both MemoryStorage and RedisStorage + // satisfy storage.DCRCredentialStore (verified by package-level var _ + // checks in pkg/authserver/storage), so an explicit type assertion at + // the boundary is provably safe and keeps the wider Storage interface + // from advertising secret-bearing DCR methods to every consumer. This + // is the wiring change that lets a Redis-backed authserver reuse RFC + // 7591 client registrations across replicas and restarts. + stor, err := createStorage(ctx, cfg.Storage) + if err != nil { + return nil, fmt.Errorf("failed to create storage: %w", err) + } + return newEmbeddedAuthServerWithStorage(ctx, cfg, stor) +} + +// newEmbeddedAuthServerWithStorage is the unexported core constructor that +// builds an EmbeddedAuthServer around a caller-supplied storage backend. +// NewEmbeddedAuthServer dispatches into this helper after running +// createStorage; tests dispatch into it directly so they can supply a +// closeTrackingStorage wrapper to verify the deferred-cleanup contract. +// +// Resource ownership: on success, the returned EmbeddedAuthServer takes +// ownership of stor (its Close releases the backend). On any error path +// after entry, the deferred cleanup closes stor before returning so a +// crash-looping caller (typical when DCR's network I/O fails) does not +// leak the Redis client connection pool / MemoryStorage cleanup goroutine +// on every restart. The named return retErr is the gate. +func newEmbeddedAuthServerWithStorage( + ctx context.Context, + cfg *authserver.RunConfig, + stor storage.Storage, +) (retEAS *EmbeddedAuthServer, retErr error) { + // From here on, any error must close stor before returning. + // + // Both errors are passed through dcr.SanitizeErrorForLog before being + // recorded: closeErr for symmetry with retErr, retErr because the + // most common cause of reaching this gate is a wrapped DCR failure + // whose error chain may inline several KiB of the upstream's raw + // /register response body — that body is attacker-influenced and may + // contain URL components that carry credentials (userinfo, query, + // fragment). The existing dcr.LogStepError boundary log routes + // through the same sanitiser; keep the two log paths consistent so + // the cleanup log cannot regress to a less-defended state. The + // "cause" key matches the package-wide vocabulary for the + // triggering error. + defer func() { + if retErr != nil { + if closeErr := stor.Close(); closeErr != nil { + slog.Warn("failed to close storage on NewEmbeddedAuthServer error path", + "error", dcr.SanitizeErrorForLog(closeErr), + "cause", dcr.SanitizeErrorForLog(retErr), + ) + } + } + }() + // 1. Create key provider from RunConfig.SigningKeyConfig keyProvider, err := createKeyProvider(cfg.SigningKeyConfig) if err != nil { @@ -73,13 +149,35 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb return nil, fmt.Errorf("failed to parse token lifespans: %w", err) } - // 4. Build upstream configurations - upstreams, err := buildUpstreamConfigs(ctx, cfg.Upstreams) + // 4. Type-assert to the DCR-capable handle for the resolver. The + // per-backend `var _ DCRCredentialStore = (*MemoryStorage)(nil)` / + // `(*RedisStorage)(nil)` checks make this provably safe for production + // backends; surfacing a non-DCR backend as a constructor error keeps + // misconfiguration fail-loud at boot rather than at first DCR resolve. + dcrStore, ok := stor.(storage.DCRCredentialStore) + if !ok { + return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", stor) + } + + // 5. Build upstream configurations. The DCR resolver caches RFC 7591 + // resolutions in dcrStore so re-entrant boot/reload paths reuse + // previously-registered upstream clients instead of re-registering. + upstreams, err := buildUpstreamConfigs(ctx, cfg.Upstreams, cfg.Issuer, dcr.NewStorageBackedStore(dcrStore)) if err != nil { return nil, fmt.Errorf("failed to build upstream configs: %w", err) } - // 5. Build the resolved Config + // 6. Build the resolved Config. + // + // Defensive copies of the scope/audience slices: cfg is operator-supplied + // input that may be retained or mutated by the caller (e.g. tests, a + // future hot-reload path). The DCR handler reads these slices on every + // request, so a mid-request mutation of the original would race. Cloning + // here once at the boundary lets all downstream stages share by reference + // safely. Cost is negligible — each slice is bounded by validation (≤10 + // for BaselineClientScopes, low cardinality in practice for the others). + cimdEnabled, cimdCacheMaxSize, cimdCacheFallbackTTL := resolveCIMDConfig(cfg.CIMD) + resolvedCfg := authserver.Config{ Issuer: cfg.Issuer, AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL, @@ -89,17 +187,17 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb RefreshTokenLifespan: refreshLifespan, AuthCodeLifespan: authCodeLifespan, Upstreams: upstreams, - ScopesSupported: cfg.ScopesSupported, - AllowedAudiences: cfg.AllowedAudiences, + ScopesSupported: slices.Clone(cfg.ScopesSupported), + BaselineClientScopes: slices.Clone(cfg.BaselineClientScopes), + AllowedAudiences: slices.Clone(cfg.AllowedAudiences), + CIMDEnabled: cimdEnabled, + CIMDCacheMaxSize: cimdCacheMaxSize, + CIMDCacheFallbackTTL: cimdCacheFallbackTTL, } - // 6. Create storage backend based on configuration - stor, err := createStorage(ctx, cfg.Storage) - if err != nil { - return nil, fmt.Errorf("failed to create storage: %w", err) - } - - // 7. Create the auth server + // 7. Create the auth server. authserver.New also asserts the DCR + // capability internally so its DCRStore() accessor returns the same + // asserted handle this constructor used for buildUpstreamConfigs. server, err := authserver.New(ctx, resolvedCfg, stor) if err != nil { return nil, fmt.Errorf("failed to create auth server: %w", err) @@ -151,6 +249,16 @@ func (e *EmbeddedAuthServer) KeyProvider() keys.KeyProvider { return e.keyProvider } +// DCRStore returns the persistent DCR credential store the authorization +// server is wired against. This delegates to the underlying authserver.Server +// so this struct does not hold a redundant copy that could drift if the +// server ever swaps backends. See authserver.Server.DCRStore for SECURITY +// and lifecycle notes — the returned interface surfaces raw client_secret +// and registration_access_token values and MUST NOT be logged or rendered. +func (e *EmbeddedAuthServer) DCRStore() storage.DCRCredentialStore { + return e.server.DCRStore() +} + // Routes returns the authorization server's HTTP route map. // // The /.well-known/ paths are registered explicitly because that namespace is shared: @@ -271,14 +379,78 @@ func parseTokenLifespans(cfg *authserver.TokenLifespanRunConfig) (access, refres // buildUpstreamConfigs converts UpstreamRunConfig slice to UpstreamConfig slice. // It preserves the provider type so the factory can create the correct provider // (OIDCProviderImpl for OIDC, BaseOAuth2Provider for OAuth2). -func buildUpstreamConfigs(_ context.Context, runConfigs []authserver.UpstreamRunConfig) ([]authserver.UpstreamConfig, error) { +// +// For OAuth2 upstreams configured with DCRConfig, buildUpstreamConfigs performs +// RFC 7591 Dynamic Client Registration against the upstream authorization +// server (hitting the network on first call, using dcrStore on subsequent +// calls) and overlays the resulting ClientID / ClientSecret onto the output +// config via consumeResolution + applyResolutionToOAuth2Config (see +// dcr_adapter.go). The caller's runConfigs slice is not mutated: in-place +// mutation of caller-provided values surprises callers and can cause data +// races, so each element is cloned before applying DCR resolution. +// +// Error logging: this function is the boundary for DCR errors — on any +// failure from dcr.ResolveCredentials it emits exactly one structured +// slog.Error via dcr.LogStepError and returns the wrapped error to the +// caller without logging further. The resolver itself does not log +// errors, which avoids the log-and-return double-reporting pattern. +func buildUpstreamConfigs( + ctx context.Context, + runConfigs []authserver.UpstreamRunConfig, + issuer string, + dcrStore dcr.CredentialStore, +) ([]authserver.UpstreamConfig, error) { configs := make([]authserver.UpstreamConfig, 0, len(runConfigs)) for _, rc := range runConfigs { - cfg, err := buildUpstreamConfig(&rc) + // Shallow copy of the outer UpstreamRunConfig so DCR resolution never + // mutates the caller's slice element. + rcCopy := rc + + var dcrResolution *dcr.Resolution + // needsDCR returns false for nil input, so the explicit Type == + // OAuth2 guard is redundant. Keeping a single source of truth for + // "does this upstream require DCR" avoids drift if the condition + // ever needs to be extended (e.g., to support OIDC DCR). + if needsDCR(rcCopy.OAuth2Config) { + // Take a local copy of the OAuth2 sub-config. dcr.ResolveCredentials + // reads it but does not mutate; consumeResolution is value-in / + // value-out, so the caller's original OAuth2Config pointer target + // is never reached by either call. + o2 := *rcCopy.OAuth2Config + + req, err := newDCRRequest(&o2, issuer) + if err != nil { + return nil, fmt.Errorf("upstream %q: %w", rc.Name, err) + } + resolution, err := dcr.ResolveCredentials(ctx, req, dcrStore) + if err != nil { + // Emit the single boundary Error record with enough context to + // correlate the failure back to this upstream; then return the + // wrapped error without further logging. + dcr.LogStepError(rc.Name, err) + return nil, fmt.Errorf("upstream %q: %w", rc.Name, err) + } + o2 = consumeResolution(o2, resolution) + rcCopy.OAuth2Config = &o2 + dcrResolution = resolution + } + + cfg, err := buildUpstreamConfig(&rcCopy) if err != nil { return nil, fmt.Errorf("upstream %q: %w", rc.Name, err) } + + // Apply the DCR-resolved ClientSecret to the built OAuth2Config. + // The split between consumeResolution (run-config fields) and + // applyResolutionToOAuth2Config (inline-only ClientSecret) is + // documented in dcr_adapter.go — both calls must be paired to + // produce a fully-resolved DCR client. + if dcrResolution != nil && cfg.OAuth2Config != nil { + applied := applyResolutionToOAuth2Config(*cfg.OAuth2Config, dcrResolution) + cfg.OAuth2Config = &applied + } + configs = append(configs, *cfg) } @@ -366,12 +538,20 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig) (*upstream.OIDCConfig, er } // buildPureOAuth2Config builds an upstream.OAuth2Config for a pure OAuth2 provider. +// +// Run-config-specific invariants (e.g. ClientID/DCRConfig mutual exclusion) are +// enforced here via OAuth2UpstreamRunConfig.Validate before secrets are +// resolved, since the downstream upstream.OAuth2Config validator only sees the +// flattened runtime shape and cannot observe DCR fields. func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig) (*upstream.OAuth2Config, error) { if rc.OAuth2Config == nil { return nil, fmt.Errorf("oauth2_config required for OAuth2 provider") } oauth2 := rc.OAuth2Config + if err := oauth2.Validate(); err != nil { + return nil, err + } clientSecret, err := resolveSecret(oauth2.ClientSecretFile, oauth2.ClientSecretEnvVar) if err != nil { return nil, fmt.Errorf("failed to resolve OAuth2 client secret: %w", err) @@ -399,6 +579,14 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig) (*upstream.OAuth2Co } } + if oauth2.IdentityFromToken != nil { + cfg.IdentityFromToken = &upstream.IdentityFromTokenConfig{ + SubjectPath: oauth2.IdentityFromToken.SubjectPath, + NamePath: oauth2.IdentityFromToken.NamePath, + EmailPath: oauth2.IdentityFromToken.EmailPath, + } + } + return cfg, nil } @@ -461,106 +649,131 @@ func createStorage(ctx context.Context, cfg *storage.RunConfig) (storage.Storage if err != nil { return nil, fmt.Errorf("invalid Redis config: %w", err) } - return storage.NewRedisStorage(ctx, *redisCfg) + return storage.NewRedisStorage(ctx, redisCfg, cfg.RedisConfig.KeyPrefix) } return nil, fmt.Errorf("unsupported storage type: %s", cfg.Type) } -// convertRedisRunConfig converts a serializable RedisRunConfig to the runtime RedisConfig. -// It resolves credentials from environment variables and parses duration strings. -func convertRedisRunConfig(rc *storage.RedisRunConfig) (*storage.RedisConfig, error) { +// convertRedisRunConfig converts a serializable RedisRunConfig to a runtime +// tcredis.Config. It resolves ACL credentials from environment variables and +// parses duration strings. Connection-mode topology and defaulting are handled +// by the shared toolhive-core redis package when the client is constructed. +func convertRedisRunConfig(rc *storage.RedisRunConfig) (tcredis.Config, error) { if rc == nil { - return nil, fmt.Errorf("redis config is required when storage type is redis") + return tcredis.Config{}, fmt.Errorf("redis config is required when storage type is redis") } - cfg := &storage.RedisConfig{ - KeyPrefix: rc.KeyPrefix, - Address: rc.Address, + cfg := tcredis.Config{ + Addr: rc.EffectiveAddr(), + ClusterMode: rc.ClusterMode, } - hasAddress := rc.Address != "" - hasSentinel := rc.SentinelConfig != nil - - switch { - case !hasAddress && !hasSentinel: - return nil, fmt.Errorf("redis address or sentinel config is required") - case hasAddress && hasSentinel: - return nil, fmt.Errorf("redis address and sentinel config are mutually exclusive") - case hasSentinel: - cfg.SentinelConfig = &storage.SentinelConfig{ + if rc.SentinelConfig != nil { + cfg.SentinelConfig = &tcredis.SentinelConfig{ MasterName: rc.SentinelConfig.MasterName, SentinelAddrs: rc.SentinelConfig.SentinelAddrs, - DB: rc.SentinelConfig.DB, } + cfg.DB = rc.SentinelConfig.DB } - // Resolve ACL credentials from environment variables - if rc.ACLUserConfig == nil { - return nil, fmt.Errorf("ACL user config is required") - } - username, err := resolveEnvVar(rc.ACLUserConfig.UsernameEnvVar) + acl, err := convertRedisACLConfig(rc.ACLUserConfig) if err != nil { - return nil, fmt.Errorf("failed to resolve Redis username: %w", err) + return tcredis.Config{}, fmt.Errorf("failed to convert ACL config: %w", err) + } + cfg.Username = acl.username + cfg.Password = acl.password + + if err := applyRedisTimeouts(rc, &cfg); err != nil { + return tcredis.Config{}, fmt.Errorf("failed to apply redis timeouts: %w", err) } - password, err := resolveEnvVar(rc.ACLUserConfig.PasswordEnvVar) + + tlsCfg, err := convertRedisTLSRunConfig(rc.TLS) if err != nil { - return nil, fmt.Errorf("failed to resolve Redis password: %w", err) + return tcredis.Config{}, fmt.Errorf("master TLS config: %w", err) + } + cfg.TLS = tlsCfg + + // SentinelTLS only applies in Sentinel mode + if rc.SentinelConfig != nil { + sentinelTLSCfg, err := convertRedisTLSRunConfig(rc.SentinelTLS) + if err != nil { + return tcredis.Config{}, fmt.Errorf("sentinel TLS config: %w", err) + } + cfg.SentinelTLS = sentinelTLSCfg } - cfg.ACLUserConfig = &storage.ACLUserConfig{ - Username: username, - Password: password, + + return cfg, nil +} + +// redisACLCredentials carries resolved Redis ACL credentials between +// convertRedisACLConfig and its caller. Named fields prevent positional +// swaps of two same-typed strings at the call site. +type redisACLCredentials struct { + username string + password string +} + +// convertRedisACLConfig resolves ACL user credentials from environment variables. +// When UsernameEnvVar is empty, no username is resolved; go-redis then sends +// HELLO with "default" as the username (or falls back to legacy AUTH +// for servers that do not support HELLO). This is required for managed Redis +// tiers without ACL users (e.g. GCP Memorystore Basic/Standard HA, Azure Cache +// for Redis). +func convertRedisACLConfig(rc *storage.ACLUserRunConfig) (redisACLCredentials, error) { + if rc == nil { + return redisACLCredentials{}, fmt.Errorf("acl user config is required") + } + var username string + if rc.UsernameEnvVar != "" { + var err error + username, err = resolveEnvVar(rc.UsernameEnvVar) + if err != nil { + return redisACLCredentials{}, fmt.Errorf("failed to resolve Redis username: %w", err) + } + } + password, err := resolveEnvVar(rc.PasswordEnvVar) + if err != nil { + return redisACLCredentials{}, fmt.Errorf("failed to resolve Redis password: %w", err) } + return redisACLCredentials{username: username, password: password}, nil +} - // Parse optional timeouts +// applyRedisTimeouts parses and applies optional timeout duration strings to cfg. +func applyRedisTimeouts(rc *storage.RedisRunConfig, cfg *tcredis.Config) error { if rc.DialTimeout != "" { d, err := time.ParseDuration(rc.DialTimeout) if err != nil { - return nil, fmt.Errorf("invalid dial timeout: %w", err) + return fmt.Errorf("invalid dial timeout: %w", err) } cfg.DialTimeout = d } if rc.ReadTimeout != "" { d, err := time.ParseDuration(rc.ReadTimeout) if err != nil { - return nil, fmt.Errorf("invalid read timeout: %w", err) + return fmt.Errorf("invalid read timeout: %w", err) } cfg.ReadTimeout = d } if rc.WriteTimeout != "" { d, err := time.ParseDuration(rc.WriteTimeout) if err != nil { - return nil, fmt.Errorf("invalid write timeout: %w", err) + return fmt.Errorf("invalid write timeout: %w", err) } cfg.WriteTimeout = d } - - tlsCfg, err := convertRedisTLSRunConfig(rc.TLS) - if err != nil { - return nil, fmt.Errorf("master TLS config: %w", err) - } - cfg.TLS = tlsCfg - - if !hasSentinel && rc.SentinelTLS != nil { - return nil, fmt.Errorf("sentinel TLS config requires sentinel mode") - } - sentinelTLSCfg, err := convertRedisTLSRunConfig(rc.SentinelTLS) - if err != nil { - return nil, fmt.Errorf("sentinel TLS config: %w", err) - } - cfg.SentinelTLS = sentinelTLSCfg - - return cfg, nil + return nil } -// convertRedisTLSRunConfig converts a RedisTLSRunConfig to runtime RedisTLSConfig. -// Returns an error if a CA cert file is configured but cannot be read — this is -// treated as a hard error because silently falling back to system CAs could mask -// a misconfiguration and cause confusing TLS failures downstream. -func convertRedisTLSRunConfig(rc *storage.RedisTLSRunConfig) (*storage.RedisTLSConfig, error) { +// convertRedisTLSRunConfig converts a RedisTLSRunConfig to a runtime +// tcredis.TLSConfig. Returns an error if a CA cert file is configured but +// cannot be read — this is treated as a hard error because silently falling +// back to system CAs could mask a misconfiguration and cause confusing TLS +// failures downstream. +func convertRedisTLSRunConfig(rc *storage.RedisTLSRunConfig) (*tcredis.TLSConfig, error) { if rc == nil { return nil, nil } - cfg := &storage.RedisTLSConfig{ + cfg := &tcredis.TLSConfig{ InsecureSkipVerify: rc.InsecureSkipVerify, } if rc.CACertFile != "" { @@ -574,6 +787,27 @@ func convertRedisTLSRunConfig(rc *storage.RedisTLSRunConfig) (*storage.RedisTLSC return cfg, nil } +// resolveCIMDConfig extracts CIMD settings from a CIMDRunConfig. +// Returns zero values when cfg is nil (CIMD disabled). +// The CacheFallbackTTL string is parsed to time.Duration; callers must ensure +// CIMDRunConfig.Validate() has already been called so the string is well-formed. +func resolveCIMDConfig(cfg *authserver.CIMDRunConfig) (enabled bool, cacheMaxSize int, cacheFallbackTTL time.Duration) { + if cfg == nil { + return false, 0, 0 + } + var ttl time.Duration + if cfg.CacheFallbackTTL != "" { + var err error + ttl, err = time.ParseDuration(cfg.CacheFallbackTTL) + if err != nil { + // Should not happen when called after CIMDRunConfig.Validate(). + slog.Warn("invalid cimd cache_fallback_ttl, zero will be replaced by default", + "value", cfg.CacheFallbackTTL, "err", err) + } + } + return cfg.Enabled, cfg.CacheMaxSize, ttl +} + // resolveEnvVar reads a value from the named environment variable. func resolveEnvVar(envVar string) (string, error) { if envVar == "" { diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index d40adc1042..a4e00d339d 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -4,17 +4,22 @@ package runner import ( + "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" + "encoding/json" "encoding/pem" "fmt" + "io" + "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -25,6 +30,7 @@ import ( servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/oauthproto" ) func TestCreateKeyProvider(t *testing.T) { @@ -280,6 +286,15 @@ func TestParseTokenLifespans(t *testing.T) { }) } +// TestResolveSecret pins the observable contract of the runner-package +// resolveSecret helper: file-precedence, whitespace-trimming, and the +// explicit error modes for missing-file / unset-env. resolveSecret is +// the single authoritative implementation in the codebase; the +// pkg/auth/dcr package no longer carries a parallel copy (removed in +// #5219 sub-issue 4b, when the resolver's input was neutralised and the +// embedded-authserver adapter took responsibility for resolving the +// file-or-env reference into Request.InitialAccessToken at the call +// site). func TestResolveSecret(t *testing.T) { t.Parallel() @@ -521,6 +536,65 @@ func TestBuildPureOAuth2Config(t *testing.T) { assert.Equal(t, map[string]string{"access_type": "offline"}, cfg.AdditionalAuthorizationParams) }) + + t.Run("rejects config with neither ClientID nor DCRConfig", func(t *testing.T) { + t.Parallel() + + rc := &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + RedirectURI: "https://my-app.com/callback", + }, + } + + _, err := buildPureOAuth2Config(rc) + require.Error(t, err) + assert.Contains(t, err.Error(), "client_id or dcr_config is required") + }) + + t.Run("rejects config with both ClientID and DCRConfig", func(t *testing.T) { + t.Parallel() + + rc := &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + ClientID: "my-client-id", + RedirectURI: "https://my-app.com/callback", + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://example.com/register", + }, + }, + } + + _, err := buildPureOAuth2Config(rc) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) + + t.Run("accepts DCRConfig without ClientID", func(t *testing.T) { + t.Parallel() + + rc := &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + RedirectURI: "https://my-app.com/callback", + DCRConfig: &authserver.DCRUpstreamConfig{ + RegistrationEndpoint: "https://example.com/register", + }, + }, + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Empty(t, cfg.ClientID) + }) } // TestBuildPureOAuth2ConfigWithEnvVar tests buildPureOAuth2Config with environment variables. @@ -549,6 +623,73 @@ func TestBuildPureOAuth2ConfigWithEnvVar(t *testing.T) { }) } +func TestBuildPureOAuth2ConfigIdentityFromToken(t *testing.T) { + t.Parallel() + + baseRC := func() *authserver.UpstreamRunConfig { + return &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + ClientID: "my-client-id", + RedirectURI: "https://my-app.com/callback", + }, + } + } + + t.Run("nil IdentityFromToken produces nil in runtime config", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + // IdentityFromToken is not set + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Nil(t, cfg.IdentityFromToken, "IdentityFromToken must be nil when not configured") + }) + + t.Run("all three paths round-trip correctly", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + rc.OAuth2Config.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{ + SubjectPath: "username", + NamePath: "display_name", + EmailPath: "email", + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.IdentityFromToken) + assert.Equal(t, "username", cfg.IdentityFromToken.SubjectPath) + assert.Equal(t, "display_name", cfg.IdentityFromToken.NamePath) + assert.Equal(t, "email", cfg.IdentityFromToken.EmailPath) + }) + + t.Run("only SubjectPath set, name and email empty", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + rc.OAuth2Config.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{ + SubjectPath: "authed_user.id", + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.IdentityFromToken) + assert.Equal(t, "authed_user.id", cfg.IdentityFromToken.SubjectPath) + assert.Empty(t, cfg.IdentityFromToken.NamePath) + assert.Empty(t, cfg.IdentityFromToken.EmailPath) + }) +} + func TestNewHMACSecrets(t *testing.T) { t.Parallel() @@ -1025,24 +1166,11 @@ func TestCreateStorage(t *testing.T) { assert.Contains(t, err.Error(), "redis config is required") }) - t.Run("redis type with missing connection config returns error", func(t *testing.T) { - t.Parallel() - - _, err := createStorage(ctx, &storage.RunConfig{ - Type: string(storage.TypeRedis), - RedisConfig: &storage.RedisRunConfig{ - KeyPrefix: "test:", - ACLUserConfig: &storage.ACLUserRunConfig{ - UsernameEnvVar: "REDIS_USER", - PasswordEnvVar: "REDIS_PASS", - }, - }, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "redis address or sentinel config is required") - }) } +// TestConvertRedisRunConfig covers the runner-owned conversion steps: nil +// guard and ACL credential resolution. Connection-mode topology validation is +// owned by the shared toolhive-core redis package and exercised in its tests. func TestConvertRedisRunConfig(t *testing.T) { t.Parallel() @@ -1053,37 +1181,6 @@ func TestConvertRedisRunConfig(t *testing.T) { assert.Contains(t, err.Error(), "redis config is required") }) - t.Run("missing connection config returns error", func(t *testing.T) { - t.Parallel() - _, err := convertRedisRunConfig(&storage.RedisRunConfig{ - KeyPrefix: "test:", - ACLUserConfig: &storage.ACLUserRunConfig{ - UsernameEnvVar: "USER", - PasswordEnvVar: "PASS", - }, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "redis address or sentinel config is required") - }) - - t.Run("address and sentinel config returns error", func(t *testing.T) { - t.Parallel() - _, err := convertRedisRunConfig(&storage.RedisRunConfig{ - Address: "dragonfly:6379", - KeyPrefix: "test:", - SentinelConfig: &storage.SentinelRunConfig{ - MasterName: "mymaster", - SentinelAddrs: []string{"localhost:26379"}, - }, - ACLUserConfig: &storage.ACLUserRunConfig{ - UsernameEnvVar: "USER", - PasswordEnvVar: "PASS", - }, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "redis address and sentinel config are mutually exclusive") - }) - t.Run("missing ACL user config returns error", func(t *testing.T) { t.Parallel() _, err := convertRedisRunConfig(&storage.RedisRunConfig{ @@ -1094,7 +1191,7 @@ func TestConvertRedisRunConfig(t *testing.T) { }, }) require.Error(t, err) - assert.Contains(t, err.Error(), "ACL user config is required") + assert.Contains(t, err.Error(), "acl user config is required") }) t.Run("unset username env var returns error", func(t *testing.T) { @@ -1138,16 +1235,13 @@ func TestConvertRedisRunConfig_WithEnvVars(t *testing.T) { WriteTimeout: "3s", }) require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, "thv:auth:ns:name:", cfg.KeyPrefix) require.NotNil(t, cfg.SentinelConfig) assert.Equal(t, "mymaster", cfg.SentinelConfig.MasterName) assert.Equal(t, []string{"10.0.0.1:26379", "10.0.0.2:26379"}, cfg.SentinelConfig.SentinelAddrs) - assert.Equal(t, 3, cfg.SentinelConfig.DB) - require.NotNil(t, cfg.ACLUserConfig) - assert.Equal(t, "myuser", cfg.ACLUserConfig.Username) - assert.Equal(t, "mypass", cfg.ACLUserConfig.Password) + assert.Equal(t, 3, cfg.DB) + assert.Equal(t, "myuser", cfg.Username) + assert.Equal(t, "mypass", cfg.Password) assert.Equal(t, 10*time.Second, cfg.DialTimeout) assert.Equal(t, 5*time.Second, cfg.ReadTimeout) assert.Equal(t, 3*time.Second, cfg.WriteTimeout) @@ -1158,7 +1252,7 @@ func TestConvertRedisRunConfig_WithEnvVars(t *testing.T) { t.Setenv("TEST_REDIS_PASS_DIRECT", "mypass") cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{ - Address: "dragonfly.default.svc.cluster.local:6379", + Addr: "dragonfly.default.svc.cluster.local:6379", KeyPrefix: "thv:auth:ns:name:", ACLUserConfig: &storage.ACLUserRunConfig{ UsernameEnvVar: "TEST_REDIS_USER_DIRECT", @@ -1171,11 +1265,10 @@ func TestConvertRedisRunConfig_WithEnvVars(t *testing.T) { require.NoError(t, err) require.NotNil(t, cfg) - assert.Equal(t, "dragonfly.default.svc.cluster.local:6379", cfg.Address) + assert.Equal(t, "dragonfly.default.svc.cluster.local:6379", cfg.Addr) assert.Nil(t, cfg.SentinelConfig) - require.NotNil(t, cfg.ACLUserConfig) - assert.Equal(t, "myuser", cfg.ACLUserConfig.Username) - assert.Equal(t, "mypass", cfg.ACLUserConfig.Password) + assert.Equal(t, "myuser", cfg.Username) + assert.Equal(t, "mypass", cfg.Password) assert.Equal(t, 10*time.Second, cfg.DialTimeout) assert.Equal(t, 5*time.Second, cfg.ReadTimeout) assert.Equal(t, 3*time.Second, cfg.WriteTimeout) @@ -1223,21 +1316,71 @@ func TestConvertRedisRunConfig_WithEnvVars(t *testing.T) { assert.Zero(t, cfg.WriteTimeout) }) - t.Run("direct address with sentinel TLS returns error", func(t *testing.T) { - t.Setenv("TEST_REDIS_USER_STLS", "myuser") - t.Setenv("TEST_REDIS_PASS_STLS", "mypass") + t.Run("standalone addr, no sentinel config", func(t *testing.T) { + t.Setenv("TOOLHIVE_AUTH_SERVER_REDIS_USERNAME", "user") + t.Setenv("TOOLHIVE_AUTH_SERVER_REDIS_PASSWORD", "pass") + cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{ + Addr: "redis.example.com:6379", + ACLUserConfig: &storage.ACLUserRunConfig{ + UsernameEnvVar: "TOOLHIVE_AUTH_SERVER_REDIS_USERNAME", + PasswordEnvVar: "TOOLHIVE_AUTH_SERVER_REDIS_PASSWORD", + }, + KeyPrefix: "thv:auth:ns:name:", + }) + require.NoError(t, err) + assert.Equal(t, "redis.example.com:6379", cfg.Addr) + assert.Nil(t, cfg.SentinelConfig) + }) - _, err := convertRedisRunConfig(&storage.RedisRunConfig{ - Address: "dragonfly.default.svc.cluster.local:6379", - KeyPrefix: "test:", + t.Run("empty UsernameEnvVar uses legacy password-only auth", func(t *testing.T) { + t.Setenv("TEST_REDIS_PASS_LEGACY", "mypass") + + cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{ + Addr: "memorystore.example.com:6379", + KeyPrefix: "thv:auth:ns:name:", ACLUserConfig: &storage.ACLUserRunConfig{ - UsernameEnvVar: "TEST_REDIS_USER_STLS", - PasswordEnvVar: "TEST_REDIS_PASS_STLS", + UsernameEnvVar: "", // omitted: triggers legacy AUTH + PasswordEnvVar: "TEST_REDIS_PASS_LEGACY", }, - SentinelTLS: &storage.RedisTLSRunConfig{}, }) - require.Error(t, err) - assert.Contains(t, err.Error(), "sentinel TLS config requires sentinel mode") + require.NoError(t, err) + assert.Empty(t, cfg.Username) + assert.Equal(t, "mypass", cfg.Password) + }) + + t.Run("cluster mode resolves correctly", func(t *testing.T) { + t.Setenv("TEST_REDIS_USER_CLUSTER", "clusteruser") + t.Setenv("TEST_REDIS_PASS_CLUSTER", "clusterpass") + + cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{ + Addr: "discovery.example.com:6379", + ClusterMode: true, + ACLUserConfig: &storage.ACLUserRunConfig{ + UsernameEnvVar: "TEST_REDIS_USER_CLUSTER", + PasswordEnvVar: "TEST_REDIS_PASS_CLUSTER", + }, + KeyPrefix: "thv:auth:ns:name:", + }) + require.NoError(t, err) + assert.Equal(t, "discovery.example.com:6379", cfg.Addr) + assert.True(t, cfg.ClusterMode) + assert.Nil(t, cfg.SentinelConfig) + assert.Equal(t, "clusteruser", cfg.Username) + assert.Equal(t, "clusterpass", cfg.Password) + }) + + t.Run("legacy serialized address is used when addr is empty", func(t *testing.T) { + t.Setenv("TEST_REDIS_PASS_LEGACY_ADDR", "mypass") + + cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{ + LegacyAddress: "dragonfly.default.svc.cluster.local:6379", //nolint:staticcheck // exercises the deprecated fallback + KeyPrefix: "thv:auth:ns:name:", + ACLUserConfig: &storage.ACLUserRunConfig{ + PasswordEnvVar: "TEST_REDIS_PASS_LEGACY_ADDR", + }, + }) + require.NoError(t, err) + assert.Equal(t, "dragonfly.default.svc.cluster.local:6379", cfg.Addr) }) } @@ -1251,6 +1394,7 @@ type stubServer struct { func (s *stubServer) Handler() http.Handler { return s.handler } func (*stubServer) IDPTokenStorage() storage.UpstreamTokenStorage { return nil } func (*stubServer) UpstreamTokenRefresher() storage.UpstreamTokenRefresher { return nil } +func (*stubServer) DCRStore() storage.DCRCredentialStore { return nil } func (*stubServer) Close() error { return nil } func TestRoutes(t *testing.T) { @@ -1363,3 +1507,597 @@ func TestRegisterHandlers(t *testing.T) { "expected 404 for unregistered root path") }) } + +// newMockAuthorizationServer stands up a mock authorization server that +// serves RFC 8414 discovery metadata and an RFC 7591 /register endpoint. +// Every request is counted via the returned *int32 so tests can assert that +// cache hits issue zero additional network I/O. The issuer advertised in +// metadata is the server's own URL (loopback), which satisfies the HTTPS +// redirect-URI policy in resolveUpstreamRedirectURI. +func newMockAuthorizationServer(t *testing.T) (*httptest.Server, *int32) { + t.Helper() + + var total int32 + var server *httptest.Server + + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&total, 1) + md := oauthproto.AuthorizationServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + RegistrationEndpoint: server.URL + "/register", + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, + ScopesSupported: []string{"openid", "profile"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&total, 1) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = r.Body.Close() + var req oauthproto.DynamicClientRegistrationRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := oauthproto.DynamicClientRegistrationResponse{ + ClientID: "dcr-client-id", + ClientSecret: "dcr-client-secret", + RegistrationAccessToken: "dcr-reg-token", + TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + // Catch-all to count unexpected requests. + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&total, 1) + w.WriteHeader(http.StatusNotFound) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, &total +} + +// TestBuildUpstreamConfigs_DCR verifies the end-to-end DCR wiring inside +// buildUpstreamConfigs: on first call it registers with the mock AS and +// overlays the resolved client_id/client_secret; on second call it hits the +// in-memory store and issues zero additional HTTP requests; and neither call +// mutates the caller's original RunConfig.Upstreams slice. +func TestBuildUpstreamConfigs_DCR(t *testing.T) { + t.Parallel() + + t.Run("DCR boot registers and overlays credentials", func(t *testing.T) { + t.Parallel() + + server, requestCount := newMockAuthorizationServer(t) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: server.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + // ClientID intentionally empty: triggers DCR. + ClientID: "", + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + store := newMemoryDCRStore(t) + got, err := buildUpstreamConfigs(context.Background(), cfg.Upstreams, cfg.Issuer, store) + require.NoError(t, err) + require.Len(t, got, 1) + + // DCR-resolved credentials appear on the built OAuth2Config. + require.NotNil(t, got[0].OAuth2Config) + assert.Equal(t, "dcr-client-id", got[0].OAuth2Config.ClientID) + assert.Equal(t, "dcr-client-secret", got[0].OAuth2Config.ClientSecret) + + // Store now contains the resolution under the canonical storage.DCRKey. + redirectURI := server.URL + "/oauth/callback" + key := storage.DCRKey{ + Issuer: server.URL, + RedirectURI: redirectURI, + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + cached, ok, err := store.Get(context.Background(), key) + require.NoError(t, err) + require.True(t, ok, "store should contain the DCR resolution keyed by storage.DCRKey") + assert.Equal(t, "dcr-client-id", cached.ClientID) + + // First call must have hit the mock AS at least once (metadata + + // register). The exact count depends on well-known path fallbacks, + // but it must be strictly greater than zero. + assert.Greater(t, atomic.LoadInt32(requestCount), int32(0), + "DCR boot should have issued network I/O to the mock AS") + + // Copy-before-mutate: the caller's original slice element is + // unchanged — ClientID is still empty; DCRConfig is still set. + assert.Equal(t, "", cfg.Upstreams[0].OAuth2Config.ClientID, + "original OAuth2Config.ClientID must not be mutated by DCR resolution") + assert.NotNil(t, cfg.Upstreams[0].OAuth2Config.DCRConfig, + "original OAuth2Config.DCRConfig must not be cleared by DCR resolution") + }) + + t.Run("cache hit on second call issues zero additional HTTP requests", func(t *testing.T) { + t.Parallel() + + server, requestCount := newMockAuthorizationServer(t) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: server.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "", + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + store := newMemoryDCRStore(t) + + // First call: populates the store. + _, err := buildUpstreamConfigs(context.Background(), cfg.Upstreams, cfg.Issuer, store) + require.NoError(t, err) + firstCallRequests := atomic.LoadInt32(requestCount) + require.Greater(t, firstCallRequests, int32(0), + "first call should have issued network I/O") + + // Second call: must short-circuit on the cache and issue zero + // additional HTTP requests against the mock AS. + got, err := buildUpstreamConfigs(context.Background(), cfg.Upstreams, cfg.Issuer, store) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "dcr-client-id", got[0].OAuth2Config.ClientID) + assert.Equal(t, "dcr-client-secret", got[0].OAuth2Config.ClientSecret) + + assert.Equal(t, firstCallRequests, atomic.LoadInt32(requestCount), + "second call must not issue any additional HTTP requests to the mock AS") + + // Copy-before-mutate still holds after both calls. + assert.Equal(t, "", cfg.Upstreams[0].OAuth2Config.ClientID, + "original OAuth2Config.ClientID must remain empty after both calls") + assert.NotNil(t, cfg.Upstreams[0].OAuth2Config.DCRConfig, + "original OAuth2Config.DCRConfig must remain set after both calls") + }) +} + +// TestNewEmbeddedAuthServer_DCRBoot drives the full NewEmbeddedAuthServer +// boot path against a mock upstream AS: signing keys are generated +// ephemerally, storage defaults to memory, and the DCR resolver runs +// inside the constructor. It verifies that (a) the constructor wires +// the shared storage.DCRCredentialStore into the DCR resolver (via the +// dcr.CredentialStore adapter passed to buildUpstreamConfigs), (b) that +// store is populated with the canonical storage.DCRKey after boot, and +// (c) the caller's original RunConfig.Upstreams[i] slice element is +// unchanged. +// +// This complements TestBuildUpstreamConfigs_DCR by exercising the full +// wiring — signing-key creation, HMAC secret defaults, storage +// instantiation, authserver.New() — rather than the internal helper in +// isolation. +func TestNewEmbeddedAuthServer_DCRBoot(t *testing.T) { + t.Parallel() + + server, requestCount := newMockAuthorizationServer(t) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: server.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "", + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + // Retain a pointer to the caller's OAuth2Config to verify that the + // constructor did not mutate it via the shared pointer. + originalOAuth2 := cfg.Upstreams[0].OAuth2Config + + embed, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, embed) + t.Cleanup(func() { _ = embed.Close() }) + + // The constructor must have wired a non-nil DCR store. + dcrStore := embed.DCRStore() + require.NotNil(t, dcrStore, "NewEmbeddedAuthServer must wire a DCR store") + + // The DCR registration must have hit the mock AS at least once. + assert.Greater(t, atomic.LoadInt32(requestCount), int32(0), + "DCR boot should have issued network I/O to the mock AS") + + // The store on the EmbeddedAuthServer contains the canonical + // storage.DCRKey for this upstream — the accessor delegates to the + // same storage.DCRCredentialStore createStorage produced, so a + // successful boot persisted the resolution there directly (no + // separate in-memory store was created). + redirectURI := server.URL + "/oauth/callback" + key := storage.DCRKey{ + Issuer: server.URL, + RedirectURI: redirectURI, + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + cached, err := dcrStore.GetDCRCredentials(context.Background(), key) + require.NoError(t, err, "DCR store on EmbeddedAuthServer must hold the DCR resolution") + require.NotNil(t, cached) + assert.Equal(t, "dcr-client-id", cached.ClientID) + assert.Equal(t, "dcr-client-secret", cached.ClientSecret) + + // Copy-before-mutate: the caller's OAuth2Config pointer is unchanged. + assert.Equal(t, "", originalOAuth2.ClientID, + "NewEmbeddedAuthServer must not mutate the caller's OAuth2Config.ClientID") + assert.NotNil(t, originalOAuth2.DCRConfig, + "NewEmbeddedAuthServer must not clear the caller's OAuth2Config.DCRConfig") + assert.Same(t, originalOAuth2, cfg.Upstreams[0].OAuth2Config, + "NewEmbeddedAuthServer must not replace the caller's OAuth2Config pointer") +} + +// closeTrackingStorage wraps an authserver storage and counts Close calls. +// It implements storage.Storage by embedding the wrapped value, then +// overriding Close to record the call before delegating to the inner +// Storage. Used by TestNewEmbeddedAuthServer_ClosesStorageOnError to +// verify the deferred-cleanup contract on NewEmbeddedAuthServer's error +// paths without depending on goroutine-count heuristics (which are +// confounded by HTTP transport keep-alive goroutines this package does +// not own). +type closeTrackingStorage struct { + storage.Storage + closeCount atomic.Int32 +} + +func (s *closeTrackingStorage) Close() error { + s.closeCount.Add(1) + return s.Storage.Close() +} + +// TestNewEmbeddedAuthServer_ClosesStorageOnError pins the post-#5185 +// invariant that NewEmbeddedAuthServer never leaks the storage backend on +// the constructor's error paths. +// +// Before the wiring change, createStorage ran late in the constructor so +// most error paths (DCR resolver, upstream config build) returned without +// having opened the backend. After the change, createStorage runs first — +// so a DCR failure (the most likely failure mode in production: upstream +// AS unreachable, /register 4xx) returns from the constructor with the +// storage still holding OS-level resources (Redis client connection pool, +// MemoryStorage cleanup goroutine). Without the deferred cleanup, a +// crash-looping pod would leak one connection pool / goroutine per +// restart. +// +// The test calls newEmbeddedAuthServerWithStorage (the test seam that +// production NewEmbeddedAuthServer dispatches into) so the storage +// instance is observable: a closeTrackingStorage wrapper records every +// Close call. The assertion is then a direct count rather than a +// goroutine-count heuristic. This avoids the package-level swap-pattern +// that would otherwise force the test to run non-parallel. +func TestNewEmbeddedAuthServer_ClosesStorageOnError(t *testing.T) { + t.Parallel() + + tracker := &closeTrackingStorage{Storage: storage.NewMemoryStorage()} + + // Always-500 discovery endpoint forces the DCR resolver to fail + // during buildUpstreamConfigs — i.e. inside the leak window between + // createStorage success and EmbeddedAuthServer construction. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: server.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "", + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + embed, err := newEmbeddedAuthServerWithStorage(context.Background(), cfg, tracker) + require.Error(t, err, + "discovery returns 500, so DCR resolution must fail and the constructor must return an error") + assert.Nil(t, embed, + "failed constructor must return nil EmbeddedAuthServer") + assert.Equal(t, int32(1), tracker.closeCount.Load(), + "failed NewEmbeddedAuthServer must Close the storage exactly once via the deferred-cleanup gate; "+ + "a count of 0 indicates the deferred Close did not run, leaking the backend on the error path") +} + +// TestEmbeddedAuthServer_DCRStorePersistsAcrossClose verifies that the DCR +// store reachable through EmbeddedAuthServer.DCRStore() holds the resolved +// RFC 7591 client registration after the constructor's full DCR resolver +// runs against a mock AS. The Get is issued BEFORE Close so the assertion +// does not depend on the (undocumented) MemoryStorage post-Close +// readability that an earlier version of this test silently relied on. +// +// What this test does cover: +// +// - NewEmbeddedAuthServer runs the full DCR resolver against a mock AS +// during construction, populating the storage-backed DCR store, and +// surfaces the same storage.DCRCredentialStore the authserver itself +// reads from via DCRStore(). The persisted credentials are readable +// by issuing a Get against the captured store while the server is +// still live. +// +// What this test does NOT cover (deferred follow-up): +// +// - The full "boot, close, boot again on the same backend, observe zero +// /register calls on the second boot" cross-restart scenario. Closing +// that gap requires either miniredis-Sentinel emulation or a +// Docker-based Redis Sentinel cluster in the test harness, since the +// production restart path lives on Redis (Memory cannot be shared +// across two NewEmbeddedAuthServer constructors). Tracked as a +// follow-up; this test deliberately scopes itself to what is +// exercisable today against the production constructor seam. +func TestEmbeddedAuthServer_DCRStorePersistsAcrossClose(t *testing.T) { + t.Parallel() + + server, requestCount := newMockAuthorizationServer(t) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: server.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "", + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + embed, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, embed) + t.Cleanup(func() { _ = embed.Close() }) + + firstBootRequests := atomic.LoadInt32(requestCount) + require.Greater(t, firstBootRequests, int32(0), + "first boot must have issued network I/O to the mock AS during DCR") + + // Capture the storage instance the constructor wired into the DCR + // store. This is the same backend the authserver itself was using; in + // production it is shared across authserver state, so DCR survives + // restart on the same backend. + persistentStore := embed.DCRStore() + require.NotNil(t, persistentStore, + "NewEmbeddedAuthServer must surface a storage-level DCRCredentialStore") + + // Verify the persisted DCR row by issuing a Get against the captured + // store BEFORE closing the server. Doing the Get pre-Close avoids + // silently depending on whichever storage backend the test happens to + // use staying readable after Close (a contract MemoryStorage honors + // today but RedisStorage's closed connection pool does not). The + // assertion proves the persistence boundary the production cross- + // replica and cross-restart reuse paths depend on: that the + // resolution lives in storage, not in process-local cache state. + redirectURI := server.URL + "/oauth/callback" + key := storage.DCRKey{ + Issuer: server.URL, + RedirectURI: redirectURI, + ScopesHash: storage.ScopesHash([]string{"openid", "profile"}), + } + creds, err := persistentStore.GetDCRCredentials(context.Background(), key) + require.NoError(t, err, + "DCR credentials must be readable from the captured store — "+ + "this is the persistence boundary cross-replica reuse depends on") + require.NotNil(t, creds) + assert.Equal(t, "dcr-client-id", creds.ClientID, + "persisted ClientID must match the first boot's DCR resolution") + assert.Equal(t, "dcr-client-secret", creds.ClientSecret, + "persisted ClientSecret must match the first boot's DCR resolution") + + // Mock-AS request count is unchanged after the survival check — the + // Get is a pure store read with no upstream traffic. + assert.Equal(t, firstBootRequests, atomic.LoadInt32(requestCount), + "GetDCRCredentials must not issue any HTTP requests to the mock AS") +} + +// urlErrorOnCloseStorage wraps an authserver storage and returns a fixed +// URL-bearing error from Close. It exists so +// TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog can verify that the +// deferred-cleanup gate routes both closeErr and retErr through +// dcr.SanitizeErrorForLog, scrubbing any query / userinfo / fragment that might +// carry credentials in a future regression. +type urlErrorOnCloseStorage struct { + storage.Storage + closeErr error +} + +func (s *urlErrorOnCloseStorage) Close() error { + // Intentionally drop the inner Close result: this test is about the log + // path, not about double-closing the inner storage. Returning the + // fixed error makes the slog capture deterministic. + _ = s.Storage.Close() + return s.closeErr +} + +// TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog pins the post-#5196 +// invariant that the deferred-cleanup slog.Warn at the top of +// newEmbeddedAuthServerWithStorage routes both closeErr and retErr through +// dcr.SanitizeErrorForLog, so a future regression that drops the call (or that +// changes the error chain to inline an upstream response body containing a +// userinfo/query/fragment) cannot silently leak secrets to operator logs. +// +// The test injects a closeErr containing a URL with a secret-bearing query +// (?token=leak-marker) and a discovery URL whose host appears verbatim in +// the wrapped DCR error chain (so the captured slog record's `cause` field +// also exercises the sanitiser). It then asserts: +// - the captured log record does NOT contain the literal secret marker; +// - the captured log record DOES contain the host components, so +// operators retain enough context to correlate the failure. +// +// NOT t.Parallel(): the test swaps slog.Default() to capture output and +// restores it via t.Cleanup. Running in parallel would race with any other +// test in this package that emits a log record. Confirmed against the +// paralleltest rule on a sample run — every other test failed with a +// data-race report on slog's internal default-logger handle. +// +//nolint:paralleltest // see comment above; mutates the package-global slog.Default() +func TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog(t *testing.T) { + const ( + closeErrSecretMarker = "close-leak-marker-7f9c" + retErrSecretMarker = "ret-leak-marker-3b2a" + ) + + closeErr := fmt.Errorf( + "redis: connection broken: https://primary:hidden@redis.example.com/0?token=%s", + closeErrSecretMarker, + ) + tracker := &urlErrorOnCloseStorage{ + Storage: storage.NewMemoryStorage(), + closeErr: closeErr, + } + + // Discovery endpoint returns 500 so the DCR resolver fails and the + // constructor reaches the deferred-cleanup gate. The query string + // embedded in the discovery URL is what the resolver wraps into its + // error chain via fmt.Errorf("...%w", err) — so retErr's text is the + // vehicle for the second secret marker. + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(httpServer.Close) + + discoveryURL := httpServer.URL + "/.well-known/oauth-authorization-server?token=" + retErrSecretMarker + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: httpServer.URL, + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + ClientID: "", + AuthorizationEndpoint: httpServer.URL + "/authorize", + TokenEndpoint: httpServer.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{ + DiscoveryURL: discoveryURL, + }, + }, + }, + }, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + + // Capture slog output by swapping the default logger for the duration + // of this test. Restore on cleanup so parallel tests are unaffected. + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + embed, err := newEmbeddedAuthServerWithStorage(context.Background(), cfg, tracker) + require.Error(t, err) + assert.Nil(t, embed) + + logged := buf.String() + + // Defense in depth: both secret markers must be stripped before the + // Warn record reaches operator logs. + assert.NotContains(t, logged, closeErrSecretMarker, + "closeErr secret marker must be sanitised before reaching the Warn record") + assert.NotContains(t, logged, retErrSecretMarker, + "retErr secret marker must be sanitised before reaching the Warn record") + assert.NotContains(t, logged, "primary:hidden", + "closeErr userinfo must be sanitised before reaching the Warn record") + + // The host components survive sanitisation so operators retain enough + // context to correlate the failure with upstream logs. + assert.Contains(t, logged, "redis.example.com", + "closeErr host must remain in the Warn record after sanitisation") +} + +func TestResolveCIMDConfig(t *testing.T) { + t.Parallel() + + t.Run("nil input returns zero values", func(t *testing.T) { + t.Parallel() + enabled, size, ttl := resolveCIMDConfig(nil) + assert.False(t, enabled) + assert.Zero(t, size) + assert.Zero(t, ttl) + }) + + t.Run("non-nil input passes values through", func(t *testing.T) { + t.Parallel() + cfg := &authserver.CIMDRunConfig{ + Enabled: true, + CacheMaxSize: 128, + CacheFallbackTTL: "10m", + } + enabled, size, ttl := resolveCIMDConfig(cfg) + assert.True(t, enabled) + assert.Equal(t, 128, size) + assert.Equal(t, 10*time.Minute, ttl) + }) +} diff --git a/pkg/authserver/server.go b/pkg/authserver/server.go index aef969fec8..d06e2331f5 100644 --- a/pkg/authserver/server.go +++ b/pkg/authserver/server.go @@ -36,6 +36,23 @@ type Server interface { // Returns nil if no upstream IDP is configured. UpstreamTokenRefresher() storage.UpstreamTokenRefresher + // DCRStore returns the persistent DCR credential store the server is wired + // against. This is the same DCRCredentialStore used by the upstream-DCR + // resolver at boot, so callers can read RFC 7591 client registrations + // without bypassing the storage backend the server itself reads from. + // + // SECURITY: the returned interface surfaces raw `client_secret` and + // `registration_access_token` values. Callers MUST NOT log or render the + // returned values; treat the handle the same way you would treat a + // secrets manager client. Intended for admin / diagnostic code paths and + // integration tests, not for general consumers. + // + // Lifecycle: the returned handle's lifetime is bound to Server.Close — + // methods invoked after Close have backend-specific behavior (a + // MemoryStorage continues to serve reads; a RedisStorage will error on + // its closed connection pool). + DCRStore() storage.DCRCredentialStore + // Close releases resources held by the server. Close() error } diff --git a/pkg/authserver/server/handlers/authorize.go b/pkg/authserver/server/handlers/authorize.go index dece4979ad..ba767e88c9 100644 --- a/pkg/authserver/server/handlers/authorize.go +++ b/pkg/authserver/server/handlers/authorize.go @@ -66,7 +66,9 @@ func (h *Handler) AuthorizeHandler(w http.ResponseWriter, req *http.Request) { return } - slog.Debug("parsed client-requested scopes", //nolint:gosec // G706: scope count is an integer + slog.Debug("authorize request received", + "client_id", clientID, + "redirect_uri", redirectURI, "scope_count", len(scopes), ) diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index c829ff3213..d8dab8df79 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" ) @@ -166,6 +167,24 @@ func TestNewHandler_ErrorsOnEmptyUpstreams(t *testing.T) { require.Error(t, err, "NewHandler should error when upstreams is empty slice") } +// TestNewHandler_ErrorsOnNilConfig pins the constructor invariant that a +// nil AuthorizationServerConfig (or one with a nil embedded *fosite.Config) +// is rejected at construction time. Without this guard, issuer() (and any +// other helper that reads config.Config.*) panics inside an HTTP handler +// at request time — far harder to diagnose than a startup error. +func TestNewHandler_ErrorsOnNilConfig(t *testing.T) { + t.Parallel() + + upstreams := []NamedUpstream{{Name: "x", Provider: nil}} + + _, err := NewHandler(nil, nil, nil, upstreams) + require.Error(t, err, "NewHandler should error when AuthorizationServerConfig is nil") + + _, err = NewHandler(nil, &server.AuthorizationServerConfig{}, nil, upstreams) + require.Error(t, err, + "NewHandler should error when AuthorizationServerConfig.Config is nil") +} + func TestAuthorizeHandler_RedirectsToUpstream(t *testing.T) { t.Parallel() handler, storState, mockUpstream := handlerTestSetup(t) diff --git a/pkg/authserver/server/handlers/callback.go b/pkg/authserver/server/handlers/callback.go index 0e262b4695..da69dd66a7 100644 --- a/pkg/authserver/server/handlers/callback.go +++ b/pkg/authserver/server/handlers/callback.go @@ -5,6 +5,7 @@ package handlers import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -105,40 +106,60 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { // This was generated in authorize.go and will be reused across all legs of the chain. sessionID := pending.SessionID - // Determine identity: first leg resolves from upstream, subsequent legs carry from pending + // Determine identity: first leg resolves from upstream, subsequent legs + // carry from pending. Synthetic identities (see upstream.Identity.Synthetic) + // bypass UserResolver — the synthesized subject rotates per re-auth and + // would otherwise grow `users` monotonically. We use the synthesized value + // directly as an ephemeral session key and skip the LastAuthenticated + // update (no provider_identities row to bump). var subject, userName, userEmail string if pending.ResolvedUserID == "" { // First leg — this is the identity provider - user, err := h.userResolver.ResolveUser(ctx, providerID, providerSubject) - if err != nil { - slog.Error("failed to resolve user", "error", err) - h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to resolve user")) - return + if result.Synthetic { + subject = result.Subject + } else { + user, err := h.userResolver.ResolveUser(ctx, providerID, providerSubject) + if err != nil { + slog.Error("failed to resolve user", "error", err) + h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to resolve user")) + return + } + subject = user.ID + h.userResolver.UpdateLastAuthenticated(ctx, providerID, providerSubject) } - subject = user.ID userName = result.Name userEmail = result.Email - h.userResolver.UpdateLastAuthenticated(ctx, providerID, providerSubject) } else { // Subsequent leg — use identity carried from first leg subject = pending.ResolvedUserID userName = pending.ResolvedUserName userEmail = pending.ResolvedUserEmail - h.userResolver.UpdateLastAuthenticated(ctx, providerID, providerSubject) + if !result.Synthetic { + h.userResolver.UpdateLastAuthenticated(ctx, providerID, providerSubject) + } } - // Convert IDP tokens to storage tokens with binding fields + // Convert IDP tokens to storage tokens with binding fields. + // SessionExpiresAt is set unconditionally as the Fosite session bound. Storage + // backends use it as a fallback storage lifetime when ExpiresAt is zero (a + // non-expiring upstream token). Setting it on every write — even when ExpiresAt + // is non-zero — protects the refresh path: if the upstream provider stops + // asserting expires_in on a later refresh, the carried-forward SessionExpiresAt + // still bounds the storage lifetime instead of leaving the row indefinitely. storageTokens := &storage.UpstreamTokens{ - ProviderID: providerID, - AccessToken: idpTokens.AccessToken, - RefreshToken: idpTokens.RefreshToken, - IDToken: idpTokens.IDToken, - ExpiresAt: idpTokens.ExpiresAt, - ClientID: pending.ClientID, - UserID: subject, // Internal ToolHive user ID - UpstreamSubject: providerSubject, // Upstream IDP's subject claim + ProviderID: providerID, + AccessToken: idpTokens.AccessToken, + RefreshToken: idpTokens.RefreshToken, + IDToken: idpTokens.IDToken, + ExpiresAt: idpTokens.ExpiresAt, + SessionExpiresAt: time.Now().Add(h.config.RefreshTokenLifespan), + ClientID: pending.ClientID, + UserID: subject, // Internal ToolHive user ID + UpstreamSubject: providerSubject, // Upstream IDP's subject claim } + h.maybeCarryForwardRefreshToken(ctx, storageTokens, subject, providerSubject, providerID) + if err := h.storage.StoreUpstreamTokens(ctx, sessionID, providerID, storageTokens); err != nil { slog.Error("failed to store upstream tokens", "error", err, @@ -152,6 +173,45 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, subject, userName, userEmail) } +// maybeCarryForwardRefreshToken preserves a prior refresh token when the upstream IdP +// omits refresh_token on re-authorization (a common behavior, e.g. Google without +// prompt=consent). Without this, the new row would be written with an empty RefreshToken, +// orphaning the previously-issued RT and forcing the next refresh attempt to fail. +// Mirrors the preservation pattern in upstreamTokenRefresher.RefreshAndStore. +// +// The UpstreamSubject == providerSubject guard is defense-in-depth against account-linking +// edge cases where one internal user might have two distinct upstream subjects on the +// same provider. +// +// storageTokens is mutated in-place only when a carry-forward is warranted. +func (h *Handler) maybeCarryForwardRefreshToken( + ctx context.Context, + storageTokens *storage.UpstreamTokens, + subject, providerSubject, providerID string, +) { + if storageTokens.RefreshToken != "" { + return + } + prior, err := h.storage.GetLatestUpstreamTokensForUser(ctx, subject, providerID) + switch { + case err == nil: + if prior != nil && prior.UpstreamSubject == providerSubject && prior.RefreshToken != "" { + storageTokens.RefreshToken = prior.RefreshToken + slog.Debug("preserved upstream refresh token from prior row", + "user_id", subject, "provider_id", providerID, + ) + } + case errors.Is(err, storage.ErrNotFound): + // First authorization for this user/provider — nothing to preserve. + default: + // Storage error — log and continue with empty RT. Failing the callback + // would be a worse user experience than the (already broken) status quo. + slog.Warn("failed to look up prior upstream tokens for RT preservation", + "error", err, "user_id", subject, "provider_id", providerID, + ) + } +} + // writeAuthorizationResponse generates an authorization code and writes the redirect response. // This uses fosite's WriteAuthorizeResponse for RFC 6749 compliant redirects with proper // status codes (303 See Other) and cache headers. diff --git a/pkg/authserver/server/handlers/callback_test.go b/pkg/authserver/server/handlers/callback_test.go index 2a1b3b8889..16d52c9cc1 100644 --- a/pkg/authserver/server/handlers/callback_test.go +++ b/pkg/authserver/server/handlers/callback_test.go @@ -176,6 +176,65 @@ func TestCallbackHandler_Success(t *testing.T) { assert.GreaterOrEqual(t, storState.idpTokenCount, 1) } +// TestCallbackHandler_SyntheticIdentity_BypassesUserResolver verifies that an +// Identity with Synthetic=true never reaches UserResolver — no `users` row, +// no `provider_identities` row. Guards against unbounded growth of those +// tables under per-token-rotating synthesized subjects. +func TestCallbackHandler_SyntheticIdentity_BypassesUserResolver(t *testing.T) { + t.Parallel() + handler, storState, mockUpstream := handlerTestSetup(t) + + // Synthesized-shaped subject + Synthetic=true mirrors production. + mockUpstream.exchangeResult.Subject = "tk-deadbeefdeadbeefdeadbeefdeadbeef" + mockUpstream.exchangeResult.Synthetic = true + + internalState := testInternalState + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state", + PKCEChallenge: "challenge123", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: internalState, + UpstreamPKCEVerifier: "test-upstream-pkce-verifier-12345678901234567890", + SessionID: "session-synthetic", + UpstreamProviderName: "test-upstream", + CreatedAt: time.Now(), + } + storState.pendingAuths[internalState] = pending + + usersBefore := len(storState.users) + identitiesBefore := len(storState.providerIdentities) + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=upstream-code&state="+internalState, nil) + rec := httptest.NewRecorder() + + handler.CallbackHandler(rec, req) + + // Auth flow succeeds end-to-end. + assert.Equal(t, http.StatusSeeOther, rec.Code) + location := rec.Header().Get("Location") + assert.Contains(t, location, testAuthRedirectURI) + assert.Contains(t, location, "code=") + assert.NotContains(t, location, "error=") + + // IDP tokens still persist — synthesis bypasses user resolution only. + assert.GreaterOrEqual(t, storState.idpTokenCount, 1, + "synthetic identity must still persist upstream tokens") + + // The bypass: no user row, no provider_identity row. + assert.Equal(t, usersBefore, len(storState.users)) + assert.Equal(t, identitiesBefore, len(storState.providerIdentities)) + + // Stored UserID is the synthesized subject directly (no UUID indirection). + require.NotEmpty(t, storState.upstreamTokens, "upstream tokens should have been stored") + for _, tok := range storState.upstreamTokens { + assert.Equal(t, "tk-deadbeefdeadbeefdeadbeefdeadbeef", tok.UserID, + "UserID on stored upstream tokens must be the synthesized subject") + } +} + func TestCallbackHandler_ScopeFiltering(t *testing.T) { t.Parallel() handler, storState, _ := handlerTestSetup(t) @@ -767,6 +826,161 @@ func TestCallbackHandler_TwoUpstreams_StorePendingError_CleansUp(t *testing.T) { } } +// TestCallbackHandler_RefreshTokenCarryForward verifies the OAuth callback's +// behavior when the upstream IdP omits refresh_token on re-authorization. The +// handler looks up a prior (user, provider) row and carries the prior RT +// forward, with a defensive UpstreamSubject guard against account-linking +// edge cases. Storage errors during the lookup are non-fatal. +func TestCallbackHandler_RefreshTokenCarryForward(t *testing.T) { + t.Parallel() + + type priorRow struct { + sessionID string + upstreamSubject string + refreshToken string + } + + cases := []struct { + name string + priorRow *priorRow // nil = no prior row + idpRefreshToken string // RT returned by upstream exchange + idpSubject string // subject claim returned by upstream + lookupErr error // if non-nil, storage lookup returns this error + expectedStoredRT string // expected RefreshToken on the new row + }{ + { + name: "preserves prior RT when IdP omits it", + priorRow: &priorRow{sessionID: "old-session", upstreamSubject: "user-123", refreshToken: "rt-prior"}, + idpRefreshToken: "", + idpSubject: "user-123", + expectedStoredRT: "rt-prior", + }, + { + name: "no carry across different upstream subjects", + priorRow: &priorRow{sessionID: "alice-session", upstreamSubject: "alice@idp", refreshToken: "rt-prior"}, + idpRefreshToken: "", + idpSubject: "bob@idp", + expectedStoredRT: "", + }, + { + name: "fresh IdP RT wins", + priorRow: &priorRow{sessionID: "old-session", upstreamSubject: "user-123", refreshToken: "rt-prior"}, + idpRefreshToken: "rt-fresh", + idpSubject: "user-123", + expectedStoredRT: "rt-fresh", + }, + { + name: "no prior row accepts empty RT", + priorRow: nil, + idpRefreshToken: "", + idpSubject: "user-123", + expectedStoredRT: "", + }, + { + name: "storage error during lookup is non-fatal", + priorRow: nil, + idpRefreshToken: "", + idpSubject: "user-123", + lookupErr: errors.New("simulated storage failure"), + expectedStoredRT: "", + }, + { + name: "does not carry prior RT when prior RT is empty", + priorRow: &priorRow{sessionID: "old-session", upstreamSubject: "user-123", refreshToken: ""}, + idpRefreshToken: "", + idpSubject: "user-123", + expectedStoredRT: "", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + const ( + providerName = "test-upstream" + existingUserID = "user-id" + newSessionID = "new-session" + ) + + var opts []baseTestSetupOption + if tc.lookupErr != nil { + opts = append(opts, withGetLatestUpstreamTokensError(tc.lookupErr)) + } + handler, storState, mockUpstream := handlerTestSetup(t, opts...) + + mockUpstream.exchangeResult = &upstream.Identity{ + Tokens: &upstream.Tokens{ + AccessToken: "new-access-token", + RefreshToken: tc.idpRefreshToken, + IDToken: "new-id-token", + ExpiresAt: time.Now().Add(time.Hour), + }, + Subject: tc.idpSubject, + } + + // Pre-populate user + identity so ResolveUser is deterministic. + storState.users[existingUserID] = &storage.User{ + ID: existingUserID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + storState.providerIdentities[providerName+":"+tc.idpSubject] = &storage.ProviderIdentity{ + UserID: existingUserID, + ProviderID: providerName, + ProviderSubject: tc.idpSubject, + LinkedAt: time.Now(), + LastUsedAt: time.Now(), + } + + if tc.priorRow != nil { + storState.upstreamTokens[tc.priorRow.sessionID+":"+providerName] = &storage.UpstreamTokens{ + ProviderID: providerName, + AccessToken: "old-access", + RefreshToken: tc.priorRow.refreshToken, + ExpiresAt: time.Now().Add(30 * time.Minute), + ClientID: testAuthClientID, + UserID: existingUserID, + UpstreamSubject: tc.priorRow.upstreamSubject, + } + } + + internalState := testInternalState + storState.pendingAuths[internalState] = &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state", + PKCEChallenge: "challenge123", + PKCEMethod: "S256", + Scopes: []string{"openid"}, + InternalState: internalState, + UpstreamPKCEVerifier: "verifier-1234567890123456789012345678", + SessionID: newSessionID, + UpstreamProviderName: providerName, + CreatedAt: time.Now(), + } + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=test-code&state="+internalState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + assert.NotContains(t, rec.Header().Get("Location"), "error=") + + newRow, ok := storState.upstreamTokens[newSessionID+":"+providerName] + require.True(t, ok, "token row for new session should be stored") + assert.Equal(t, tc.expectedStoredRT, newRow.RefreshToken) + // Sanity-check the rest of the row was written by the callback path so a + // regression that early-returns before StoreUpstreamTokens cannot pass. + assert.Equal(t, "new-access-token", newRow.AccessToken) + assert.Equal(t, "new-id-token", newRow.IDToken) + assert.Equal(t, tc.idpSubject, newRow.UpstreamSubject) + assert.False(t, newRow.ExpiresAt.IsZero(), "ExpiresAt must be populated") + }) + } +} + func TestRoutesIncludeAuthorizeAndCallback(t *testing.T) { t.Parallel() handler, _, _ := handlerTestSetup(t) diff --git a/pkg/authserver/server/handlers/dcr.go b/pkg/authserver/server/handlers/dcr.go index 226c225940..f7dabed355 100644 --- a/pkg/authserver/server/handlers/dcr.go +++ b/pkg/authserver/server/handlers/dcr.go @@ -7,12 +7,14 @@ import ( "encoding/json" "log/slog" "net/http" + "slices" "strings" "time" "github.com/google/uuid" "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // maxDCRBodySize is the maximum allowed size for DCR request bodies (64KB). @@ -39,8 +41,10 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request return } - // Parse request body - var dcrReq registration.DCRRequest + // Parse request body. oauthproto.ScopeList.UnmarshalJSON handles both + // RFC 7591 wire formats for "scope" (space-delimited string or JSON + // array) so we accept either shape transparently here. + var dcrReq oauthproto.DynamicClientRegistrationRequest if err := json.NewDecoder(req.Body).Decode(&dcrReq); err != nil { writeDCRError(w, http.StatusBadRequest, ®istration.DCRError{ Error: registration.DCRErrorInvalidClientMetadata, @@ -57,12 +61,39 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request } // Validate requested scopes against server's supported scopes - scopes, dcrErr := registration.ValidateScopes(dcrReq.Scope, h.config.ScopesSupported) + scopes, dcrErr := registration.ValidateScopes(dcrReq.Scopes, h.config.ScopesSupported) if dcrErr != nil { writeDCRError(w, http.StatusBadRequest, dcrErr) return } + // Union with the operator-configured scope baseline. RFC 7591 §3.2.1 permits + // the AS to replace requested client metadata values during registration; we + // use that to expand the registered scope set so a client whose DCR request + // narrowed the scope field can still request the baseline at /oauth/authorize. + // h.config.BaselineClientScopes is validated at startup to be a subset of + // ScopesSupported, so the union is guaranteed to be a subset of advertised + // scopes. Operators should keep the baseline narrow (e.g. openid, + // offline_access) — every DCR-registered client gains the ability to request + // these scopes at /oauth/authorize regardless of what they registered with. + if len(h.config.BaselineClientScopes) > 0 { + effective := registration.UnionScopes(scopes, h.config.BaselineClientScopes) + if !slices.Equal(effective, scopes) { + // Baseline-driven expansion is the intended behavior whenever + // baseline_client_scopes is configured, so per-registration + // audit lives at Debug rather than Warn. Operator-visible + // signal that the baseline is in effect comes from a one-time + // Info log at server startup (NewAuthorizationServerConfig). + slog.Debug("DCR registered scope set expanded by baseline_client_scopes", + "client_name", validated.ClientName, + "requested", scopes, + "effective", effective, + "baseline", h.config.BaselineClientScopes, + ) + scopes = effective + } + } + // Generate client ID clientID := uuid.NewString() @@ -95,16 +126,42 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request return } - slog.Debug("registered new DCR client", + // Successful DCR registration is a normal operational event, not a + // long-running operation, so it logs at Debug to stay silent at INFO+. + // client_id, software_id, token_endpoint_auth_method, and scopes are + // public client metadata per RFC 7591 and not credentials. If audit + // signal is desired in future, the right home is a dedicated audit- + // log emission path rather than promoting this record to INFO. + // + // Note: the "issuer" attribute here identifies THIS server (the + // ToolHive-embedded AS that is performing the registration), not the + // upstream AS being registered against. That distinction is important + // when correlating these logs with the resolver's logs in + // pkg/auth/dcr/resolver.go, which use "issuer" to mean the + // upstream AS. The two uses live at opposite ends of the DCR flow. + // No "upstream" attribute is emitted because the /oauth/register + // endpoint has no upstream concept. + logAttrs := []any{ "client_id", clientID, - "client_name", validated.ClientName, - ) + "software_id", validated.SoftwareID, + "token_endpoint_auth_method", validated.TokenEndpointAuthMethod, + "scopes", scopes, + } + if issuer := h.issuer(); issuer != "" { + logAttrs = append(logAttrs, "issuer", issuer) + } + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("registered new DCR client", logAttrs...) // Build response per RFC 7591 Section 3.2.1. - // Scope reflects the scopes actually granted to this client (from - // ValidateScopes above), not all server-supported scopes. This lets - // the client know exactly which scopes it can request. - response := registration.DCRResponse{ + // Scopes reflects the scopes actually granted to this client: the + // client-supplied scope set was validated against ScopesSupported by + // ValidateScopes above, then (if configured) unioned with + // BaselineClientScopes — which is itself guaranteed by startup-time + // validation to be a subset of ScopesSupported. The unioned set is NOT + // re-validated here. ScopeList.MarshalJSON emits the RFC 7591 §2 + // space-delimited wire form on the way out. + response := oauthproto.DynamicClientRegistrationResponse{ ClientID: clientID, ClientIDIssuedAt: time.Now().Unix(), RedirectURIs: validated.RedirectURIs, @@ -112,7 +169,7 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request TokenEndpointAuthMethod: validated.TokenEndpointAuthMethod, GrantTypes: validated.GrantTypes, ResponseTypes: validated.ResponseTypes, - Scope: registration.FormatScopes(scopes), + Scopes: oauthproto.ScopeList(scopes), } w.Header().Set("Content-Type", "application/json") diff --git a/pkg/authserver/server/handlers/dcr_test.go b/pkg/authserver/server/handlers/dcr_test.go index 266322e4f1..2491b4f500 100644 --- a/pkg/authserver/server/handlers/dcr_test.go +++ b/pkg/authserver/server/handlers/dcr_test.go @@ -21,6 +21,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/storage/mocks" + "github.com/stacklok/toolhive/pkg/oauthproto" ) func TestRegisterClientHandler(t *testing.T) { @@ -36,7 +37,7 @@ func TestRegisterClientHandler(t *testing.T) { }{ { name: "success", - requestBody: registration.DCRRequest{ + requestBody: oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1:8080/callback"}, ClientName: "Test Client", }, @@ -50,7 +51,7 @@ func TestRegisterClientHandler(t *testing.T) { }, { name: "validation error propagated", - requestBody: registration.DCRRequest{ + requestBody: oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://example.com/callback"}, }, expectedStatus: http.StatusBadRequest, @@ -58,7 +59,7 @@ func TestRegisterClientHandler(t *testing.T) { }, { name: "storage failure returns 500", - requestBody: registration.DCRRequest{ + requestBody: oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1:8080/callback"}, }, storageErr: errors.New("disk full"), @@ -82,6 +83,7 @@ func TestRegisterClientHandler(t *testing.T) { stor := mocks.NewMockStorage(ctrl) stor.EXPECT().RegisterClient(gomock.Any(), gomock.Any()).Return(tc.storageErr).AnyTimes() cfg := &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, ScopesSupported: registration.DefaultScopes, } handler := &Handler{storage: stor, config: cfg} @@ -110,7 +112,7 @@ func TestRegisterClientHandler(t *testing.T) { assert.Contains(t, errResp.ErrorDescription, tc.expectedErrDesc) } } else { - var resp registration.DCRResponse + var resp oauthproto.DynamicClientRegistrationResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp.ClientID) assert.NotZero(t, resp.ClientIDIssuedAt) @@ -131,11 +133,12 @@ func TestRegisterClientHandler_ScopeInResponse(t *testing.T) { handler := &Handler{ storage: stor, config: &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, ScopesSupported: registration.DefaultScopes, }, } - reqBody, err := json.Marshal(registration.DCRRequest{ + reqBody, err := json.Marshal(oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1:8080/callback"}, }) require.NoError(t, err) @@ -147,12 +150,122 @@ func TestRegisterClientHandler_ScopeInResponse(t *testing.T) { handler.RegisterClientHandler(w, req) require.Equal(t, http.StatusCreated, w.Code) - var resp registration.DCRResponse + var resp oauthproto.DynamicClientRegistrationResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, registration.FormatScopes(registration.DefaultScopes), resp.Scope, + assert.Equal(t, []string(registration.DefaultScopes), []string(resp.Scopes), "DCR response should include granted scopes per RFC 7591 Section 3.2.1") } +func TestRegisterClientHandler_BaselineClientScopes(t *testing.T) { + t.Parallel() + + // DefaultScopes is ["openid","profile","email","offline_access"]. + // Tests build ScopesSupported from DefaultScopes plus any extra scopes + // required by the case. + + tests := []struct { + name string + requestScopes []string + baselineClientScopes []string + extraScopesSupported []string // appended to DefaultScopes in ScopesSupported + expectedScopes []string + }{ + { + // When scope is empty, ValidateScopes returns DefaultScopes. + // The baseline adds "custom:scope" (not in DefaultScopes), so the + // union expands the set: DefaultScopes + ["custom:scope"]. + name: "empty client scope with non-empty baseline adds baseline scope", + requestScopes: nil, + baselineClientScopes: []string{"custom:scope"}, + extraScopesSupported: []string{"custom:scope"}, + expectedScopes: append(append([]string{}, registration.DefaultScopes...), "custom:scope"), + }, + { + // Requested scopes already contain the baseline; no expansion occurs. + name: "baseline is subset of requested scopes no expansion", + requestScopes: []string{"openid", "profile", "email", "offline_access"}, + baselineClientScopes: []string{"openid"}, + extraScopesSupported: nil, + expectedScopes: []string{"openid", "profile", "email", "offline_access"}, + }, + { + // Partial overlap: baseline shares "openid" with the request but adds + // "offline_access" not in the request. Exercises the dedup+append paths + // of unionScopes in the same handler call. + name: "partial overlap baseline appends only non-overlapping scopes", + requestScopes: []string{"openid", "profile"}, + baselineClientScopes: []string{"openid", "offline_access"}, + extraScopesSupported: nil, + expectedScopes: []string{"openid", "profile", "offline_access"}, + }, + { + // Canonical regression: client registers with "openid" only, + // baseline adds "offline_access" → union is both. + name: "disjoint baseline expands registered scope set", + requestScopes: []string{"openid"}, + baselineClientScopes: []string{"offline_access"}, + extraScopesSupported: nil, + expectedScopes: []string{"openid", "offline_access"}, + }, + { + // Nil baseline must not alter the registered scope set. + name: "nil baseline preserves existing behavior", + requestScopes: []string{"openid"}, + baselineClientScopes: nil, + extraScopesSupported: nil, + expectedScopes: []string{"openid"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stor := mocks.NewMockStorage(ctrl) + var capturedClient fosite.Client + stor.EXPECT().RegisterClient(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, c fosite.Client) error { + capturedClient = c + return nil + }) + + // Defensive copy of DefaultScopes (a package-level var) before extending, + // so per-case extraScopesSupported never mutates the global. + scopesSupported := append(append([]string{}, registration.DefaultScopes...), tc.extraScopesSupported...) + cfg := &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, + ScopesSupported: scopesSupported, + BaselineClientScopes: tc.baselineClientScopes, + } + handler := &Handler{storage: stor, config: cfg} + + reqBody, err := json.Marshal(oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1:8080/callback"}, + Scopes: oauthproto.ScopeList(tc.requestScopes), + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.RegisterClientHandler(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + + var resp oauthproto.DynamicClientRegistrationResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, tc.expectedScopes, []string(resp.Scopes), + "DCR response scope must equal the union of requested and baseline scopes") + + require.NotNil(t, capturedClient, "storage was not called") + assert.Equal(t, fosite.Arguments(tc.expectedScopes), capturedClient.GetScopes(), + "the union of requested and baseline scopes must reach storage") + }) + } +} + func TestRegisterClientHandler_ClientIsStored(t *testing.T) { t.Parallel() @@ -167,12 +280,13 @@ func TestRegisterClientHandler_ClientIsStored(t *testing.T) { allowedAudiences := []string{"https://mcp.example.com"} cfg := &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, ScopesSupported: registration.DefaultScopes, AllowedAudiences: allowedAudiences, } handler := &Handler{storage: stor, config: cfg} - reqBody, err := json.Marshal(registration.DCRRequest{ + reqBody, err := json.Marshal(oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1:8080/callback"}, ClientName: "Stored Client", }) @@ -185,7 +299,7 @@ func TestRegisterClientHandler_ClientIsStored(t *testing.T) { handler.RegisterClientHandler(w, req) require.Equal(t, http.StatusCreated, w.Code) - var resp registration.DCRResponse + var resp oauthproto.DynamicClientRegistrationResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.NotNil(t, storedClient) @@ -198,3 +312,108 @@ func TestRegisterClientHandler_ClientIsStored(t *testing.T) { assert.Equal(t, fosite.Arguments(allowedAudiences), loopbackClient.GetAudience(), "DCR client must inherit server's AllowedAudiences so refresh token requests with resource= succeed") } + +// TestRegisterClientHandler_ScopeAsJSONArray verifies that the /oauth/register +// endpoint accepts the RFC 7591 array form of "scope". Prior to consolidating +// onto oauthproto.ScopeList, the handler only accepted space-delimited strings +// and would reject these bodies as a JSON decode error. +// +// Each case sends a raw JSON body (not a Go literal that round-trips through +// ScopeList.MarshalJSON) so the dual-format UnmarshalJSON path is exercised +// end-to-end at the handler boundary. +func TestRegisterClientHandler_ScopeAsJSONArray(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + expectedStatus int + expectedScopes []string // ignored when expectedStatus != 201 + expectedWireFrag string // ignored when expectedStatus != 201 + expectedError string // DCR error code when expectedStatus != 201 + }{ + { + name: "happy path: array form is accepted and granted", + body: `{"redirect_uris":["http://127.0.0.1:8080/callback"],"scope":["openid","offline_access"]}`, + expectedStatus: http.StatusCreated, + expectedScopes: []string{"openid", "offline_access"}, + expectedWireFrag: `"scope":"openid offline_access"`, + }, + { + // Mirrors the string-form unsupported-scope path from + // TestValidateScopes ("unknown scope rejected") at the handler + // boundary, but routed through the array-form decoder. + name: "array form with unsupported scope is rejected", + body: `{"redirect_uris":["http://127.0.0.1:8080/callback"],"scope":["openid","sneaky_admin"]}`, + expectedStatus: http.StatusBadRequest, + expectedError: registration.DCRErrorInvalidClientMetadata, + }, + { + // Empty-array case. ScopeList.UnmarshalJSON normalizes [] to + // nil, ValidateScopes then falls back to DefaultScopes. + // Documented intentional behavior change: pre-consolidation the + // authserver returned 400 for this body because the + // space-delimited-only decoder could not consume a JSON array. + name: "empty array falls back to DefaultScopes", + body: `{"redirect_uris":["http://127.0.0.1:8080/callback"],"scope":[]}`, + expectedStatus: http.StatusCreated, + expectedScopes: registration.DefaultScopes, + expectedWireFrag: `"scope":"openid profile email offline_access"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stor := mocks.NewMockStorage(ctrl) + var capturedClient fosite.Client + stor.EXPECT().RegisterClient(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, c fosite.Client) error { + capturedClient = c + return nil + }).AnyTimes() + + handler := &Handler{ + storage: stor, + config: &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, + ScopesSupported: registration.DefaultScopes, + }, + } + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", bytes.NewReader([]byte(tc.body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.RegisterClientHandler(w, req) + + require.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedStatus != http.StatusCreated { + var errResp registration.DCRError + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Equal(t, tc.expectedError, errResp.Error) + return + } + + // Pin the response wire form: RFC 7591 §3.2.1 requires a + // space-delimited scope string, not a JSON array. A raw-body + // assertion catches a regression that the dual-format + // ScopeList.UnmarshalJSON would otherwise mask when decoding + // resp.Scopes back into a slice. + assert.Contains(t, w.Body.String(), tc.expectedWireFrag, + "RFC 7591 §3.2.1: response scope must be space-delimited string, not JSON array") + + var resp oauthproto.DynamicClientRegistrationResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, tc.expectedScopes, []string(resp.Scopes), + "granted scopes must reflect the array-form request") + + require.NotNil(t, capturedClient, "storage was not called") + assert.Equal(t, fosite.Arguments(tc.expectedScopes), capturedClient.GetScopes(), + "the array-form scopes must reach storage") + }) + } +} diff --git a/pkg/authserver/server/handlers/discovery.go b/pkg/authserver/server/handlers/discovery.go index 5db12682b9..0ae8cc9a13 100644 --- a/pkg/authserver/server/handlers/discovery.go +++ b/pkg/authserver/server/handlers/discovery.go @@ -23,7 +23,7 @@ import ( "github.com/ory/fosite" "github.com/stacklok/toolhive/pkg/authserver/server/crypto" - sharedobauth "github.com/stacklok/toolhive/pkg/oauth" + sharedobauth "github.com/stacklok/toolhive/pkg/oauthproto" ) // Cache-Control max-age values for discovery endpoints. @@ -114,6 +114,13 @@ func (h *Handler) buildOAuthMetadata() sharedobauth.AuthorizationServerMetadata }, CodeChallengeMethodsSupported: []string{crypto.PKCEChallengeMethodS256}, TokenEndpointAuthMethodsSupported: []string{sharedobauth.TokenEndpointAuthMethodNone}, + + // ClientIDMetadataDocumentSupported is defined in the CIMD draft as an + // OAuth AS metadata field (RFC 8414), not in OIDC Discovery 1.0. It is + // included here because MCP clients (e.g. VS Code) discover the AS via + // /.well-known/openid-configuration and need this flag there to activate + // CIMD. Spec-compliant OIDC consumers silently ignore unknown fields. + ClientIDMetadataDocumentSupported: h.config.CIMDEnabled, } } diff --git a/pkg/authserver/server/handlers/handler.go b/pkg/authserver/server/handlers/handler.go index ab04fbd91b..6727bc6f03 100644 --- a/pkg/authserver/server/handlers/handler.go +++ b/pkg/authserver/server/handlers/handler.go @@ -47,13 +47,20 @@ type Handler struct { // upstreams defines the ordered sequence of upstream providers consulted // during multi-upstream authorization flows (e.g., sequential token acquisition). // -// Returns an error if upstreams is empty or if any entry has an empty name or nil provider. +// Returns an error if config is nil, if config's embedded *fosite.Config is +// nil, if upstreams is empty, or if any entry has an empty name or nil +// provider. Catching misconfiguration here is far easier to diagnose than +// a nil-deref panic deep inside an HTTP handler at request time. func NewHandler( provider fosite.OAuth2Provider, config *server.AuthorizationServerConfig, stor storage.Storage, upstreams []NamedUpstream, ) (*Handler, error) { + if config == nil || config.Config == nil { + return nil, fmt.Errorf( + "handlers: AuthorizationServerConfig with embedded *fosite.Config must be non-nil") + } if len(upstreams) == 0 { return nil, fmt.Errorf("handlers: upstreams must not be empty") } @@ -136,3 +143,10 @@ func (h *Handler) upstreamByName(name string) (upstream.OAuth2Provider, bool) { } return nil, false } + +// issuer returns the authorization-server issuer URL. NewHandler enforces +// that h.config and h.config.Config are non-nil, so this method does not +// re-validate. +func (h *Handler) issuer() string { + return h.config.AccessTokenIssuer +} diff --git a/pkg/authserver/server/handlers/handler_chain_test.go b/pkg/authserver/server/handlers/handler_chain_test.go index b2e04cf6dc..9d0832466b 100644 --- a/pkg/authserver/server/handlers/handler_chain_test.go +++ b/pkg/authserver/server/handlers/handler_chain_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/ory/fosite" "github.com/ory/fosite/compose" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,3 +150,37 @@ func TestNextMissingUpstream_StorageError(t *testing.T) { assert.ErrorIs(t, err, storageErr) assert.Empty(t, got) } + +func TestHandler_issuer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *server.AuthorizationServerConfig + expected string + }{ + { + name: "returns configured AccessTokenIssuer", + config: &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://as.example.com"}, + }, + expected: "https://as.example.com", + }, + { + name: "returns empty string when AccessTokenIssuer is unset", + config: &server.AuthorizationServerConfig{ + Config: &fosite.Config{}, + }, + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := &Handler{config: tc.config} + assert.Equal(t, tc.expected, h.issuer()) + }) + } +} diff --git a/pkg/authserver/server/handlers/handlers_test.go b/pkg/authserver/server/handlers/handlers_test.go index 2e2d14729b..5b99c4a8eb 100644 --- a/pkg/authserver/server/handlers/handlers_test.go +++ b/pkg/authserver/server/handlers/handlers_test.go @@ -32,12 +32,13 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/storage/mocks" - sharedobauth "github.com/stacklok/toolhive/pkg/oauth" + sharedobauth "github.com/stacklok/toolhive/pkg/oauthproto" ) // testSetupOptions allows customizing the test handler setup. type testSetupOptions struct { AuthorizationEndpointBaseURL string + CIMDEnabled bool } // testSetup creates a Handler with all dependencies for testing. @@ -66,6 +67,7 @@ func testSetupWithOptions(t *testing.T, opts testSetupOptions) *Handler { cfg := &server.AuthorizationServerParams{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: opts.AuthorizationEndpointBaseURL, + CIMDEnabled: opts.CIMDEnabled, AccessTokenLifespan: time.Hour, RefreshTokenLifespan: time.Hour * 24, AuthCodeLifespan: time.Minute * 10, @@ -340,3 +342,61 @@ func TestWellKnownRoutes(t *testing.T) { } // TODO: Add TestOAuthRoutes once OAuth handlers are implemented + +func TestDiscoveryHandlers_CIMDEnabled_AdvertisesSupport(t *testing.T) { + t.Parallel() + + handler := testSetupWithOptions(t, testSetupOptions{CIMDEnabled: true}) + + for _, tc := range []struct { + name string + fn func(http.ResponseWriter, *http.Request) + }{ + {"OAuth AS metadata", handler.OAuthDiscoveryHandler}, + {"OIDC discovery", handler.OIDCDiscoveryHandler}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + tc.fn(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var meta sharedobauth.AuthorizationServerMetadata + require.NoError(t, json.NewDecoder(rec.Body).Decode(&meta)) + assert.True(t, meta.ClientIDMetadataDocumentSupported, + "client_id_metadata_document_supported must be true when CIMD is enabled") + }) + } +} + +func TestDiscoveryHandlers_CIMDDisabled_OmitsFlag(t *testing.T) { + t.Parallel() + + handler := testSetupWithOptions(t, testSetupOptions{CIMDEnabled: false}) + + for _, tc := range []struct { + name string + fn func(http.ResponseWriter, *http.Request) + }{ + {"OAuth AS metadata", handler.OAuthDiscoveryHandler}, + {"OIDC discovery", handler.OIDCDiscoveryHandler}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + tc.fn(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + // Decode to raw map to verify the field is truly absent from the JSON + // output (omitempty), not merely false — a struct decode cannot + // distinguish between an omitted field and an explicit false value. + var raw map[string]interface{} + require.NoError(t, json.NewDecoder(rec.Body).Decode(&raw)) + _, present := raw["client_id_metadata_document_supported"] + assert.False(t, present, + "client_id_metadata_document_supported must be omitted from JSON when CIMD is disabled") + }) + } +} diff --git a/pkg/authserver/server/handlers/helpers_test.go b/pkg/authserver/server/handlers/helpers_test.go index 66745ca131..8195890761 100644 --- a/pkg/authserver/server/handlers/helpers_test.go +++ b/pkg/authserver/server/handlers/helpers_test.go @@ -91,13 +91,15 @@ type testStorageState struct { authCodeSessions map[string]fosite.Requester // authorize code sessions for token exchange pkceSessions map[string]fosite.Requester // PKCE sessions for token exchange idpTokenCount int + renewedClients []string // client IDs passed to RenewClientTTL } // baseTestSetupOption configures optional behavior overrides for baseTestSetup. type baseTestSetupOption func(*baseTestSetupConfig) type baseTestSetupConfig struct { - storePendingErr error // if non-nil, StorePendingAuthorization always returns this error + storePendingErr error // if non-nil, StorePendingAuthorization always returns this error + getLatestUpstreamTokensErr error // if non-nil, GetLatestUpstreamTokensForUser always returns this error } func withStorePendingError(err error) baseTestSetupOption { @@ -106,6 +108,12 @@ func withStorePendingError(err error) baseTestSetupOption { } } +func withGetLatestUpstreamTokensError(err error) baseTestSetupOption { + return func(c *baseTestSetupConfig) { + c.getLatestUpstreamTokensErr = err + } +} + // baseTestSetup creates the shared test infrastructure (RSA keys, fosite provider, mock storage // with all expectations wired, including upstream token mocks). Callers create the Handler. func baseTestSetup(t *testing.T, opts ...baseTestSetupOption) (fosite.OAuth2Provider, *server.AuthorizationServerConfig, *mocks.MockStorage, *testStorageState) { @@ -178,6 +186,14 @@ func baseTestSetup(t *testing.T, opts ...baseTestSetupOption) (fosite.OAuth2Prov }).AnyTimes() stor.EXPECT().GetClient(gomock.Any(), gomock.Not(testAuthClientID)).Return(nil, fosite.ErrNotFound).AnyTimes() + // Token issuance renews the public client's registration TTL (best-effort). + // Record the calls so tests can assert the renewal fired on success. + stor.EXPECT().RenewClientTTL(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, c fosite.Client) error { + storState.renewedClients = append(storState.renewedClients, c.GetID()) + return nil + }).AnyTimes() + // Setup mock expectations for pending authorization storage if setupCfg.storePendingErr != nil { // StorePendingAuthorization always fails with the configured error @@ -346,6 +362,28 @@ func baseTestSetup(t *testing.T, opts ...baseTestSetupOption) (fosite.OAuth2Prov return result, nil }).AnyTimes() + stor.EXPECT(). + GetLatestUpstreamTokensForUser(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, userID, providerID string) (*storage.UpstreamTokens, error) { + if setupCfg.getLatestUpstreamTokensErr != nil { + return nil, setupCfg.getLatestUpstreamTokensErr + } + var winner *storage.UpstreamTokens + for _, t := range storState.upstreamTokens { + if t == nil || t.UserID != userID || t.ProviderID != providerID { + continue + } + if winner == nil || t.ExpiresAt.After(winner.ExpiresAt) { + winner = t + } + } + if winner == nil { + return nil, storage.ErrNotFound + } + return winner, nil + }). + AnyTimes() + // Create fosite provider with authorization code support jwtStrategy := compose.NewOAuth2JWTStrategy( func(_ context.Context) (any, error) { @@ -368,10 +406,11 @@ func baseTestSetup(t *testing.T, opts ...baseTestSetupOption) (fosite.OAuth2Prov } // handlerTestSetup creates a test setup with all dependencies including an upstream provider. -func handlerTestSetup(t *testing.T) (*Handler, *testStorageState, *mockIDPProvider) { +// Any baseTestSetupOption values are forwarded to baseTestSetup. +func handlerTestSetup(t *testing.T, opts ...baseTestSetupOption) (*Handler, *testStorageState, *mockIDPProvider) { t.Helper() - provider, oauth2Config, stor, storState := baseTestSetup(t) + provider, oauth2Config, stor, storState := baseTestSetup(t, opts...) mockUpstream := &mockIDPProvider{ providerType: upstream.ProviderTypeOAuth2, diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 420a731f9c..46efc43ab3 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -100,6 +100,20 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { return } + // Renew the registration TTL for public (DCR) clients on a successful token + // exchange/refresh. This is the proven-use signal — unlike the unauthenticated + // /oauth/authorize client read — so an actively-used public client is not evicted + // mid-lifecycle and forced to re-register. Best-effort: a renewal failure must not + // fail the token that was just issued. Storage renews only public clients. + if client := accessRequest.GetClient(); client != nil { + if err := h.storage.RenewClientTTL(ctx, client); err != nil { + slog.Warn("failed to renew client registration TTL", + "client_id", client.GetID(), + "error", err, + ) + } + } + // Write the token response h.provider.WriteAccessResponse(ctx, w, accessRequest, response) } diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index 74a793c11a..23f923faa9 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -255,6 +255,35 @@ func TestTokenHandler_Success(t *testing.T) { assert.Contains(t, body, "expires_in") } +// TestTokenHandler_Success_RenewsClientTTL asserts that a successful token +// exchange renews the registration TTL of the public client — the proven-use +// signal that keeps an active client from being evicted and forced to +// re-register. Renewal must run on the token path, not the unauthenticated +// authorize read. +func TestTokenHandler_Success_RenewsClientTTL(t *testing.T) { + t.Parallel() + handler, storState, _ := handlerTestSetup(t) + + authorizeCode := simulateAuthorizeFlow(t, handler, storState) + + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {testAuthClientID}, + "redirect_uri": {testAuthRedirectURI}, + "code": {authorizeCode}, + "code_verifier": {testPKCEVerifier}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "got %d: %s", rec.Code, rec.Body.String()) + assert.Contains(t, storState.renewedClients, testAuthClientID, + "successful token exchange should renew the public client's registration TTL") +} + func TestTokenHandler_AudienceClaim(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/provider.go b/pkg/authserver/server/provider.go index 5eb5acee34..92dac49960 100644 --- a/pkg/authserver/server/provider.go +++ b/pkg/authserver/server/provider.go @@ -18,6 +18,7 @@ import ( "context" "crypto" "fmt" + "log/slog" "net/url" "strings" "time" @@ -26,6 +27,7 @@ import ( "github.com/ory/fosite" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" ) // Token lifespan bounds for validation. @@ -58,9 +60,16 @@ type AuthorizationServerConfig struct { // This is advertised in /.well-known/openid-configuration and // /.well-known/oauth-authorization-server discovery endpoints. ScopesSupported []string + // BaselineClientScopes is a baseline set of OAuth 2.0 scopes the DCR handler + // unions into every newly registered client's scope set. All entries are + // guaranteed to be a subset of ScopesSupported. + BaselineClientScopes []string // AuthorizationEndpointBaseURL overrides the base URL for the authorization_endpoint // in the discovery document. When empty, defaults to the issuer (AccessTokenIssuer). AuthorizationEndpointBaseURL string + // CIMDEnabled indicates that the CIMD storage decorator is active. When true, + // the discovery document advertises client_id_metadata_document_supported. + CIMDEnabled bool } // Factory is a constructor which is used to create an OAuth2 endpoint handler. @@ -89,9 +98,16 @@ type AuthorizationServerParams struct { AllowedAudiences []string // ScopesSupported lists the OAuth 2.0 scope values advertised in discovery documents. ScopesSupported []string + // BaselineClientScopes is a baseline set of OAuth 2.0 scopes the DCR handler + // unions into every newly registered client's scope set. All entries are + // guaranteed to be a subset of ScopesSupported. + BaselineClientScopes []string // AuthorizationEndpointBaseURL overrides the base URL for the authorization_endpoint // in the discovery document. When empty, defaults to Issuer. AuthorizationEndpointBaseURL string + // CIMDEnabled indicates that the CIMD storage decorator is active. When true, + // the discovery document advertises client_id_metadata_document_supported. + CIMDEnabled bool } // validateIssuerURL validates that the issuer is a valid URL with http or https scheme @@ -189,7 +205,14 @@ func validateParams(cfg *AuthorizationServerParams) error { return fmt.Errorf("authorization endpoint base URL: %w", err) } } - return validateAllowedAudiences(cfg.AllowedAudiences) + if err := validateAllowedAudiences(cfg.AllowedAudiences); err != nil { + return err + } + // Defense-in-depth: re-check the baseline-⊆-scopes_supported invariant. + // RunConfig.Validate performs the same check at the operator-supplied + // wire-format boundary; this gate covers callers that construct + // AuthorizationServerParams programmatically and bypass that path. + return registration.ValidateScopeSubset(cfg.BaselineClientScopes, cfg.ScopesSupported, "baseline_client_scopes") } // NewAuthorizationServerConfig creates an AuthorizationServerConfig from the provided configuration. @@ -201,6 +224,12 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio return nil, err } + if len(cfg.BaselineClientScopes) > 0 { + slog.Info("DCR registrations will be auto-granted baseline scopes", + "scopes", cfg.BaselineClientScopes, + ) + } + // Build JWK from signing key jwk := jose.JSONWebKey{ Key: cfg.SigningKey, @@ -231,7 +260,9 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio SigningJWKS: &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}, AllowedAudiences: cfg.AllowedAudiences, ScopesSupported: cfg.ScopesSupported, + BaselineClientScopes: cfg.BaselineClientScopes, AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL, + CIMDEnabled: cfg.CIMDEnabled, }, nil } diff --git a/pkg/authserver/server/provider_test.go b/pkg/authserver/server/provider_test.go index 02482fa34d..d633d412ee 100644 --- a/pkg/authserver/server/provider_test.go +++ b/pkg/authserver/server/provider_test.go @@ -311,6 +311,26 @@ func TestNewAuthorizationServerConfig_InvalidConfig(t *testing.T) { }, wantErr: "rotated HMAC secret [1] must be at least 32 bytes", }, + { + // Defense-in-depth gate: a direct caller of NewAuthorizationServerConfig + // that bypasses RunConfig.Validate must still be rejected when + // BaselineClientScopes contains a scope outside ScopesSupported. + // Without this case, only the operator-YAML path is regression-protected. + name: "baseline scope not in scopes_supported", + params: &AuthorizationServerParams{ + Issuer: "https://auth.example.com", + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: time.Hour * 24, + AuthCodeLifespan: time.Minute * 10, + HMACSecrets: servercrypto.NewHMACSecrets([]byte("test-secret-with-32-bytes-long!!")), + SigningKeyID: "key-1", + SigningKeyAlgorithm: "RS256", + SigningKey: rsaKey, + ScopesSupported: []string{"openid"}, + BaselineClientScopes: []string{"offline_access"}, + }, + wantErr: `baseline_client_scopes contains "offline_access"`, + }, } for _, tt := range tests { diff --git a/pkg/authserver/server/registration/client.go b/pkg/authserver/server/registration/client.go index 4f72a8582c..d6cdcf39a5 100644 --- a/pkg/authserver/server/registration/client.go +++ b/pkg/authserver/server/registration/client.go @@ -43,12 +43,14 @@ import ( // native apps that register redirect URIs like "http://localhost/callback" and then // request authorization with dynamic ports like "http://localhost:57403/callback". type LoopbackClient struct { - *fosite.DefaultClient + *fosite.DefaultOpenIDConnectClient } -// NewLoopbackClient creates a new LoopbackClient wrapping the provided DefaultClient. -func NewLoopbackClient(client *fosite.DefaultClient) *LoopbackClient { - return &LoopbackClient{DefaultClient: client} +// NewLoopbackClient creates a new LoopbackClient wrapping the provided client. +// The wrapper preserves all OIDC fields (including TokenEndpointAuthMethod) +// while adding RFC 8252 §7.3 dynamic port matching for loopback redirect URIs. +func NewLoopbackClient(client *fosite.DefaultOpenIDConnectClient) *LoopbackClient { + return &LoopbackClient{DefaultOpenIDConnectClient: client} } // MatchRedirectURI checks if the given redirect URI matches one of the client's @@ -167,8 +169,14 @@ func New(cfg Config) (fosite.Client, error) { // Wrap public clients in LoopbackClient for RFC 8252 Section 7.3 // dynamic port matching for native app loopback redirect URIs. + // Use DefaultOpenIDConnectClient so TokenEndpointAuthMethod ("none" for + // public clients) is preserved through the LoopbackClient wrapper. if cfg.Public { - return NewLoopbackClient(defaultClient), nil + oidcClient := &fosite.DefaultOpenIDConnectClient{ + DefaultClient: defaultClient, + TokenEndpointAuthMethod: "none", + } + return NewLoopbackClient(oidcClient), nil } return defaultClient, nil diff --git a/pkg/authserver/server/registration/client_test.go b/pkg/authserver/server/registration/client_test.go index b536eb50a6..37f6280006 100644 --- a/pkg/authserver/server/registration/client_test.go +++ b/pkg/authserver/server/registration/client_test.go @@ -32,7 +32,7 @@ func TestNewLoopbackClient(t *testing.T) { Public: true, } - client := NewLoopbackClient(defaultClient) + client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{DefaultClient: defaultClient}) assert.NotNil(t, client) assert.Equal(t, "test-client", client.GetID()) @@ -196,10 +196,12 @@ func TestLoopbackClient_MatchRedirectURI(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - client := NewLoopbackClient(&fosite.DefaultClient{ - ID: "test-client", - RedirectURIs: tt.registeredURIs, - Public: true, + client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{ + DefaultClient: &fosite.DefaultClient{ + ID: "test-client", + RedirectURIs: tt.registeredURIs, + Public: true, + }, }) result := client.MatchRedirectURI(tt.requestedURI) @@ -247,10 +249,12 @@ func TestLoopbackClient_GetMatchingRedirectURI(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - client := NewLoopbackClient(&fosite.DefaultClient{ - ID: "test-client", - RedirectURIs: tt.registeredURIs, - Public: true, + client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{ + DefaultClient: &fosite.DefaultClient{ + ID: "test-client", + RedirectURIs: tt.registeredURIs, + Public: true, + }, }) result := client.GetMatchingRedirectURI(tt.requestedURI) diff --git a/pkg/authserver/server/registration/dcr.go b/pkg/authserver/server/registration/dcr.go index 5b1308e652..80a43b2d1c 100644 --- a/pkg/authserver/server/registration/dcr.go +++ b/pkg/authserver/server/registration/dcr.go @@ -18,12 +18,39 @@ package registration import ( + "fmt" "slices" - "strings" - "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) +// ValidateScopeSubset checks that every scope in subset is also present in +// superset, returning an error that names fieldName and the offending scope. +// +// Shared across the layers that validate baseline-scope configuration so the +// error message format is identical wherever the violation is caught (a +// caller using YAML-loaded config and a caller constructing config +// programmatically both see the same wording). +// +// fieldName should be the wire-format or display name of the field being +// validated (e.g. "baseline_client_scopes"). It is embedded verbatim in the +// returned error. +func ValidateScopeSubset(subset, superset []string, fieldName string) error { + if len(subset) == 0 { + return nil + } + supported := make(map[string]bool, len(superset)) + for _, s := range superset { + supported[s] = true + } + for _, s := range subset { + if !supported[s] { + return fmt.Errorf("%s contains %q which is not in scopes_supported", fieldName, s) + } + } + return nil +} + // DCR error codes per RFC 7591 Section 3.2.2 const ( // DCRErrorInvalidRedirectURI indicates that the value of one or more @@ -42,65 +69,13 @@ const ( // MaxClientNameLength is the maximum allowed length for a client name. MaxClientNameLength = 256 -) - -// DCRRequest represents an OAuth 2.0 Dynamic Client Registration request -// per RFC 7591 Section 2. -type DCRRequest struct { - // RedirectURIs is an array of redirection URIs for the client. - // Required for public clients. - RedirectURIs []string `json:"redirect_uris"` - - // ClientName is a human-readable name for the client. - ClientName string `json:"client_name,omitempty"` - - // TokenEndpointAuthMethod is the requested authentication method for the token endpoint. - // For public clients, this must be "none". - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - // GrantTypes is an array of OAuth 2.0 grant types the client may use. - // Defaults to ["authorization_code"] if not specified. - GrantTypes []string `json:"grant_types,omitempty"` - - // ResponseTypes is an array of OAuth 2.0 response types the client may use. - // Defaults to ["code"] if not specified. - ResponseTypes []string `json:"response_types,omitempty"` - - // Scope is a space-separated list of OAuth 2.0 scope values the client may use. - // If not specified, defaults to the server's default scopes. - // All requested scopes must be supported by the server. - Scope string `json:"scope,omitempty"` -} - -// DCRResponse represents a successful OAuth 2.0 Dynamic Client Registration -// response per RFC 7591 Section 3.2.1. -type DCRResponse struct { - // ClientID is the unique identifier for the client. - ClientID string `json:"client_id"` - - // ClientIDIssuedAt is the time at which the client identifier was issued, - // as a Unix timestamp. - ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` - - // RedirectURIs is an array of redirection URIs for the client. - RedirectURIs []string `json:"redirect_uris"` - - // ClientName is a human-readable name for the client. - ClientName string `json:"client_name,omitempty"` - - // TokenEndpointAuthMethod is the authentication method for the token endpoint. - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - - // GrantTypes is an array of OAuth 2.0 grant types the client may use. - GrantTypes []string `json:"grant_types"` - - // ResponseTypes is an array of OAuth 2.0 response types the client may use. - ResponseTypes []string `json:"response_types"` - - // Scope is a space-separated list of OAuth 2.0 scope values the client may use. - // Per RFC 7591 Section 3.2, this tells the client what scopes it was granted. - Scope string `json:"scope,omitempty"` -} + // MaxSoftwareIDLength is the maximum allowed length for a software_id + // value. RFC 7591 does not mandate an upper bound, so we reuse the + // client_name cap for consistency — a software_id is a similar-purpose + // human-oriented identifier and the same ballpark DoS concerns apply. + MaxSoftwareIDLength = 256 +) // DCRError represents an OAuth 2.0 Dynamic Client Registration error // response per RFC 7591 Section 3.2.2. @@ -132,7 +107,13 @@ var allowedResponseTypes = map[string]bool{ // ValidateDCRRequest validates a DCR request according to RFC 7591 // and the server's security policy (loopback-only public clients). // Returns the validated request with defaults applied, or an error. -func ValidateDCRRequest(req *DCRRequest) (*DCRRequest, *DCRError) { +// +// The validated request does NOT carry the requested scopes — scope +// validation against the server's supported set is a separate step, +// handled by ValidateScopes using the caller's policy inputs. +func ValidateDCRRequest( + req *oauthproto.DynamicClientRegistrationRequest, +) (*oauthproto.DynamicClientRegistrationRequest, *DCRError) { // 1. Validate redirect_uris - required if len(req.RedirectURIs) == 0 { return nil, &DCRError{ @@ -164,6 +145,15 @@ func ValidateDCRRequest(req *DCRRequest) (*DCRRequest, *DCRError) { } } + // 4a. Validate software_id: length cap + printable-ASCII charset. + // RFC 7591 does not mandate an upper bound or a character class for + // software_id, but since we capture the value in audit logs we want a + // predictable shape and a hard cap against DoS — a caller sending + // multi-MB strings would slip past only the 64 KiB body cap otherwise. + if dcrErr := validateSoftwareID(req.SoftwareID); dcrErr != nil { + return nil, dcrErr + } + // 5. Validate/default token_endpoint_auth_method authMethod := req.TokenEndpointAuthMethod if authMethod == "" { @@ -189,15 +179,44 @@ func ValidateDCRRequest(req *DCRRequest) (*DCRRequest, *DCRError) { } // Return validated request with defaults applied - return &DCRRequest{ + return &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: req.RedirectURIs, ClientName: req.ClientName, TokenEndpointAuthMethod: authMethod, GrantTypes: grantTypes, ResponseTypes: responseTypes, + SoftwareID: req.SoftwareID, }, nil } +// validateSoftwareID enforces the length cap and printable-ASCII charset +// for a DCR request's software_id. An empty value is accepted (the field +// is optional per RFC 7591). +func validateSoftwareID(softwareID string) *DCRError { + if softwareID == "" { + return nil + } + if len(softwareID) > MaxSoftwareIDLength { + return &DCRError{ + Error: DCRErrorInvalidClientMetadata, + ErrorDescription: "software_id too long (maximum 256 characters)", + } + } + // Allow printable ASCII (0x20..0x7E) only. Control characters and + // non-ASCII input are rejected: we log this value and want a + // predictable on-disk representation regardless of handler choice. + for i := 0; i < len(softwareID); i++ { + c := softwareID[i] + if c < 0x20 || c > 0x7E { + return &DCRError{ + Error: DCRErrorInvalidClientMetadata, + ErrorDescription: "software_id must contain only printable ASCII characters", + } + } + } + return nil +} + func validateGrantTypes(grantTypes []string) ([]string, *DCRError) { if len(grantTypes) == 0 { grantTypes = defaultGrantTypes @@ -249,7 +268,7 @@ func validateResponseTypes(responseTypes []string) ([]string, *DCRError) { // - HTTP is only allowed for loopback addresses (127.0.0.1, [::1], localhost) // - Private-use URI schemes (e.g., cursor://, vscode://) are allowed for native apps func ValidateRedirectURI(uri string) *DCRError { - if err := oauth.ValidateRedirectURI(uri, oauth.RedirectURIPolicyAllowPrivateSchemes); err != nil { + if err := oauthproto.ValidateRedirectURI(uri, oauthproto.RedirectURIPolicyAllowPrivateSchemes); err != nil { return &DCRError{ Error: DCRErrorInvalidRedirectURI, ErrorDescription: err.Error(), @@ -258,30 +277,31 @@ func ValidateRedirectURI(uri string) *DCRError { return nil } -// ValidateScopes validates that all requested scopes are in the allowed set. -// Returns the validated scopes (or defaults if empty) and any error. -// This enforces server-side scope restrictions per RFC 7591 Section 2. +// ValidateScopes validates a slice of already-parsed scope tokens against +// the server's allowed set per RFC 7591 §2. // -// When the client omits the scope field, RFC 7591 leaves scope selection to the -// server's defaults. ToolHive does not currently expose a separate "default -// scopes" setting, so if the server advertises an explicit supported-scope set -// we treat that as the default grant set for DCR. This keeps dynamic -// registration aligned with the protected-resource metadata and avoids hard -// failures when deployments intentionally narrow the supported scopes. -func ValidateScopes(requestedScope string, allowedScopes []string) ([]string, *DCRError) { +// - Each requested scope must appear in allowedScopes; otherwise returns +// invalid_client_metadata. +// - Duplicates in the input are tolerated and deduplicated per RFC 6749 +// §3.3 (scope is a set of case-sensitive strings). +// - Empty/nil input falls back to the server's defaults. DocPlanner fork: +// RFC 7591 leaves scope selection to the server when the client omits the +// scope field. ToolHive has no separate "default scopes" setting, so an +// explicit supported-scope set is treated as the default grant set; with no +// supported scopes configured, the built-in DefaultScopes are used. +func ValidateScopes(requestedScopes, allowedScopes []string) ([]string, *DCRError) { // Build allowed scope set for O(1) lookup allowed := make(map[string]bool, len(allowedScopes)) for _, s := range allowedScopes { allowed[s] = true } - // Parse space-separated scope string per RFC 6749 Section 3.3. - // Deduplicate to ensure each scope appears at most once (RFC 6749 - // defines scope as a set of case-sensitive strings). + // Deduplicate while validating each requested scope against the + // allowed set. var scopes []string - if requestedScope != "" { + if len(requestedScopes) > 0 { seen := make(map[string]bool) - for _, s := range strings.Fields(requestedScope) { + for _, s := range requestedScopes { if !allowed[s] { return nil, &DCRError{ Error: DCRErrorInvalidClientMetadata, @@ -328,7 +348,46 @@ func defaultScopesForServer(allowedScopes []string) []string { return defaults } -// FormatScopes formats a scope slice as a space-separated string. -func FormatScopes(scopes []string) string { - return strings.Join(scopes, " ") +// UnionScopes returns the union of requested and baseline scopes, preserving +// the order of requested first, then appending any baseline scopes not already +// present. Duplicates are removed. Returns nil when the result is empty. +// +// Both inputs must already be validated by the caller. UnionScopes does not +// filter empty strings or validate scope syntax — it only deduplicates and +// merges in stable order. +func UnionScopes(requested, baseline []string) []string { + seen := make(map[string]bool, len(requested)+len(baseline)) + out := make([]string, 0, len(requested)+len(baseline)) + for _, s := range requested { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + for _, s := range baseline { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// ValidatePublicGrantTypes validates the grant_types for a public OAuth client, +// applying the same rules as DCR: authorization_code must be present, and all +// declared values must be in the allowed set. Returns the validated slice (with +// defaults applied when nil/empty) or a *DCRError on violation. +func ValidatePublicGrantTypes(grantTypes []string) ([]string, *DCRError) { + return validateGrantTypes(grantTypes) +} + +// ValidatePublicResponseTypes validates the response_types for a public OAuth +// client, applying the same rules as DCR: code must be present and all declared +// values must be in the allowed set. Returns the validated slice (with defaults +// applied when nil/empty) or a *DCRError on violation. +func ValidatePublicResponseTypes(responseTypes []string) ([]string, *DCRError) { + return validateResponseTypes(responseTypes) } diff --git a/pkg/authserver/server/registration/dcr_test.go b/pkg/authserver/server/registration/dcr_test.go index 24e789cac3..06acb7fae7 100644 --- a/pkg/authserver/server/registration/dcr_test.go +++ b/pkg/authserver/server/registration/dcr_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) func TestValidateRedirectURI(t *testing.T) { @@ -115,7 +115,7 @@ func TestValidateRedirectURI(t *testing.T) { // Length validation { name: "redirect URI exceeding max length is rejected", - uri: "https://example.com/" + strings.Repeat("a", oauth.MaxRedirectURILength), + uri: "https://example.com/" + strings.Repeat("a", oauthproto.MaxRedirectURILength), expectError: true, errorCode: DCRErrorInvalidRedirectURI, }, @@ -142,7 +142,7 @@ func TestValidateDCRRequest(t *testing.T) { tests := []struct { name string - request *DCRRequest + request *oauthproto.DynamicClientRegistrationRequest expectError bool errorCode string expectedAuthMethod string @@ -152,7 +152,7 @@ func TestValidateDCRRequest(t *testing.T) { // Valid requests { name: "valid minimal request with loopback redirect URI", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, }, expectError: false, @@ -162,7 +162,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "valid request with all fields specified", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://localhost:8080/callback", "https://example.com/callback"}, ClientName: "My Test Client", TokenEndpointAuthMethod: "none", @@ -176,7 +176,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "valid request with https redirect URI", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"https://example.com/oauth/callback"}, }, expectError: false, @@ -188,7 +188,7 @@ func TestValidateDCRRequest(t *testing.T) { // Empty redirect_uris { name: "empty redirect_uris", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{}, }, expectError: true, @@ -196,7 +196,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "nil redirect_uris", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: nil, }, expectError: true, @@ -206,7 +206,7 @@ func TestValidateDCRRequest(t *testing.T) { // Too many redirect URIs { name: "too many redirect URIs", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{ "http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback", @@ -228,7 +228,7 @@ func TestValidateDCRRequest(t *testing.T) { // Invalid redirect URI in list { name: "invalid redirect URI in list", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback", "http://example.com/callback"}, }, expectError: true, @@ -236,7 +236,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "malformed redirect URI in list", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"://invalid"}, }, expectError: true, @@ -246,7 +246,7 @@ func TestValidateDCRRequest(t *testing.T) { // token_endpoint_auth_method validation { name: "token_endpoint_auth_method = none", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, TokenEndpointAuthMethod: "none", }, @@ -255,7 +255,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "token_endpoint_auth_method empty defaults to none", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, TokenEndpointAuthMethod: "", }, @@ -264,7 +264,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "token_endpoint_auth_method = client_secret_basic fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, TokenEndpointAuthMethod: "client_secret_basic", }, @@ -273,7 +273,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "token_endpoint_auth_method = client_secret_post fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, TokenEndpointAuthMethod: "client_secret_post", }, @@ -284,7 +284,7 @@ func TestValidateDCRRequest(t *testing.T) { // grant_types validation { name: "grant_types defaults when empty", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{}, }, @@ -293,7 +293,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "grant_types defaults when nil", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: nil, }, @@ -302,7 +302,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "grant_types without authorization_code fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{"refresh_token"}, }, @@ -311,7 +311,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "grant_types with only client_credentials fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{"client_credentials"}, }, @@ -320,7 +320,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "grant_types with authorization_code passes", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{"authorization_code"}, }, @@ -329,7 +329,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "grant_types with unsupported type rejected", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: []string{"authorization_code", "client_credentials"}, }, @@ -340,7 +340,7 @@ func TestValidateDCRRequest(t *testing.T) { // response_types validation { name: "response_types defaults when empty", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{}, }, @@ -349,7 +349,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "response_types defaults when nil", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: nil, }, @@ -358,7 +358,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "response_types without code fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"token"}, }, @@ -367,7 +367,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "response_types with only id_token fails", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"id_token"}, }, @@ -376,7 +376,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "response_types with code passes", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"code"}, }, @@ -385,7 +385,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "response_types with unsupported type rejected", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"code", "token"}, }, @@ -396,7 +396,7 @@ func TestValidateDCRRequest(t *testing.T) { // ClientName validation { name: "client_name is preserved", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ClientName: "My Application", }, @@ -404,7 +404,7 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "client_name exceeding max length is rejected", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ClientName: strings.Repeat("a", MaxClientNameLength+1), }, @@ -413,12 +413,65 @@ func TestValidateDCRRequest(t *testing.T) { }, { name: "client_name at max length is accepted", - request: &DCRRequest{ + request: &oauthproto.DynamicClientRegistrationRequest{ RedirectURIs: []string{"http://127.0.0.1/callback"}, ClientName: strings.Repeat("a", MaxClientNameLength), }, expectError: false, }, + + // software_id length cap and charset enforcement + { + name: "software_id at max length is accepted", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: strings.Repeat("a", MaxSoftwareIDLength), + }, + expectError: false, + }, + { + name: "software_id exceeding max length is rejected", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: strings.Repeat("a", MaxSoftwareIDLength+1), + }, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, + }, + { + name: "software_id with control character is rejected", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: "bad\x00id", + }, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, + }, + { + name: "software_id with non-ASCII character is rejected", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: "softwäre", + }, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, + }, + { + name: "empty software_id is accepted (field is optional)", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: "", + }, + expectError: false, + }, + { + name: "printable-ASCII software_id is accepted", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://127.0.0.1/callback"}, + SoftwareID: "example-app-v1.2.3", + }, + expectError: false, + }, } for _, tt := range tests { @@ -451,6 +504,9 @@ func TestValidateDCRRequest(t *testing.T) { // Verify client_name is preserved assert.Equal(t, tt.request.ClientName, result.ClientName) + + // Verify software_id is preserved + assert.Equal(t, tt.request.SoftwareID, result.SoftwareID) } }) } @@ -462,69 +518,69 @@ func TestValidateScopes(t *testing.T) { allowedScopes := []string{"openid", "profile", "email", "offline_access"} tests := []struct { - name string - requestedScope string - allowedScopes []string - expectError bool - errorCode string - expectedScopes []string + name string + requestedScopes []string + allowedScopes []string + expectError bool + errorCode string + expectedScopes []string }{ { - name: "valid subset of allowed scopes", - requestedScope: "openid profile", - allowedScopes: allowedScopes, - expectedScopes: []string{"openid", "profile"}, + name: "valid subset of allowed scopes", + requestedScopes: []string{"openid", "profile"}, + allowedScopes: allowedScopes, + expectedScopes: []string{"openid", "profile"}, }, { - name: "full set of allowed scopes accepted", - requestedScope: "openid profile email offline_access", - allowedScopes: allowedScopes, - expectedScopes: []string{"openid", "profile", "email", "offline_access"}, + name: "full set of allowed scopes accepted", + requestedScopes: []string{"openid", "profile", "email", "offline_access"}, + allowedScopes: allowedScopes, + expectedScopes: []string{"openid", "profile", "email", "offline_access"}, }, { - name: "unknown scope rejected", - requestedScope: "openid sneaky_admin", - allowedScopes: allowedScopes, - expectError: true, - errorCode: DCRErrorInvalidClientMetadata, + name: "unknown scope rejected", + requestedScopes: []string{"openid", "sneaky_admin"}, + allowedScopes: allowedScopes, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, }, { - name: "prefix of valid scope rejected", - requestedScope: "openid.evil", - allowedScopes: allowedScopes, - expectError: true, - errorCode: DCRErrorInvalidClientMetadata, + name: "prefix of valid scope rejected", + requestedScopes: []string{"openid.evil"}, + allowedScopes: allowedScopes, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, }, { - name: "substring of valid scope rejected", - requestedScope: "open", - allowedScopes: allowedScopes, - expectError: true, - errorCode: DCRErrorInvalidClientMetadata, + name: "substring of valid scope rejected", + requestedScopes: []string{"open"}, + allowedScopes: allowedScopes, + expectError: true, + errorCode: DCRErrorInvalidClientMetadata, }, { - name: "empty input returns default scopes", - requestedScope: "", - allowedScopes: allowedScopes, - expectedScopes: DefaultScopes, + name: "empty input returns default scopes", + requestedScopes: nil, + allowedScopes: allowedScopes, + expectedScopes: DefaultScopes, }, { - name: "duplicate scopes are deduplicated", - requestedScope: "openid openid profile", - allowedScopes: allowedScopes, - expectedScopes: []string{"openid", "profile"}, + name: "duplicate scopes are deduplicated", + requestedScopes: []string{"openid", "openid", "profile"}, + allowedScopes: allowedScopes, + expectedScopes: []string{"openid", "profile"}, }, { - name: "empty input returns server-supported scopes when customized", - requestedScope: "", - allowedScopes: []string{"openid", "email", "toolhive-mcp/access"}, - expectedScopes: []string{"openid", "email", "toolhive-mcp/access"}, + name: "empty input returns server-supported scopes when customized", + requestedScopes: nil, + allowedScopes: []string{"openid", "email", "toolhive-mcp/access"}, + expectedScopes: []string{"openid", "email", "toolhive-mcp/access"}, }, { - name: "empty input deduplicates customized defaults", - requestedScope: "", - allowedScopes: []string{"openid", "email", "openid", "", "toolhive-mcp/access"}, - expectedScopes: []string{"openid", "email", "toolhive-mcp/access"}, + name: "empty input deduplicates customized defaults", + requestedScopes: nil, + allowedScopes: []string{"openid", "email", "openid", "", "toolhive-mcp/access"}, + expectedScopes: []string{"openid", "email", "toolhive-mcp/access"}, }, } @@ -532,7 +588,7 @@ func TestValidateScopes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - scopes, dcrErr := ValidateScopes(tt.requestedScope, tt.allowedScopes) + scopes, dcrErr := ValidateScopes(tt.requestedScopes, tt.allowedScopes) if tt.expectError { require.NotNil(t, dcrErr, "expected error") @@ -564,3 +620,110 @@ func TestDefaultGrantTypesAndResponseTypes(t *testing.T) { // Verify default response types include code assert.Contains(t, defaultResponseTypes, "code") } + +func TestValidateScopeSubset(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subset []string + superset []string + fieldName string + wantErr bool + errMsg string + }{ + { + name: "nil subset passes", + subset: nil, + superset: []string{"openid", "profile"}, + fieldName: "baseline_client_scopes", + }, + { + name: "empty subset passes", + subset: []string{}, + superset: []string{"openid", "profile"}, + fieldName: "baseline_client_scopes", + }, + { + name: "all subset entries present in superset passes", + subset: []string{"openid", "profile"}, + superset: []string{"openid", "profile", "email", "offline_access"}, + fieldName: "baseline_client_scopes", + }, + { + name: "single entry not in superset returns error", + subset: []string{"offline_access"}, + superset: []string{"openid"}, + fieldName: "baseline_client_scopes", + wantErr: true, + errMsg: `baseline_client_scopes contains "offline_access" which is not in scopes_supported`, + }, + { + name: "first offending entry reported in error", + subset: []string{"foo", "bar"}, + superset: []string{"openid"}, + fieldName: "baseline_client_scopes", + wantErr: true, + errMsg: `baseline_client_scopes contains "foo" which is not in scopes_supported`, + }, + { + name: "non-nil subset with nil superset returns error", + subset: []string{"openid"}, + superset: nil, + fieldName: "baseline_client_scopes", + wantErr: true, + errMsg: `baseline_client_scopes contains "openid" which is not in scopes_supported`, + }, + { + name: "fieldName is embedded in error message", + subset: []string{"missing"}, + superset: []string{"openid"}, + fieldName: "my_custom_field", + wantErr: true, + errMsg: `my_custom_field contains "missing" which is not in scopes_supported`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateScopeSubset(tt.subset, tt.superset, tt.fieldName) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestUnionScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req []string + baseline []string + want []string + }{ + {name: "both nil returns nil", req: nil, baseline: nil, want: nil}, + {name: "both empty returns nil", req: []string{}, baseline: []string{}, want: nil}, + {name: "requested only preserved unchanged", req: []string{"openid", "profile"}, baseline: nil, want: []string{"openid", "profile"}}, + {name: "baseline only returned when no requested", req: nil, baseline: []string{"openid", "email"}, want: []string{"openid", "email"}}, + {name: "requested subset of baseline expands correctly", req: []string{"openid"}, baseline: []string{"openid", "profile", "email"}, want: []string{"openid", "profile", "email"}}, + {name: "disjoint sets: requested first then baseline", req: []string{"openid", "profile"}, baseline: []string{"email", "offline_access"}, want: []string{"openid", "profile", "email", "offline_access"}}, + {name: "exact match produces no duplicates", req: []string{"openid", "profile"}, baseline: []string{"openid", "profile"}, want: []string{"openid", "profile"}}, + {name: "duplicates in requested are deduplicated", req: []string{"openid", "openid", "profile"}, baseline: nil, want: []string{"openid", "profile"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := UnionScopes(tt.req, tt.baseline) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 75cf938029..250f0a68c0 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/http" + "time" josev3 "github.com/go-jose/go-jose/v3" "github.com/ory/fosite" @@ -21,9 +22,22 @@ import ( // server is the internal implementation of the Server interface. type server struct { - handler http.Handler - storage storage.Storage + handler http.Handler + // storage is the active storage backend, potentially wrapped by decorators + // such as CIMDStorageDecorator. Code that needs the concrete type must walk + // the Unwrap() chain rather than asserting directly. + storage storage.Storage + // dcrStore is the same storage.Storage value asserted to + // storage.DCRCredentialStore. The assertion runs once at construction + // (newServer) so DCRStore() is a field read rather than re-asserting on + // every call, and a backend that does not implement DCRCredentialStore + // is rejected at boot rather than at first DCR resolve. + dcrStore storage.DCRCredentialStore upstreams []handlers.NamedUpstream + // refreshTokenLifespan mirrors the validated Config.RefreshTokenLifespan. + // It is threaded into upstreamTokenRefresher so the refresh path can + // re-anchor SessionExpiresAt for legacy storage rows missing that field. + refreshTokenLifespan time.Duration } // upstreamProviderFactory creates an upstream OAuth2Provider from configuration. @@ -88,6 +102,20 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se return nil, fmt.Errorf("storage is required") } + // Storage no longer embeds DCRCredentialStore (the embed widened secret + // reach to every Storage consumer); obtain the DCR-capable handle via an + // explicit assertion at the boundary. The per-backend + // `var _ DCRCredentialStore = (*MemoryStorage)(nil)` / + // `var _ DCRCredentialStore = (*RedisStorage)(nil)` checks make this + // provably safe for the production backends; surfacing a bad backend as + // a constructor error keeps misconfiguration fail-loud at boot rather + // than at first DCR resolve. + baseStore := unwrapStorage(stor) + dcrStore, ok := baseStore.(storage.DCRCredentialStore) + if !ok { + return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", baseStore) + } + slog.Debug("creating OAuth2 configuration") // Get signing key from KeyProvider @@ -107,8 +135,10 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se SigningKeyAlgorithm: signingKey.Algorithm, SigningKey: signingKey.Key, ScopesSupported: cfg.ScopesSupported, + BaselineClientScopes: cfg.BaselineClientScopes, AllowedAudiences: cfg.AllowedAudiences, AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL, + CIMDEnabled: cfg.CIMDEnabled, } authServerConfig, err := oauthserver.NewAuthorizationServerConfig(oauthParams) if err != nil { @@ -121,10 +151,6 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se "auth_code_lifespan", cfg.AuthCodeLifespan, ) - // Create fosite provider - slog.Debug("creating fosite OAuth2 provider") - fositeProvider := createProvider(authServerConfig, stor) - // Build ordered upstream provider list from all configured upstreams. upstreams := make([]handlers.NamedUpstream, 0, len(cfg.Upstreams)) for i := range cfg.Upstreams { @@ -143,15 +169,36 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se // Run one-shot bulk migration of legacy data before handler construction. // TODO(migration): Remove once all deployments have upgraded past this version. - if rs, ok := stor.(*storage.RedisStorage); ok { - for i := range cfg.Upstreams { - upCfg := &cfg.Upstreams[i] - if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil { - return nil, fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err) - } + if err := runLegacyMigration(ctx, stor, cfg.Upstreams); err != nil { + return nil, err + } + + // Wrap storage with the CIMD decorator before constructing the fosite provider + // so that GetClient calls for HTTPS client_id values are intercepted at the + // fosite level (not just the handler level). + if cfg.CIMDEnabled { + if len(cfg.BaselineClientScopes) > 0 { + slog.Warn("CIMD is enabled with baseline_client_scopes configured; "+ + "any third-party client resolved via CIMD will also receive these scopes — "+ + "ensure they are scopes you would grant by default to any unknown client", + "baseline_client_scopes", cfg.BaselineClientScopes) + } + stor, err = storage.NewCIMDStorageDecorator(stor, storage.CIMDDecoratorConfig{ + Enabled: true, + CacheMaxSize: cfg.CIMDCacheMaxSize, + FallbackTTL: cfg.CIMDCacheFallbackTTL, + ScopesSupported: cfg.ScopesSupported, + BaselineClientScopes: cfg.BaselineClientScopes, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err) } } + // Create fosite provider with the (possibly decorated) storage. + slog.Debug("creating fosite OAuth2 provider") + fositeProvider := createProvider(authServerConfig, stor) + handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams) if err != nil { return nil, fmt.Errorf("failed to create handler: %w", err) @@ -165,9 +212,11 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se ) return &server{ - handler: router, - storage: stor, - upstreams: upstreams, + handler: router, + storage: stor, + dcrStore: dcrStore, + upstreams: upstreams, + refreshTokenLifespan: cfg.RefreshTokenLifespan, }, nil } @@ -181,6 +230,12 @@ func (s *server) IDPTokenStorage() storage.UpstreamTokenStorage { return s.storage } +// DCRStore returns the persistent DCR credential store the server is wired +// against. See the Server interface doc for SECURITY and lifecycle notes. +func (s *server) DCRStore() storage.DCRCredentialStore { + return s.dcrStore +} + // UpstreamTokenRefresher returns a refresher that wraps the upstream providers // and storage to transparently refresh expired upstream tokens. The refresher // dispatches to the correct provider based on each token's ProviderID. @@ -193,8 +248,9 @@ func (s *server) UpstreamTokenRefresher() storage.UpstreamTokenRefresher { providers[u.Name] = u.Provider } return &upstreamTokenRefresher{ - providers: providers, - storage: s.storage, + providers: providers, + storage: s.storage, + refreshTokenLifespan: s.refreshTokenLifespan, } } @@ -260,3 +316,31 @@ func createProvider(authServerConfig *oauthserver.AuthorizationServerConfig, sto compose.OAuth2PKCEFactory, // PKCE for public clients ) } + +// unwrapStorage peels off one decorator layer if the storage implements +// Unwrap(), returning the concrete backend. Both newServer (DCRCredentialStore +// assertion) and runLegacyMigration (RedisStorage type assertion) need this. +func unwrapStorage(stor storage.Storage) storage.Storage { + if unwrapper, ok := stor.(interface{ Unwrap() storage.Storage }); ok { + return unwrapper.Unwrap() + } + return stor +} + +// runLegacyMigration runs one-shot Redis data migrations before handlers are +// constructed. It is a no-op for non-Redis backends and passes through any +// decorator wrapping so the concrete type can be reached. +func runLegacyMigration(ctx context.Context, stor storage.Storage, upstreams []UpstreamConfig) error { + base := unwrapStorage(stor) + rs, ok := base.(*storage.RedisStorage) + if !ok { + return nil + } + for i := range upstreams { + upCfg := &upstreams[i] + if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil { + return fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err) + } + } + return nil +} diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index 121082f54f..61a22a8cb7 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -8,11 +8,13 @@ import ( "crypto/rand" "strings" "testing" + "time" "go.uber.org/mock/gomock" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" + "github.com/stacklok/toolhive/pkg/authserver/storage" storagemocks "github.com/stacklok/toolhive/pkg/authserver/storage/mocks" "github.com/stacklok/toolhive/pkg/authserver/upstream" upstreammocks "github.com/stacklok/toolhive/pkg/authserver/upstream/mocks" @@ -145,10 +147,17 @@ func TestNewServer_Success(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - // Create mocks - mockStorage := storagemocks.NewMockStorage(ctrl) mockUpstream := upstreammocks.NewMockOAuth2Provider(ctrl) + // Use a real MemoryStorage rather than storagemocks.MockStorage: the + // constructor type-asserts the storage to storage.DCRCredentialStore (per + // the F6 design — Storage no longer embeds DCRCredentialStore), and + // generated MockStorage does not implement DCRCredentialStore. This test + // exercises the constructor flow, not specific storage method calls, so + // a real MemoryStorage is sufficient and keeps the assertion path real. + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + // Create valid config cfg := Config{ Issuer: "https://example.com", @@ -165,7 +174,7 @@ func TestNewServer_Success(t *testing.T) { // Call newServer with the mock factory ctx := context.Background() - srv, err := newServer(ctx, cfg, mockStorage, withUpstreamFactory(mockFactory)) + srv, err := newServer(ctx, cfg, stor, withUpstreamFactory(mockFactory)) if err != nil { t.Fatalf("newServer() unexpected error: %v", err) @@ -176,7 +185,41 @@ func TestNewServer_Success(t *testing.T) { if srv.Handler() == nil { t.Error("server.Handler() returned nil") } - if srv.IDPTokenStorage() != mockStorage { + if srv.IDPTokenStorage() != stor { t.Error("server.IDPTokenStorage() did not return expected storage") } } + +func TestNewServer_CIMDEnabled_WrapsStorage(t *testing.T) { + t.Parallel() + + mockUpstream := upstreammocks.NewMockOAuth2Provider(gomock.NewController(t)) + + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + + cfg := Config{ + Issuer: "https://example.com", + KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm), + HMACSecrets: &servercrypto.HMACSecrets{Current: validHMACSecret()}, + Upstreams: []UpstreamConfig{{Name: "default", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstreamConfig()}}, + AllowedAudiences: []string{"https://mcp.example.com"}, + CIMDEnabled: true, + CIMDCacheMaxSize: 16, + CIMDCacheFallbackTTL: 5 * time.Minute, + } + + mockFactory := func(_ context.Context, _ *UpstreamConfig) (upstream.OAuth2Provider, error) { + return mockUpstream, nil + } + + srv, err := newServer(context.Background(), cfg, stor, withUpstreamFactory(mockFactory)) + if err != nil { + t.Fatalf("newServer() unexpected error: %v", err) + } + + _, ok := srv.storage.(*storage.CIMDStorageDecorator) + if !ok { + t.Errorf("expected storage to be *storage.CIMDStorageDecorator when CIMDEnabled=true, got %T", srv.storage) + } +} diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go new file mode 100644 index 0000000000..d8a8c5c508 --- /dev/null +++ b/pkg/authserver/storage/cimd_decorator.go @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "context" + "fmt" + "net/url" + "slices" + "strings" + "time" + + lru "github.com/hashicorp/golang-lru/v2" + "github.com/ory/fosite" + "golang.org/x/sync/singleflight" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/oauthproto" + "github.com/stacklok/toolhive/pkg/oauthproto/cimd" +) + +// CIMDStorageDecorator wraps storage.Storage and intercepts GetClient calls +// for HTTPS client_id values, fetching and caching the corresponding Client +// ID Metadata Document instead of requiring prior DCR registration. +// +// All other Storage methods delegate to the underlying storage unchanged. +// Only GetClient is overridden. DCR clients (opaque IDs) continue to work +// exactly as before. +type CIMDStorageDecorator struct { + Storage // embed full interface — all methods delegate + sf singleflight.Group // deduplicates concurrent fetches for the same URL + cache *lru.Cache[string, *cimdCacheEntry] + ttl time.Duration + scopesSupported []string // AS-configured scopes; nil means accept any + baselineClientScopes []string // unioned into every client's scope set, same as DCR +} + +type cimdCacheEntry struct { + client fosite.Client + expires time.Time +} + +// CIMDDecoratorConfig holds the configuration for NewCIMDStorageDecorator. +// Using a struct prevents silent swaps of the two adjacent []string fields. +type CIMDDecoratorConfig struct { + // Enabled returns base unchanged when false, avoiding an allocation. + Enabled bool + // CacheMaxSize is the maximum number of documents in the LRU cache (must be >= 1). + CacheMaxSize int + // FallbackTTL is the fixed TTL applied to every cache entry. + FallbackTTL time.Duration + // ScopesSupported is the AS scope allowlist; see pkg/authserver/config.go + // applyDefaults for production guarantees. Pass nil in tests only. + ScopesSupported []string + // BaselineClientScopes is unioned into every CIMD client's scope set, + // matching DCR handler behaviour. + BaselineClientScopes []string +} + +// NewCIMDStorageDecorator wraps base with CIMD client lookup. +// When cfg.Enabled is false it returns base unchanged (no allocation). +func NewCIMDStorageDecorator(base Storage, cfg CIMDDecoratorConfig) (Storage, error) { + if !cfg.Enabled { + return base, nil + } + + if cfg.CacheMaxSize < 1 { + return nil, fmt.Errorf("CIMD storage decorator cacheMaxSize must be >= 1, got %d", cfg.CacheMaxSize) + } + + c, err := lru.New[string, *cimdCacheEntry](cfg.CacheMaxSize) + if err != nil { + return nil, fmt.Errorf("failed to create CIMD LRU cache: %w", err) + } + + return &CIMDStorageDecorator{ + Storage: base, + cache: c, + ttl: cfg.FallbackTTL, + scopesSupported: slices.Clone(cfg.ScopesSupported), + baselineClientScopes: slices.Clone(cfg.BaselineClientScopes), + }, nil +} + +// GetClient intercepts HTTPS client_id values to resolve them via CIMD. +// Opaque DCR-issued IDs are delegated to the underlying storage unchanged. +func (d *CIMDStorageDecorator) GetClient(ctx context.Context, id string) (fosite.Client, error) { + if !oauthproto.IsClientIDMetadataDocumentURL(id) { + return d.Storage.GetClient(ctx, id) + } + return d.fetchOrCached(ctx, id) +} + +// Unwrap returns the underlying storage so that type assertions (e.g. for +// storage.DCRCredentialStore in server_impl.go) can reach the concrete type. +func (d *CIMDStorageDecorator) Unwrap() Storage { + return d.Storage +} + +func (d *CIMDStorageDecorator) fetchOrCached(ctx context.Context, id string) (fosite.Client, error) { + // Check cache first (outside singleflight to avoid holding the group lock for cache hits) + if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) { + return entry.client, nil + } + + // Deduplicate concurrent fetches for the same URL. The shared fetch uses a + // context detached from the caller so that one caller cancelling does not + // abort the in-flight request for other waiters. The HTTP client inside + // FetchClientMetadataDocument enforces its own 5-second timeout. + fetchCtx := context.WithoutCancel(ctx) + result, err, _ := d.sf.Do(id, func() (interface{}, error) { + // Re-check cache inside singleflight (another goroutine may have populated it) + if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) { + return entry.client, nil + } + return d.fetch(fetchCtx, id) + }) + if err != nil { + return nil, err + } + client, ok := result.(fosite.Client) + if !ok { + return nil, fmt.Errorf("CIMD singleflight returned unexpected type %T", result) + } + return client, nil +} + +func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Client, error) { + doc, err := cimd.FetchClientMetadataDocument(ctx, id) + if err != nil { + return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound.WithHint("CIMD fetch failed"), err) + } + + // Reject documents that declare an auth method this AS does not support. + // ErrInvalidClient: the document was fetched successfully but its declared + // metadata violates AS policy (distinct from ErrNotFound which means the + // document could not be fetched at all). + if m := doc.TokenEndpointAuthMethod; m != "" && m != defaultCIMDTokenEndpointAuthMethod { + return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+ + "but this server only supports %q", + fosite.ErrInvalidClient.WithHint("unsupported token_endpoint_auth_method"), + id, m, defaultCIMDTokenEndpointAuthMethod) + } + + // Reject documents that declare grant_types or response_types the embedded AS + // does not support for public clients. Uses the same validators as DCR so the + // error messages and allowed sets are identical on both registration paths. + if _, dcrErr := registration.ValidatePublicGrantTypes(doc.GrantTypes); dcrErr != nil { + return nil, fmt.Errorf("%w: CIMD document at %s: %s", + fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + } + if _, dcrErr := registration.ValidatePublicResponseTypes(doc.ResponseTypes); dcrErr != nil { + return nil, fmt.Errorf("%w: CIMD document at %s: %s", + fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + } + + // Compute and validate the client scope list consistent with DCR. + // When ScopesSupported is configured: + // - Declared scopes are validated via registration.ValidateScopes (same + // function as the DCR handler). + // - Omitted scope uses ValidateScopes(nil, scopesSupported) which returns + // DefaultScopes when DefaultScopes ⊆ ScopesSupported, matching DCR. + // If DefaultScopes ⊄ ScopesSupported the document must declare scope + // explicitly to avoid ambiguous privilege grant. + // When ScopesSupported is not configured: no AS-level validation; declared + // scopes are used directly, or nil to let buildFositeClient apply DefaultScopes. + // In both cases BaselineClientScopes is unioned in after validation, + // matching the DCR handler's behaviour. + var resolvedScopes []string + if len(d.scopesSupported) > 0 { + if doc.Scope != "" { + computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported) + if dcrErr != nil { + return nil, fmt.Errorf("%w: CIMD document at %s: %s", + fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + } + resolvedScopes = computed + } else { + // Omitted scope: match DCR — give DefaultScopes when they fit, else require explicit scope. + computed, dcrErr := registration.ValidateScopes(nil, d.scopesSupported) + if dcrErr != nil { + return nil, fmt.Errorf("%w: CIMD document at %s omits scope but "+ + "DefaultScopes are not a subset of this server's scopes_supported — "+ + "the document must explicitly declare its required scopes", + fosite.ErrInvalidClient.WithHint("scope field required"), + id) + } + resolvedScopes = computed + } + } else if doc.Scope != "" { + resolvedScopes = strings.Fields(doc.Scope) + } + if len(d.baselineClientScopes) > 0 { + resolvedScopes = registration.UnionScopes(resolvedScopes, d.baselineClientScopes) + } + + client := buildFositeClient(doc, resolvedScopes) + + d.cache.Add(id, &cimdCacheEntry{ + client: client, + expires: time.Now().Add(d.ttl), + }) + + return client, nil +} + +// defaultCIMDGrantTypes are the OAuth 2.0 grant types applied when the CIMD +// document omits grant_types. CIMD clients are typically public native apps +// that use the authorization code flow with refresh token rotation. +var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"} + +// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the +// CIMD document omits response_types. +var defaultCIMDResponseTypes = []string{"code"} + +// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication +// method applied when the CIMD document omits token_endpoint_auth_method. +// Documents that declare any other value are rejected by fetch() before +// buildFositeClient is called. +const defaultCIMDTokenEndpointAuthMethod = "none" + +// buildFositeClient converts a ClientMetadataDocument into a fosite.Client. +// Redirect URIs containing http://localhost are wrapped in a LoopbackClient +// so that RFC 8252 §7.3 dynamic port matching applies. +// resolvedScopes is the already-validated scope list computed by fetch() via +// registration.ValidateScopes; when empty, DefaultScopes is used — this occurs when +// the decorator has no ScopesSupported restriction (unconstrained AS). +func buildFositeClient(doc *cimd.ClientMetadataDocument, resolvedScopes []string) fosite.Client { + grantTypes := doc.GrantTypes + if len(grantTypes) == 0 { + grantTypes = defaultCIMDGrantTypes + } + + responseTypes := doc.ResponseTypes + if len(responseTypes) == 0 { + responseTypes = defaultCIMDResponseTypes + } + + tokenEndpointAuthMethod := doc.TokenEndpointAuthMethod + if tokenEndpointAuthMethod == "" { + tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod + } + + // Scopes were computed and validated by fetch() via registration.ValidateScopes, + // consistent with the DCR handler. Fall back to DefaultScopes only when the + // decorator has no ScopesSupported restriction (unconstrained AS). + scopes := resolvedScopes + if len(scopes) == 0 { + scopes = slices.Clone(registration.DefaultScopes) + } + + defaultClient := &fosite.DefaultClient{ + ID: doc.ClientID, + RedirectURIs: doc.RedirectURIs, + GrantTypes: grantTypes, + ResponseTypes: responseTypes, + Scopes: scopes, + // CIMD clients don't pre-declare audience; leave empty so the AS + // applies its own audience policy rather than rejecting all values. + Audience: nil, + Public: true, + } + + openIDClient := &fosite.DefaultOpenIDConnectClient{ + DefaultClient: defaultClient, + TokenEndpointAuthMethod: tokenEndpointAuthMethod, + } + + // Wrap in LoopbackClient when any redirect URI targets localhost so that + // RFC 8252 §7.3 dynamic port matching works for native app clients. + // Pass openIDClient directly so TokenEndpointAuthMethod is preserved — + // LoopbackClient now embeds *fosite.DefaultOpenIDConnectClient. + if hasLoopbackRedirectURI(doc.RedirectURIs) { + return registration.NewLoopbackClient(openIDClient) + } + + return openIDClient +} + +// hasLoopbackRedirectURI returns true when any of the redirect URIs in the +// list targets a loopback address over HTTP. The host is parsed from each URI +// to prevent bypass via hosts like "http://localhost.evil.com/". +func hasLoopbackRedirectURI(uris []string) bool { + for _, uri := range uris { + parsed, err := url.Parse(uri) + if err != nil { + continue + } + if parsed.Scheme == "http" && oauthproto.IsLoopbackHost(parsed.Hostname()) { + return true + } + } + return false +} diff --git a/pkg/authserver/storage/cimd_decorator_test.go b/pkg/authserver/storage/cimd_decorator_test.go new file mode 100644 index 0000000000..1666cc81de --- /dev/null +++ b/pkg/authserver/storage/cimd_decorator_test.go @@ -0,0 +1,637 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ory/fosite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/oauthproto/cimd" +) + +// serveCIMDDoc starts an httptest.Server that serves a valid CIMD document at +// path. The document's client_id equals the full URL (scheme + host + path) as +// required by ValidateClientMetadataDocument. The returned server URL is the +// base (without path); append path to form the client_id. +// +// An optional pre-handler runs before the default JSON response, allowing +// tests to inject counters or delays. Pass nil to use the default behaviour. +func serveCIMDDoc(t *testing.T, path string, preHandler func()) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + http.NotFound(w, r) + return + } + if preHandler != nil { + preHandler() + } + // client_id must equal the URL we are serving from. + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + return srv +} + +// newTestBase creates a MemoryStorage suitable for use as the decorator base in tests. +func newTestBase(t *testing.T) *MemoryStorage { + t.Helper() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + return base +} + +// newEnabledDecorator creates a CIMDStorageDecorator wrapping base. +func newEnabledDecorator(t *testing.T, base *MemoryStorage, maxSize int, ttl time.Duration) *CIMDStorageDecorator { + t.Helper() + got, err := NewCIMDStorageDecorator(base, CIMDDecoratorConfig{Enabled: true, CacheMaxSize: maxSize, FallbackTTL: ttl}) + require.NoError(t, err) + return got.(*CIMDStorageDecorator) +} + +// cimdURL returns the CIMD URL for the given server and path. +func cimdURL(srv *httptest.Server, path string) string { + return srv.URL + path +} + +// --- Constructor tests --- + +func TestNewCIMDStorageDecorator_DisabledReturnsBase(t *testing.T) { + t.Parallel() + base := newTestBase(t) + got, err := NewCIMDStorageDecorator(base, CIMDDecoratorConfig{Enabled: false, CacheMaxSize: 10, FallbackTTL: time.Minute}) + require.NoError(t, err) + assert.Same(t, base, got, "disabled decorator must return base unchanged") +} + +func TestNewCIMDStorageDecorator_ZeroCacheSizeReturnsError(t *testing.T) { + t.Parallel() + base := newTestBase(t) + _, err := NewCIMDStorageDecorator(base, CIMDDecoratorConfig{Enabled: true, CacheMaxSize: 0, FallbackTTL: time.Minute}) + require.Error(t, err) +} + +func TestNewCIMDStorageDecorator_NegativeCacheSizeReturnsError(t *testing.T) { + t.Parallel() + base := newTestBase(t) + _, err := NewCIMDStorageDecorator(base, CIMDDecoratorConfig{Enabled: true, CacheMaxSize: -1, FallbackTTL: time.Minute}) + require.Error(t, err) +} + +func TestNewCIMDStorageDecorator_EnabledReturnsCIMDDecorator(t *testing.T) { + t.Parallel() + base := newTestBase(t) + got, err := NewCIMDStorageDecorator(base, CIMDDecoratorConfig{Enabled: true, CacheMaxSize: 10, FallbackTTL: time.Minute}) + require.NoError(t, err) + require.NotNil(t, got) + _, isCIMD := got.(*CIMDStorageDecorator) + assert.True(t, isCIMD, "enabled decorator must return a *CIMDStorageDecorator") +} + +// --- Unwrap --- + +func TestCIMDStorageDecorator_UnwrapReturnsBase(t *testing.T) { + t.Parallel() + base := newTestBase(t) + dec := newEnabledDecorator(t, base, 10, time.Minute) + assert.Same(t, base, dec.Unwrap()) +} + +// --- GetClient delegation for non-CIMD IDs --- + +func TestCIMDStorageDecorator_GetClient_OpaqueIDDelegatesToBase(t *testing.T) { + t.Parallel() + base := newTestBase(t) + ctx := context.Background() + + dc := &fosite.DefaultClient{ID: "opaque-client-id"} + require.NoError(t, base.RegisterClient(ctx, dc)) + + dec := newEnabledDecorator(t, base, 10, time.Minute) + + got, err := dec.GetClient(ctx, "opaque-client-id") + require.NoError(t, err) + assert.Equal(t, "opaque-client-id", got.GetID()) +} + +func TestCIMDStorageDecorator_GetClient_UnknownOpaqueIDReturnsError(t *testing.T) { + t.Parallel() + base := newTestBase(t) + dec := newEnabledDecorator(t, base, 10, time.Minute) + _, err := dec.GetClient(context.Background(), "unknown-opaque-id") + require.Error(t, err) +} + +// --- fetchOrCached / fetch (loopback HTTP accepted by FetchClientMetadataDocument) --- +// These tests call fetchOrCached directly (same package) using http://127.0.0.1 +// URLs, which FetchClientMetadataDocument accepts for testing purposes. + +func TestCIMDStorageDecorator_FetchOrCached_FetchesAndReturnsClient(t *testing.T) { + t.Parallel() + + var fetchCount atomic.Int32 + srv := serveCIMDDoc(t, "/metadata.json", func() { fetchCount.Add(1) }) + + id := cimdURL(srv, "/metadata.json") + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + + got, err := dec.fetchOrCached(context.Background(), id) + require.NoError(t, err) + assert.Equal(t, id, got.GetID()) + assert.Equal(t, int32(1), fetchCount.Load()) +} + +func TestCIMDStorageDecorator_FetchOrCached_CacheHitAvoidsSecondFetch(t *testing.T) { + t.Parallel() + + var fetchCount atomic.Int32 + srv := serveCIMDDoc(t, "/metadata.json", func() { fetchCount.Add(1) }) + + id := cimdURL(srv, "/metadata.json") + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + + ctx := context.Background() + _, err := dec.fetchOrCached(ctx, id) + require.NoError(t, err) + + _, err = dec.fetchOrCached(ctx, id) + require.NoError(t, err) + + assert.Equal(t, int32(1), fetchCount.Load(), "second call must be served from cache") +} + +func TestCIMDStorageDecorator_FetchOrCached_LRUEvictionForcesRefetch(t *testing.T) { + t.Parallel() + + var fetchCount atomic.Int32 + srv := serveCIMDDoc(t, "/a.json", func() { fetchCount.Add(1) }) + srv2 := serveCIMDDoc(t, "/b.json", func() { fetchCount.Add(1) }) + + id1 := cimdURL(srv, "/a.json") + id2 := cimdURL(srv2, "/b.json") + + // maxSize=1 forces eviction after the first entry. + dec := newEnabledDecorator(t, newTestBase(t), 1, time.Minute) + ctx := context.Background() + + _, err := dec.fetchOrCached(ctx, id1) + require.NoError(t, err) + + // Fetching id2 evicts id1 from the single-slot cache. + _, err = dec.fetchOrCached(ctx, id2) + require.NoError(t, err) + + // id1 must re-fetch. + _, err = dec.fetchOrCached(ctx, id1) + require.NoError(t, err) + + assert.Equal(t, int32(3), fetchCount.Load(), "id1 must be fetched twice due to LRU eviction") +} + +func TestCIMDStorageDecorator_FetchOrCached_SingleflightDeduplicatesConcurrentFetches(t *testing.T) { + t.Parallel() + + var fetchCount atomic.Int32 + // Barrier lets us hold all goroutines until they are all in-flight. + ready := make(chan struct{}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-ready + fetchCount.Add(1) + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + id := cimdURL(srv, "/metadata.json") + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + + const goroutines = 5 + errs := make([]error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + + // Each goroutine signals on startBarrier immediately before calling + // fetchOrCached. Draining all signals before closing ready ensures they + // are all scheduled and about to enter sf.Do, making the singleflight + // deduplication deterministic without relying on time.Sleep. + startBarrier := make(chan struct{}, goroutines) + + for i := 0; i < goroutines; i++ { + go func(i int) { + defer wg.Done() + startBarrier <- struct{}{} + _, errs[i] = dec.fetchOrCached(context.Background(), id) + }(i) + } + + for range goroutines { + <-startBarrier + } + close(ready) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent fetchOrCached goroutines") + } + + for i, e := range errs { + require.NoError(t, e, "goroutine %d returned an error", i) + } + assert.Equal(t, int32(1), fetchCount.Load(), "singleflight must collapse concurrent fetches into one") +} + +func TestCIMDStorageDecorator_FetchOrCached_FetchFailureReturnsNotFound(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err) + assert.ErrorIs(t, err, fosite.ErrNotFound, "fetch failure must be wrapped as fosite.ErrNotFound") +} + +func TestCIMDStorageDecorator_FetchOrCached_ExpiredCacheEntryRefetches(t *testing.T) { + t.Parallel() + + var fetchCount atomic.Int32 + srv := serveCIMDDoc(t, "/metadata.json", func() { fetchCount.Add(1) }) + + id := cimdURL(srv, "/metadata.json") + dec := newEnabledDecorator(t, newTestBase(t), 10, 1*time.Millisecond) + + ctx := context.Background() + _, err := dec.fetchOrCached(ctx, id) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + _, err = dec.fetchOrCached(ctx, id) + require.NoError(t, err) + + assert.Equal(t, int32(2), fetchCount.Load(), "expired cache entry must trigger a re-fetch") +} + +// --- GetClient with HTTPS CIMD URLs --- +// Verify that GetClient routes HTTPS client_id values through fetchOrCached by +// pre-populating the cache directly (avoiding real network). + +func TestCIMDStorageDecorator_GetClient_CIMDURLHitsCacheDirectly(t *testing.T) { + t.Parallel() + + base := newTestBase(t) + dec := newEnabledDecorator(t, base, 10, time.Minute) + + const httpsID = "https://example.com/meta.json" + fakeClient := &fosite.DefaultClient{ID: httpsID} + + // Pre-populate the cache so no real HTTP fetch is needed. + dec.cache.Add(httpsID, &cimdCacheEntry{ + client: fakeClient, + expires: time.Now().Add(time.Minute), + }) + + got, err := dec.GetClient(context.Background(), httpsID) + require.NoError(t, err) + assert.Equal(t, httpsID, got.GetID()) +} + +// --- buildFositeClient --- + +func TestBuildFositeClient_Defaults(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + + got := buildFositeClient(doc, nil) + assert.Equal(t, "https://example.com/meta.json", got.GetID()) + assert.True(t, got.IsPublic()) + assert.ElementsMatch(t, []string{"authorization_code", "refresh_token"}, []string(got.GetGrantTypes())) + assert.ElementsMatch(t, []string{"code"}, []string(got.GetResponseTypes())) +} + +func TestBuildFositeClient_ExplicitGrantTypes(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{"authorization_code"}, + } + + got := buildFositeClient(doc, nil) + assert.ElementsMatch(t, []string{"authorization_code"}, []string(got.GetGrantTypes())) +} + +func TestBuildFositeClient_ScopeParsing(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + Scope: "openid profile email", + } + + // Scope parsing is done by fetch() before buildFositeClient. + got := buildFositeClient(doc, strings.Fields(doc.Scope)) + assert.ElementsMatch(t, []string{"openid", "profile", "email"}, []string(got.GetScopes())) +} + +func TestBuildFositeClient_LoopbackRedirectWrapsInLoopbackClient(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"http://localhost/callback"}, + } + + got := buildFositeClient(doc, nil) + // LoopbackClient adds MatchRedirectURI — check the distinctive method is present. + type loopbackMatcher interface { + MatchRedirectURI(string) bool + } + _, ok := got.(loopbackMatcher) + assert.True(t, ok, "loopback redirect URI must produce a LoopbackClient") + + // TokenEndpointAuthMethod must be preserved through the LoopbackClient wrapper. + oidc, ok := got.(fosite.OpenIDConnectClient) + require.True(t, ok, "LoopbackClient must implement fosite.OpenIDConnectClient") + assert.Equal(t, "none", oidc.GetTokenEndpointAuthMethod(), + "loopback client must preserve TokenEndpointAuthMethod from the OIDC client") +} + +func TestBuildFositeClient_NonLoopbackRedirectReturnsOpenIDConnectClient(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + + got := buildFositeClient(doc, nil) + _, ok := got.(*fosite.DefaultOpenIDConnectClient) + assert.True(t, ok, "non-loopback redirect URI must produce a DefaultOpenIDConnectClient") +} + +func TestBuildFositeClient_TokenEndpointAuthMethodDefault(t *testing.T) { + t.Parallel() + + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + + got := buildFositeClient(doc, nil) + if oidc, ok := got.(fosite.OpenIDConnectClient); ok { + assert.Equal(t, "none", oidc.GetTokenEndpointAuthMethod()) + } +} + +func TestFetch_RejectsUnsupportedTokenEndpointAuthMethod(t *testing.T) { + t.Parallel() + + // Serve a CIMD doc that declares a non-"none" auth method. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clientID := "http://" + r.Host + r.URL.Path + doc := cimd.ClientMetadataDocument{ + ClientID: clientID, + RedirectURIs: []string{"https://example.com/callback"}, + TokenEndpointAuthMethod: "private_key_jwt", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + require.Error(t, err, "fetch must fail when token_endpoint_auth_method is not \"none\"") + assert.ErrorIs(t, err, fosite.ErrInvalidClient, + "CIMD policy rejections must use ErrInvalidClient, not ErrNotFound") + assert.NotErrorIs(t, err, fosite.ErrNotFound) +} + +// serveCIMDDocWithFields starts an httptest.Server that serves a CIMD document +// customised by the provided mutator function. Pass nil for a plain valid doc. +func serveCIMDDocWithFields(t *testing.T, mutate func(*cimd.ClientMetadataDocument)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/meta.json" { + http.NotFound(w, r) + return + } + doc := cimd.ClientMetadataDocument{ + ClientID: "http://" + r.Host + r.URL.Path, + RedirectURIs: []string{"https://example.com/callback"}, + } + if mutate != nil { + mutate(&doc) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + return srv +} + +// --- grant_types validation --- + +func TestFetch_GrantTypeValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + grantTypes []string + wantErr bool + }{ + {"omitted grant_types accepted", nil, false}, + {"explicit [authorization_code, refresh_token] accepted", []string{"authorization_code", "refresh_token"}, false}, + {"explicit [authorization_code] accepted", []string{"authorization_code"}, false}, + {"refresh_token only missing authorization_code rejected", []string{"refresh_token"}, true}, + {"client_credentials rejected", []string{"client_credentials"}, true}, + {"implicit rejected", []string{"implicit"}, true}, + {"device_code rejected", []string{"urn:ietf:params:oauth:grant-type:device_code"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + srv := serveCIMDDocWithFields(t, func(doc *cimd.ClientMetadataDocument) { + doc.GrantTypes = tt.grantTypes + }) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, fosite.ErrInvalidClient, + "grant_type policy rejections must use ErrInvalidClient") + assert.NotErrorIs(t, err, fosite.ErrNotFound) + } else { + require.NoError(t, err) + } + }) + } +} + +// --- response_types validation --- + +func TestFetch_ResponseTypeValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + responseTypes []string + wantErr bool + }{ + {"omitted response_types accepted", nil, false}, + {"code accepted", []string{"code"}, false}, + {"token rejected", []string{"token"}, true}, + {"code id_token rejected (hybrid)", []string{"code id_token"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + srv := serveCIMDDocWithFields(t, func(doc *cimd.ClientMetadataDocument) { + doc.ResponseTypes = tt.responseTypes + }) + dec := newEnabledDecorator(t, newTestBase(t), 10, time.Minute) + _, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, fosite.ErrInvalidClient, + "response_type policy rejections must use ErrInvalidClient") + assert.NotErrorIs(t, err, fosite.ErrNotFound) + } else { + require.NoError(t, err) + } + }) + } +} + +// --- scope resolution --- + +func TestFetch_ScopeResolution(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + docScope string + scopesSupported []string + baseline []string + wantErr bool + wantScopes []string + }{ + { + name: "no constraint uses DefaultScopes", + docScope: "", + wantScopes: registration.DefaultScopes, + }, + { + name: "explicit scope accepted within ScopesSupported", + docScope: "openid", + scopesSupported: []string{"openid", "profile"}, + wantScopes: []string{"openid"}, + }, + { + name: "explicit scope outside ScopesSupported rejected", + docScope: "openid profile email", + scopesSupported: []string{"openid"}, + wantErr: true, + }, + { + name: "omitted scope with permissive ScopesSupported uses DefaultScopes", + docScope: "", + scopesSupported: []string{"openid", "profile", "email", "offline_access"}, + wantScopes: registration.DefaultScopes, + }, + { + name: "omitted scope with restrictive ScopesSupported requires explicit scope", + docScope: "", + scopesSupported: []string{"openid"}, + wantErr: true, + }, + { + name: "baseline unioned into scope set", + docScope: "openid", + scopesSupported: []string{"openid", "offline_access"}, + baseline: []string{"offline_access"}, + wantScopes: []string{"openid", "offline_access"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + scope := tt.docScope + srv := serveCIMDDocWithFields(t, func(doc *cimd.ClientMetadataDocument) { + doc.Scope = scope + }) + got, err := NewCIMDStorageDecorator(newTestBase(t), CIMDDecoratorConfig{ + Enabled: true, + CacheMaxSize: 10, + FallbackTTL: time.Minute, + ScopesSupported: tt.scopesSupported, + BaselineClientScopes: tt.baseline, + }) + require.NoError(t, err) + dec := got.(*CIMDStorageDecorator) + + client, err := dec.fetchOrCached(context.Background(), srv.URL+"/meta.json") + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, fosite.ErrInvalidClient) + assert.NotErrorIs(t, err, fosite.ErrNotFound) + return + } + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantScopes, []string(client.GetScopes())) + }) + } +} + +// TestBuildFositeClient_ScopeDefaultsToDefaultScopesWhenNoScopesSupported verifies the +// fallback branch in buildFositeClient: nil resolvedScopes → DefaultScopes. +func TestBuildFositeClient_ScopeDefaultsToDefaultScopesWhenNoScopesSupported(t *testing.T) { + t.Parallel() + doc := &cimd.ClientMetadataDocument{ + ClientID: "https://example.com/meta.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + got := buildFositeClient(doc, nil) + assert.ElementsMatch(t, registration.DefaultScopes, []string(got.GetScopes())) +} diff --git a/pkg/authserver/storage/config.go b/pkg/authserver/storage/config.go index e16a00d3c1..4ceaed8e7b 100644 --- a/pkg/authserver/storage/config.go +++ b/pkg/authserver/storage/config.go @@ -12,7 +12,7 @@ const ( // TypeMemory uses in-memory storage (default). TypeMemory Type = "memory" - // TypeRedis uses Redis-compatible storage for distributed deployments. + // TypeRedis uses Redis-backed storage for distributed deployments. TypeRedis Type = "redis" // AuthTypeACLUser is the Redis ACL user authentication type. @@ -67,15 +67,25 @@ type RunConfig struct { } // RedisRunConfig is the serializable Redis configuration for RunConfig. -// This supports either direct Redis-compatible endpoints (for example DragonflyDB) -// or Redis Sentinel deployments with ACL user authentication. +// Exactly one of Addr (standalone/cluster) or SentinelConfig must be set. +// Set ClusterMode to true when Addr points to a Redis Cluster discovery endpoint. type RedisRunConfig struct { - // Address is the Redis-compatible server address for direct mode (host:port). + // Addr is the Redis server address (host:port). Required for standalone and cluster modes. // Mutually exclusive with SentinelConfig. - Address string `json:"address,omitempty" yaml:"address,omitempty"` + Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` - // SentinelConfig contains Sentinel-specific configuration for HA deployments. - // Mutually exclusive with Address. + // LegacyAddress is the pre-v0.27 serialized name of Addr. DocPlanner fork: + // still read so an image can start from a RunConfig written by an older + // operator. Never written by the current operator. Remove with PAIINFRA-228. + // + // Deprecated: use Addr. + LegacyAddress string `json:"address,omitempty" yaml:"address,omitempty"` + + // ClusterMode enables the Redis Cluster protocol. Requires Addr to be set. + ClusterMode bool `json:"cluster_mode,omitempty" yaml:"cluster_mode,omitempty"` + + // SentinelConfig contains Sentinel-specific configuration. + // Mutually exclusive with Addr. SentinelConfig *SentinelRunConfig `json:"sentinel_config,omitempty" yaml:"sentinel_config,omitempty"` // AuthType must be "aclUser" - only ACL user authentication is supported. @@ -96,11 +106,10 @@ type RedisRunConfig struct { // WriteTimeout is the timeout for write operations (e.g., "3s"). WriteTimeout string `json:"write_timeout,omitempty" yaml:"write_timeout,omitempty"` - // TLS configures TLS for Redis/Valkey master connections. + // TLS configures TLS for Redis/Valkey master or cluster node connections. TLS *RedisTLSRunConfig `json:"tls,omitempty" yaml:"tls,omitempty"` - // SentinelTLS configures TLS for Sentinel connections. - // Falls back to TLS config when nil. + // SentinelTLS configures TLS for Sentinel connections. Only applies when SentinelConfig is set. SentinelTLS *RedisTLSRunConfig `json:"sentinel_tls,omitempty" yaml:"sentinel_tls,omitempty"` } @@ -135,3 +144,14 @@ type ACLUserRunConfig struct { // PasswordEnvVar is the environment variable containing the Redis password. PasswordEnvVar string `json:"password_env_var" yaml:"password_env_var"` } + +// EffectiveAddr returns Addr, falling back to the legacy serialized address. +func (c *RedisRunConfig) EffectiveAddr() string { + if c == nil { + return "" + } + if c.Addr != "" { + return c.Addr + } + return c.LegacyAddress +} diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index a6c03e2684..144a5dbc18 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -94,6 +94,20 @@ type MemoryStorage struct { // This enables O(1) lookup during authentication callbacks. providerIdentities map[string]*ProviderIdentity + // dcrCredentials maps DCRKey -> DCRCredentials for RFC 7591 Dynamic Client + // Registration credentials. Entries are intentionally excluded from the + // periodic cleanupExpired loop: DCR registrations are long-lived and the + // authoritative expiry signal is RFC 7591 client_secret_expires_at, which + // is honored at read time by callers (and by the future Redis backend's + // SetEX TTL). Growth is bounded by upstream count × distinct scope sets + // ever registered for each upstream during the process lifetime; for a + // stable configuration this collapses to the upstream count, but rotating + // scope sets (operator-driven scope changes, or upstream + // scopes_supported rotations re-derived by the resolver) accumulate + // stale entries that survive until process restart. The Redis backend's + // SetEX TTL mitigates this in production deployments. + dcrCredentials map[DCRKey]*DCRCredentials + // cleanupInterval is how often the background cleanup runs cleanupInterval time.Duration @@ -129,6 +143,7 @@ func NewMemoryStorage(opts ...MemoryStorageOption) *MemoryStorage { clientAssertionJWTs: make(map[string]time.Time), users: make(map[string]*User), providerIdentities: make(map[string]*ProviderIdentity), + dcrCredentials: make(map[DCRKey]*DCRCredentials), cleanupInterval: DefaultCleanupInterval, stopCleanup: make(chan struct{}), cleanupDone: make(chan struct{}), @@ -222,7 +237,11 @@ func (s *MemoryStorage) cleanupExpired() { var expiredUpstreamTokens []upstreamKey for k, v := range s.upstreamTokens { - if now.After(v.expiresAt) { + // Zero expiresAt is the sentinel for "no TTL" (non-expiring token with no session + // bound). Other entry types never use zero expiresAt, so only upstream tokens need + // this guard — without it, time.Time{} would compare as before any real time and + // every non-expiring token would be swept on the next tick. + if !v.expiresAt.IsZero() && now.After(v.expiresAt) { expiredUpstreamTokens = append(expiredUpstreamTokens, k) } } @@ -327,6 +346,13 @@ func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) return nil } +// RenewClientTTL is a no-op for the in-memory backend: clients are held for the +// process lifetime with no TTL, so there is nothing to renew. The behavior that +// matters for distributed deployments lives in RedisStorage.RenewClientTTL. +func (*MemoryStorage) RenewClientTTL(_ context.Context, _ fosite.Client) error { + return nil +} + // ----------------------- // fosite.ClientManager // ----------------------- @@ -704,25 +730,33 @@ func (s *MemoryStorage) StoreUpstreamTokens(_ context.Context, sessionID, provid now := time.Now() // Add DefaultRefreshTokenTTL beyond access token expiry so the refresh token // survives in storage for transparent token refresh by the middleware. - var expiresAt time.Time - if tokens != nil && !tokens.ExpiresAt.IsZero() { - expiresAt = tokens.ExpiresAt.Add(DefaultRefreshTokenTTL) - } else { - expiresAt = now.Add(DefaultAccessTokenTTL + DefaultRefreshTokenTTL) - } + // Zero ExpiresAt means the token never expires; no TTL is applied. + expiresAt := func() time.Time { + if tokens == nil { + return now.Add(DefaultAccessTokenTTL + DefaultRefreshTokenTTL) + } + if !tokens.ExpiresAt.IsZero() { + return tokens.ExpiresAt.Add(DefaultRefreshTokenTTL) + } + if !tokens.SessionExpiresAt.IsZero() { + return tokens.SessionExpiresAt.Add(DefaultRefreshTokenTTL) + } + return time.Time{} // non-expiring token with no known session bound + }() // Make a defensive copy to prevent aliasing issues var tokensCopy *UpstreamTokens if tokens != nil { tokensCopy = &UpstreamTokens{ - ProviderID: tokens.ProviderID, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - IDToken: tokens.IDToken, - ExpiresAt: tokens.ExpiresAt, - UserID: tokens.UserID, - UpstreamSubject: tokens.UpstreamSubject, - ClientID: tokens.ClientID, + ProviderID: tokens.ProviderID, + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + IDToken: tokens.IDToken, + ExpiresAt: tokens.ExpiresAt, + SessionExpiresAt: tokens.SessionExpiresAt, + UserID: tokens.UserID, + UpstreamSubject: tokens.UpstreamSubject, + ClientID: tokens.ClientID, } } @@ -734,6 +768,24 @@ func (s *MemoryStorage) StoreUpstreamTokens(_ context.Context, sessionID, provid return nil } +// cloneUpstreamTokens returns a field-by-field copy of t, or nil if t is nil. +func cloneUpstreamTokens(t *UpstreamTokens) *UpstreamTokens { + if t == nil { + return nil + } + return &UpstreamTokens{ + ProviderID: t.ProviderID, + AccessToken: t.AccessToken, + RefreshToken: t.RefreshToken, + IDToken: t.IDToken, + ExpiresAt: t.ExpiresAt, + SessionExpiresAt: t.SessionExpiresAt, + UserID: t.UserID, + UpstreamSubject: t.UpstreamSubject, + ClientID: t.ClientID, + } +} + // GetUpstreamTokens retrieves the upstream IDP tokens for a session and provider. // Returns a defensive copy to prevent aliasing issues. func (s *MemoryStorage) GetUpstreamTokens(_ context.Context, sessionID, providerName string) (*UpstreamTokens, error) { @@ -754,20 +806,10 @@ func (s *MemoryStorage) GetUpstreamTokens(_ context.Context, sessionID, provider } // Return a defensive copy to prevent aliasing issues - tokens := entry.value - if tokens == nil { + result := cloneUpstreamTokens(entry.value) + if result == nil { return nil, nil } - result := &UpstreamTokens{ - ProviderID: tokens.ProviderID, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - IDToken: tokens.IDToken, - ExpiresAt: tokens.ExpiresAt, - UserID: tokens.UserID, - UpstreamSubject: tokens.UpstreamSubject, - ClientID: tokens.ClientID, - } // Check the token's own ExpiresAt (access token expiry), not the entry's expiresAt // (storage TTL which includes DefaultRefreshTokenTTL buffer for refresh token survival). @@ -793,22 +835,8 @@ func (s *MemoryStorage) GetAllUpstreamTokens(_ context.Context, sessionID string if key.sessionID != sessionID { continue } - tokens := entry.value - if tokens == nil { - result[key.providerName] = nil - continue - } - // Defensive copy - result[key.providerName] = &UpstreamTokens{ - ProviderID: tokens.ProviderID, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - IDToken: tokens.IDToken, - ExpiresAt: tokens.ExpiresAt, - UserID: tokens.UserID, - UpstreamSubject: tokens.UpstreamSubject, - ClientID: tokens.ClientID, - } + // Defensive copy (cloneUpstreamTokens handles nil) + result[key.providerName] = cloneUpstreamTokens(entry.value) } return result, nil @@ -832,6 +860,56 @@ func (s *MemoryStorage) DeleteUpstreamTokens(_ context.Context, sessionID string return nil } +// compareExpiry orders ExpiresAt values for the GetLatestUpstreamTokensForUser +// tie-breaker. Non-expiring rows (zero ExpiresAt — "alive forever") rank latest; +// among finite expiries, later ranks latest. Mirrors time.Compare but with the +// zero sentinel reinterpreted. Returns -1/0/+1. +func compareExpiry(a, b time.Time) int { + aZero, bZero := a.IsZero(), b.IsZero() + switch { + case aZero && bZero: + return 0 + case aZero: + return 1 + case bZero: + return -1 + } + return a.Compare(b) +} + +// GetLatestUpstreamTokensForUser implements UpstreamTokenStorage. +// +// Expired tokens (past ExpiresAt) are returned so callers can use the refresh +// token; filtering by access-token expiry is the caller's responsibility. +// See the interface declaration in types.go for the full contract. +func (s *MemoryStorage) GetLatestUpstreamTokensForUser(_ context.Context, userID, providerID string) (*UpstreamTokens, error) { + if userID == "" { + return nil, fosite.ErrInvalidRequest.WithHint("user ID cannot be empty") + } + if providerID == "" { + return nil, fosite.ErrInvalidRequest.WithHint("provider ID cannot be empty") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + var winner *UpstreamTokens + for _, entry := range s.upstreamTokens { + if entry.value == nil || entry.value.UserID != userID || entry.value.ProviderID != providerID { + continue + } + if winner == nil || compareExpiry(entry.value.ExpiresAt, winner.ExpiresAt) > 0 { + winner = entry.value + } + } + + if winner == nil { + return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + } + + return cloneUpstreamTokens(winner), nil +} + // ----------------------- // Pending Authorization Storage // ----------------------- @@ -1131,6 +1209,65 @@ func (s *MemoryStorage) GetUserProviderIdentities(_ context.Context, userID stri return identities, nil } +// ----------------------- +// DCR Credentials Storage +// ----------------------- + +// cloneDCRCredentials returns a field-by-field copy of c, or nil if c is nil. +// All fields are values (no slices, maps, or pointers), so a shallow copy is +// sufficient — adding a new reference-typed field requires updating this +// helper to deep-copy that field. +func cloneDCRCredentials(c *DCRCredentials) *DCRCredentials { + if c == nil { + return nil + } + cp := *c + return &cp +} + +// StoreDCRCredentials persists DCR credentials for the given key. +// The credentials are stored under their own Key field; callers must populate +// it before calling. A defensive copy is made so subsequent caller mutations +// do not affect persisted state. +// +// Overwrites any existing entry for the same Key. The in-memory backend +// applies no native TTL — DCR registrations are long-lived and bounded by +// the operator-configured upstream count, and ClientSecretExpiresAt is +// retained verbatim for callers to re-check on read (see the interface +// docstring's "TTL handling" section). +// +// Validation is delegated to validateDCRCredentialsForStore so the rejection +// set stays in sync with sibling backends. +func (s *MemoryStorage) StoreDCRCredentials(_ context.Context, creds *DCRCredentials) error { + if err := validateDCRCredentialsForStore(creds); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.dcrCredentials[creds.Key] = cloneDCRCredentials(creds) + return nil +} + +// GetDCRCredentials retrieves DCR credentials by key. +// Returns a defensive copy; returns ErrNotFound (wrapped) on miss. +func (s *MemoryStorage) GetDCRCredentials(_ context.Context, key DCRKey) (*DCRCredentials, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.dcrCredentials[key] + if !ok { + slog.Debug("dcr credentials not found", + "issuer", key.Issuer, + "redirect_uri", key.RedirectURI, + ) + return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("DCR credentials not found")) + } + + return cloneDCRCredentials(entry), nil +} + // ----------------------- // Metrics/Stats (for testing and monitoring) // ----------------------- @@ -1148,6 +1285,7 @@ type Stats struct { ClientAssertionJWTs int Users int ProviderIdentities int + DCRCredentials int } // Stats returns current statistics about storage contents. @@ -1168,6 +1306,7 @@ func (s *MemoryStorage) Stats() Stats { ClientAssertionJWTs: len(s.clientAssertionJWTs), Users: len(s.users), ProviderIdentities: len(s.providerIdentities), + DCRCredentials: len(s.dcrCredentials), } } @@ -1178,4 +1317,5 @@ var ( _ ClientRegistry = (*MemoryStorage)(nil) _ UpstreamTokenStorage = (*MemoryStorage)(nil) _ UserStorage = (*MemoryStorage)(nil) + _ DCRCredentialStore = (*MemoryStorage)(nil) ) diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index 113a531b10..8b9036f90f 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -23,6 +23,7 @@ import ( "fmt" "net/url" "sync" + "sync/atomic" "testing" "time" @@ -220,6 +221,19 @@ func TestMemoryStorage_RegisterClient(t *testing.T) { }) } +func TestMemoryStorage_RenewClientTTL_NoOp(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // In-memory clients have no TTL, so renewal is a documented no-op: it must + // not error and must leave the client retrievable. + client := &mockClient{id: "public-client", public: true} + require.NoError(t, s.RegisterClient(ctx, client)) + require.NoError(t, s.RenewClientTTL(ctx, client)) + retrieved, err := s.GetClient(ctx, "public-client") + require.NoError(t, err) + assert.Equal(t, "public-client", retrieved.GetID()) + }) +} + func TestMemoryStorage_ClientAssertionJWT(t *testing.T) { t.Parallel() tests := []struct { @@ -621,6 +635,206 @@ func TestMemoryStorage_UpstreamTokens(t *testing.T) { }) } +func TestMemoryStorage_GetLatestUpstreamTokensForUser(t *testing.T) { + t.Parallel() + + t.Run("no_match", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("one_match_returns_deep_copy", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-1", + ExpiresAt: time.Now().Add(time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "rt-1", got.RefreshToken) + + // Mutate the returned copy and confirm the stored value is unchanged. + got.RefreshToken = "mutated" + got2, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + assert.Equal(t, "rt-1", got2.RefreshToken, "stored row must be unaffected by mutation of returned copy") + }) + }) + + t.Run("multiple_sessions_pick_latest_expires_at", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + now := time.Now() + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-1h", + ExpiresAt: now.Add(time.Hour), + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-2", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-2h", + ExpiresAt: now.Add(2 * time.Hour), + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-3", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-3h", + ExpiresAt: now.Add(3 * time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + assert.Equal(t, "rt-3h", got.RefreshToken) + }) + }) + + t.Run("different_user_not_matched", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-B", + ExpiresAt: time.Now().Add(time.Hour), + })) + + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("different_provider_not_matched", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // The lookup filters on the stored UpstreamTokens.ProviderID field, + // not the map key. StoreUpstreamTokens copies the field from the + // input struct, so they always match here. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-Y", &UpstreamTokens{ + ProviderID: "prov-Y", + UserID: "user-A", + ExpiresAt: time.Now().Add(time.Hour), + })) + + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("tolerate_access_token_expired", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // ExpiresAt is 1h in the past (access token expired), but the storage TTL + // is ExpiresAt + DefaultRefreshTokenTTL which is still in the future, + // so the cleanup loop has not swept this row yet. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-expired-at", + ExpiresAt: time.Now().Add(-time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "rt-expired-at", got.RefreshToken) + }) + }) + + t.Run("zero_expires_at_wins_over_nonzero", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // Row with zero ExpiresAt — providers like Slack and GitHub OAuth Apps + // genuinely never expire; treated as "alive forever" by IsExpired. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero", + ExpiresAt: time.Time{}, + })) + // Row with a real ExpiresAt in the future. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-nonzero", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-nonzero", + ExpiresAt: time.Now().Add(time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + assert.Equal(t, "rt-zero", got.RefreshToken) + }) + }) + + t.Run("two_zero_expires_at_rows", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // Both rows have zero ExpiresAt. Go's map iteration is randomized so + // we only assert that one of the two is returned. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero-1", + ExpiresAt: time.Time{}, + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero-2", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero-2", + ExpiresAt: time.Time{}, + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Contains(t, []string{"rt-zero-1", "rt-zero-2"}, got.RefreshToken) + }) + }) + + t.Run("empty_user_id", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.GetLatestUpstreamTokensForUser(ctx, "", "prov-X") + require.Error(t, err) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) + }) + }) + + t.Run("empty_provider_id", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "") + require.Error(t, err) + require.ErrorIs(t, err, fosite.ErrInvalidRequest) + }) + }) + + t.Run("returns_all_fields_round_trip", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + now := time.Now().Truncate(time.Second) + fixture := UpstreamTokens{ + ProviderID: "prov-X", + AccessToken: "access-tok", + RefreshToken: "refresh-tok", + IDToken: "id-tok", + ExpiresAt: now.Add(time.Hour), + SessionExpiresAt: now.Add(2 * time.Hour), + UserID: "user-A", + UpstreamSubject: "sub-upstream", + ClientID: "client-1", + } + + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-rt", "prov-X", &fixture)) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, fixture, *got) + }) + }) +} + // --- Pending Authorization Tests --- func TestMemoryStorage_PendingAuthorization(t *testing.T) { @@ -819,6 +1033,65 @@ func TestMemoryStorage_CleanupExpired(t *testing.T) { }) } + t.Run("upstream tokens with zero ExpiresAt are never evicted", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + // Non-expiring token (ExpiresAt zero) must survive cleanup runs. + _ = s.StoreUpstreamTokens(ctx, "never-expiring", "provider-a", &UpstreamTokens{ + AccessToken: "non-expiring-token", + // ExpiresAt intentionally zero + }) + // Expiring token to confirm cleanup still works. + _ = s.StoreUpstreamTokens(ctx, "expired", "provider-a", &UpstreamTokens{ + AccessToken: "expired-token", + ExpiresAt: time.Now().Add(-DefaultRefreshTokenTTL - time.Hour), + }) + + assert.Equal(t, 2, s.Stats().UpstreamTokens) + + s.cleanupExpired() + + assert.Equal(t, 1, s.Stats().UpstreamTokens, "only the expiring token should be removed") + + tokens, err := s.GetUpstreamTokens(ctx, "never-expiring", "provider-a") + require.NoError(t, err) + require.NotNil(t, tokens) + assert.Equal(t, "non-expiring-token", tokens.AccessToken) + assert.True(t, tokens.ExpiresAt.IsZero()) + }) + }) + + t.Run("non-expiring token with SessionExpiresAt is evicted after session bound passes", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + err := s.StoreUpstreamTokens(ctx, "sess-1", "github", &UpstreamTokens{ + AccessToken: "pat-token", + SessionExpiresAt: time.Now().Add(-DefaultRefreshTokenTTL - time.Second), + }) + require.NoError(t, err) + assert.Equal(t, 1, s.Stats().UpstreamTokens) + + s.cleanupExpired() + + assert.Equal(t, 0, s.Stats().UpstreamTokens) + _, getErr := s.GetUpstreamTokens(ctx, "sess-1", "github") + requireNotFoundError(t, getErr) + }) + }) + + t.Run("non-expiring token with future SessionExpiresAt is kept", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + err := s.StoreUpstreamTokens(ctx, "sess-2", "github", &UpstreamTokens{ + AccessToken: "pat-token", + SessionExpiresAt: time.Now().Add(time.Hour), + }) + require.NoError(t, err) + assert.Equal(t, 1, s.Stats().UpstreamTokens) + + s.cleanupExpired() + + assert.Equal(t, 1, s.Stats().UpstreamTokens) + }) + }) + t.Run("cleanup expired invalidated codes", func(t *testing.T) { withStorage(t, func(ctx context.Context, s *MemoryStorage) { request := newMockRequesterWithExpiration("req-1", client, fosite.AuthorizeCode, time.Now().Add(time.Hour)) @@ -1458,3 +1731,412 @@ func TestMemoryStorage_ConcurrentAccess(t *testing.T) { }) }) } + +func TestMemoryStorage_DCRCredentials_RoundTrip(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://thv.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: ScopesHash([]string{"openid", "profile"}), + } + creds := &DCRCredentials{ + Key: key, + ProviderName: "atlassian", + ClientID: "client-abc", + ClientSecret: "secret-xyz", + TokenEndpointAuthMethod: "client_secret_basic", + RegistrationAccessToken: "rat-123", + RegistrationClientURI: "https://idp.example.com/register/client-abc", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + CreatedAt: time.Date(2025, 5, 1, 12, 0, 0, 0, time.UTC), + ClientSecretExpiresAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), + } + + require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + require.NotNil(t, got) + + // Every field round-trips, including the embedded Key. + assert.Equal(t, *creds, *got) + }) +} + +func TestMemoryStorage_DCRCredentials_DistinctKeysDoNotCollide(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + mkKey := func(issuer, redirect string, scopes []string) DCRKey { + return DCRKey{ + Issuer: issuer, + RedirectURI: redirect, + ScopesHash: ScopesHash(scopes), + } + } + mkCreds := func(key DCRKey, clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + entries := []*DCRCredentials{ + mkCreds(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid"}), "a"), + mkCreds(mkKey("https://idp-b.example.com", "https://x/cb", []string{"openid"}), "b"), + mkCreds(mkKey("https://idp-a.example.com", "https://y/cb", []string{"openid"}), "c"), + mkCreds(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid", "email"}), "d"), + } + for _, e := range entries { + require.NoError(t, s.StoreDCRCredentials(ctx, e)) + } + + for _, want := range entries { + got, err := s.GetDCRCredentials(ctx, want.Key) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want.ClientID, got.ClientID) + } + }) +} + +func TestMemoryStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + mkCreds := func(clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + require.NoError(t, s.StoreDCRCredentials(ctx, mkCreds("first"))) + require.NoError(t, s.StoreDCRCredentials(ctx, mkCreds("second"))) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "second", got.ClientID) + }) +} + +func TestMemoryStorage_DCRCredentials_NotFound(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.GetDCRCredentials(ctx, DCRKey{Issuer: "https://unknown.example.com"}) + requireNotFoundError(t, err) + }) +} + +// TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected pins the +// fail-loud-on-invalid-input contract: nil creds, an unpopulated Key +// (empty Issuer, RedirectURI, or ScopesHash), and missing RFC 7591 +// mandatory response fields (ClientID, AuthorizationEndpoint, +// TokenEndpoint) must be rejected with fosite.ErrInvalidRequest rather +// than producing a working-looking write that fails downstream at the +// upstream's token endpoint. +func TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) { + t.Parallel() + + // validCreds returns a fully-populated DCRCredentials that subtests + // mutate to isolate a single missing field. Keeping every other field + // valid ensures the assertion proves which field was rejected. + validCreds := func() *DCRCredentials { + return &DCRCredentials{ + Key: DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + }, + ClientID: "abc", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + tests := []struct { + name string + mutator func(*DCRCredentials) *DCRCredentials + }{ + { + name: "nil creds", + mutator: func(*DCRCredentials) *DCRCredentials { return nil }, + }, + { + name: "empty issuer", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.Issuer = "" + return c + }, + }, + { + name: "empty redirect_uri", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.RedirectURI = "" + return c + }, + }, + { + name: "empty scopes_hash", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.ScopesHash = "" + return c + }, + }, + { + name: "empty client_id", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.ClientID = "" + return c + }, + }, + { + name: "empty authorization_endpoint", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.AuthorizationEndpoint = "" + return c + }, + }, + { + name: "empty token_endpoint", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.TokenEndpoint = "" + return c + }, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + err := s.StoreDCRCredentials(ctx, tc.mutator(validCreds())) + require.Error(t, err) + assert.ErrorIs(t, err, fosite.ErrInvalidRequest) + // Confirm the rejection did not partially populate the store. + assert.Equal(t, 0, s.Stats().DCRCredentials, + "rejected Store must not leave any entry behind") + }) + }) + } +} + +// TestMemoryStorage_DCRCredentials_GetReturnsDefensiveCopy pins the +// defensive-copy contract: a caller mutating the returned record must not +// affect persisted state. +func TestMemoryStorage_DCRCredentials_GetReturnsDefensiveCopy(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "orig", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + })) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + got.ClientID = "tampered-by-caller" + + refetched, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "orig", refetched.ClientID) + }) +} + +// TestMemoryStorage_DCRCredentials_StoreCopyIsolatesCaller pins the +// store-side defensive-copy contract: a caller mutating the input *after* +// Store must not affect persisted state. +func TestMemoryStorage_DCRCredentials_StoreCopyIsolatesCaller(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + input := &DCRCredentials{ + Key: key, + ClientID: "orig", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + require.NoError(t, s.StoreDCRCredentials(ctx, input)) + + input.ClientID = "tampered-after-store" + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "orig", got.ClientID) + }) +} + +// TestMemoryStorage_DCRCredentials_ExcludedFromCleanupExpired pins the +// invariant that DCR entries are NOT swept by cleanupExpired. The Redis +// backend applies TTL via SetEX; the in-memory backend keeps entries for the +// process lifetime. +func TestMemoryStorage_DCRCredentials_ExcludedFromCleanupExpired(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "abc", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + CreatedAt: time.Now().Add(-365 * 24 * time.Hour), + })) + + s.cleanupExpired() + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "abc", got.ClientID) + }) +} + +// TestMemoryStorage_DCRCredentials_ConcurrentAccess fans out N goroutines +// performing alternating StoreDCRCredentials / GetDCRCredentials against +// overlapping and disjoint keys, exercising the sync.RWMutex guard +// advertised in the DCRCredentialStore contract. With go test -race this +// catches a future change that drops the lock or returns an internal +// pointer instead of a defensive copy. +// +// The test is bounded by a fail-fast deadline so a regression that +// deadlocks fails loudly rather than hanging until the global Go test +// timeout, per .claude/rules/testing.md. +func TestMemoryStorage_DCRCredentials_ConcurrentAccess(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + const ( + workers = 16 + opsPerWorker = 200 + ) + + // Two key spaces: overlapping (every worker writes the same keys, so + // the lock must serialise their writes) and disjoint (each worker has + // its own key space, so reads never see another worker's writes). + overlappingKey := func(i int) DCRKey { + return DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: fmt.Sprintf("overlap-%d", i%4), + } + } + disjointKey := func(worker, i int) DCRKey { + return DCRKey{ + Issuer: fmt.Sprintf("https://idp-%d.example.com", worker), + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: fmt.Sprintf("disjoint-%d", i), + } + } + mkCreds := func(key DCRKey, clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + var errCount int32 + var wg sync.WaitGroup + wg.Add(workers) + for w := 0; w < workers; w++ { + go func(worker int) { + defer wg.Done() + for i := 0; i < opsPerWorker; i++ { + var key DCRKey + if i%2 == 0 { + key = overlappingKey(i) + } else { + key = disjointKey(worker, i) + } + if err := s.StoreDCRCredentials(ctx, mkCreds(key, fmt.Sprintf("worker-%d-op-%d", worker, i))); err != nil { + atomic.AddInt32(&errCount, 1) + } + // The disjoint Get must always hit (the goroutine that + // just wrote owns this key); the overlapping Get may + // miss if another worker happens to be mid-Store, but + // that's not an error. + if _, err := s.GetDCRCredentials(ctx, key); err != nil && i%2 != 0 { + atomic.AddInt32(&errCount, 1) + } + } + }(w) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent DCR Store/Get to finish; possible deadlock") + } + + assert.Zero(t, atomic.LoadInt32(&errCount), + "no Store or disjoint-Get should have errored under concurrent access") + }) +} + +// TestScopesHash_StableAcrossPermutationAndDuplicates pins the canonical-form +// invariants of ScopesHash. +func TestScopesHash_StableAcrossPermutationAndDuplicates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b []string + }{ + {"two-element permutation", []string{"openid", "profile"}, []string{"profile", "openid"}}, + {"three-element permutation", []string{"openid", "profile", "email"}, []string{"email", "openid", "profile"}}, + // OAuth scope sets are sets, not multisets (RFC 6749 §3.3); duplicates + // must canonicalise to the same hash. + {"single equals double duplicate", []string{"openid"}, []string{"openid", "openid"}}, + {"three with duplicate equals two unique", []string{"openid", "profile", "openid"}, []string{"openid", "profile"}}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, ScopesHash(tc.a), ScopesHash(tc.b)) + }) + } +} + +// TestScopesHash_NoCollisionFromBoundaryJoin guards against a regression in +// the canonical form: ["ab", "c"] and ["a", "bc"] must produce different +// hashes. The newline delimiter is what prevents the collision; this test +// exists to fail loudly if the canonical form is ever simplified. +func TestScopesHash_NoCollisionFromBoundaryJoin(t *testing.T) { + t.Parallel() + assert.NotEqual(t, ScopesHash([]string{"ab", "c"}), ScopesHash([]string{"a", "bc"})) +} + +// TestScopesHash_DistinctForDistinctScopes pins that distinct non-empty +// scope sets hash to distinct values, while nil and empty slice canonicalise +// to the same value (both reduce to the empty canonical form). +func TestScopesHash_DistinctForDistinctScopes(t *testing.T) { + t.Parallel() + + a := ScopesHash([]string{"openid"}) + b := ScopesHash([]string{"openid", "profile"}) + c := ScopesHash([]string{"profile"}) + d := ScopesHash(nil) + e := ScopesHash([]string{}) + + assert.NotEqual(t, a, b) + assert.NotEqual(t, a, c) + assert.NotEqual(t, b, c) + assert.NotEqual(t, a, d) + assert.Equal(t, d, e) +} diff --git a/pkg/authserver/storage/mocks/mock_storage.go b/pkg/authserver/storage/mocks/mock_storage.go index ceeb57e575..d4dc2ab7ac 100644 --- a/pkg/authserver/storage/mocks/mock_storage.go +++ b/pkg/authserver/storage/mocks/mock_storage.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage +// mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore // // Package mocks is a generated GoMock package. @@ -19,6 +19,59 @@ import ( gomock "go.uber.org/mock/gomock" ) +// MockDCRCredentialStore is a mock of DCRCredentialStore interface. +type MockDCRCredentialStore struct { + ctrl *gomock.Controller + recorder *MockDCRCredentialStoreMockRecorder + isgomock struct{} +} + +// MockDCRCredentialStoreMockRecorder is the mock recorder for MockDCRCredentialStore. +type MockDCRCredentialStoreMockRecorder struct { + mock *MockDCRCredentialStore +} + +// NewMockDCRCredentialStore creates a new mock instance. +func NewMockDCRCredentialStore(ctrl *gomock.Controller) *MockDCRCredentialStore { + mock := &MockDCRCredentialStore{ctrl: ctrl} + mock.recorder = &MockDCRCredentialStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDCRCredentialStore) EXPECT() *MockDCRCredentialStoreMockRecorder { + return m.recorder +} + +// GetDCRCredentials mocks base method. +func (m *MockDCRCredentialStore) GetDCRCredentials(ctx context.Context, key storage.DCRKey) (*storage.DCRCredentials, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDCRCredentials", ctx, key) + ret0, _ := ret[0].(*storage.DCRCredentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDCRCredentials indicates an expected call of GetDCRCredentials. +func (mr *MockDCRCredentialStoreMockRecorder) GetDCRCredentials(ctx, key any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDCRCredentials", reflect.TypeOf((*MockDCRCredentialStore)(nil).GetDCRCredentials), ctx, key) +} + +// StoreDCRCredentials mocks base method. +func (m *MockDCRCredentialStore) StoreDCRCredentials(ctx context.Context, creds *storage.DCRCredentials) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreDCRCredentials", ctx, creds) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreDCRCredentials indicates an expected call of StoreDCRCredentials. +func (mr *MockDCRCredentialStoreMockRecorder) StoreDCRCredentials(ctx, creds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreDCRCredentials", reflect.TypeOf((*MockDCRCredentialStore)(nil).StoreDCRCredentials), ctx, creds) +} + // MockPendingAuthorizationStorage is a mock of PendingAuthorizationStorage interface. type MockPendingAuthorizationStorage struct { ctrl *gomock.Controller @@ -153,6 +206,20 @@ func (mr *MockClientRegistryMockRecorder) RegisterClient(ctx, client any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterClient", reflect.TypeOf((*MockClientRegistry)(nil).RegisterClient), ctx, client) } +// RenewClientTTL mocks base method. +func (m *MockClientRegistry) RenewClientTTL(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewClientTTL", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewClientTTL indicates an expected call of RenewClientTTL. +func (mr *MockClientRegistryMockRecorder) RenewClientTTL(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewClientTTL", reflect.TypeOf((*MockClientRegistry)(nil).RenewClientTTL), ctx, client) +} + // SetClientAssertionJWT mocks base method. func (m *MockClientRegistry) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { m.ctrl.T.Helper() @@ -220,6 +287,21 @@ func (mr *MockUpstreamTokenStorageMockRecorder) GetAllUpstreamTokens(ctx, sessio return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllUpstreamTokens", reflect.TypeOf((*MockUpstreamTokenStorage)(nil).GetAllUpstreamTokens), ctx, sessionID) } +// GetLatestUpstreamTokensForUser mocks base method. +func (m *MockUpstreamTokenStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userID, providerID string) (*storage.UpstreamTokens, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestUpstreamTokensForUser", ctx, userID, providerID) + ret0, _ := ret[0].(*storage.UpstreamTokens) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestUpstreamTokensForUser indicates an expected call of GetLatestUpstreamTokensForUser. +func (mr *MockUpstreamTokenStorageMockRecorder) GetLatestUpstreamTokensForUser(ctx, userID, providerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestUpstreamTokensForUser", reflect.TypeOf((*MockUpstreamTokenStorage)(nil).GetLatestUpstreamTokensForUser), ctx, userID, providerID) +} + // GetUpstreamTokens mocks base method. func (m *MockUpstreamTokenStorage) GetUpstreamTokens(ctx context.Context, sessionID, providerName string) (*storage.UpstreamTokens, error) { m.ctrl.T.Helper() @@ -693,6 +775,21 @@ func (mr *MockStorageMockRecorder) GetClient(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClient", reflect.TypeOf((*MockStorage)(nil).GetClient), ctx, id) } +// GetLatestUpstreamTokensForUser mocks base method. +func (m *MockStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userID, providerID string) (*storage.UpstreamTokens, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestUpstreamTokensForUser", ctx, userID, providerID) + ret0, _ := ret[0].(*storage.UpstreamTokens) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestUpstreamTokensForUser indicates an expected call of GetLatestUpstreamTokensForUser. +func (mr *MockStorageMockRecorder) GetLatestUpstreamTokensForUser(ctx, userID, providerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestUpstreamTokensForUser", reflect.TypeOf((*MockStorage)(nil).GetLatestUpstreamTokensForUser), ctx, userID, providerID) +} + // GetPKCERequestSession mocks base method. func (m *MockStorage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { m.ctrl.T.Helper() @@ -840,6 +937,20 @@ func (mr *MockStorageMockRecorder) RegisterClient(ctx, client any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterClient", reflect.TypeOf((*MockStorage)(nil).RegisterClient), ctx, client) } +// RenewClientTTL mocks base method. +func (m *MockStorage) RenewClientTTL(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewClientTTL", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewClientTTL indicates an expected call of RenewClientTTL. +func (mr *MockStorageMockRecorder) RenewClientTTL(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewClientTTL", reflect.TypeOf((*MockStorage)(nil).RenewClientTTL), ctx, client) +} + // RevokeAccessToken mocks base method. func (m *MockStorage) RevokeAccessToken(ctx context.Context, requestID string) error { m.ctrl.T.Helper() diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index d2e15d4fcb..61ac51b6c8 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -5,33 +5,36 @@ package storage import ( "context" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "fmt" "log/slog" - "net" "net/url" "slices" + "strings" "time" "github.com/ory/fosite" "github.com/redis/go-redis/v9" + tcredis "github.com/stacklok/toolhive-core/redis" "github.com/stacklok/toolhive/pkg/authserver/server/session" ) -// Default timeouts for Redis operations. -const ( - DefaultDialTimeout = 5 * time.Second - DefaultReadTimeout = 3 * time.Second - DefaultWriteTimeout = 3 * time.Second -) - // nullMarker is used to store nil upstream tokens in Redis. const nullMarker = "null" +// pastExpiryDCRTTL is the bounded TTL applied when a caller writes DCR +// credentials whose ClientSecretExpiresAt is already in the past. The row is +// still accepted (the resolver decides when to re-register) but it self-evicts +// almost immediately so the store does not hold an already-expired secret +// forever. One second is small enough that no caller can usefully read the +// row, large enough that real Redis applies the TTL reliably (sub-second TTLs +// are technically supported via PEXPIRE but the second-grain TTL command is +// the broadly-tested path), and short enough that operational metrics do not +// confuse this row with a healthy long-lived registration. +const pastExpiryDCRTTL = time.Second + // warnOnCleanupErr logs a warning when a best-effort cleanup operation fails. // // Secondary index cleanup in Redis (SRem from reverse-lookup sets, Del of orphaned @@ -50,64 +53,39 @@ func warnOnCleanupErr(err error, operation, key string) { } } -// RedisConfig holds Redis connection configuration for runtime use. -type RedisConfig struct { - // Address is the Redis-compatible server address for direct mode (e.g. DragonflyDB). - // Mutually exclusive with SentinelConfig. - Address string - - // SentinelConfig activates Sentinel mode for HA deployments. - // Mutually exclusive with Address. - SentinelConfig *SentinelConfig - - // ACLUserConfig is required - ACL user authentication only. - ACLUserConfig *ACLUserConfig - - // KeyPrefix for multi-tenancy: "thv:auth:{ns}:{name}:". - KeyPrefix string - - // Timeouts (defaults: Dial=5s, Read=3s, Write=3s). - DialTimeout time.Duration - ReadTimeout time.Duration - WriteTimeout time.Duration - - // TLS configures TLS for connections to the Redis/Valkey master. - // When nil, master connections are plaintext. - TLS *RedisTLSConfig - - // SentinelTLS configures TLS for connections to Sentinel instances. - // When nil, sentinel connections are plaintext (no fallback to TLS config). - SentinelTLS *RedisTLSConfig -} - -// RedisTLSConfig holds TLS configuration for Redis connections. -// Presence of this struct enables TLS for the connection type. -type RedisTLSConfig struct { - // InsecureSkipVerify skips certificate verification. - // Use for self-signed certificates (e.g., sentinel emulators). - InsecureSkipVerify bool - - // CACert is the PEM-encoded CA certificate for verifying the server. - // When empty, the system root CAs are used. - CACert []byte -} - -// SentinelConfig contains Redis Sentinel configuration. -type SentinelConfig struct { - MasterName string - SentinelAddrs []string - DB int +// filterIndexMembersByPrefix splits SMEMBERS results into members that share +// this storage instance's key prefix and those that do not. Multi-key +// operations like MGet and Del fail with CROSSSLOT under Redis Cluster when +// any member hashes to a different slot, and a stray un-prefixed entry +// (legacy data, an external admin op, a test fixture) is the most likely +// source of such drift. Filtering at read time turns a hard cluster failure +// into a logged warning, while remaining a no-op on standalone Redis. +func filterIndexMembersByPrefix(prefix string, members []string) (kept, dropped []string) { + for _, m := range members { + if strings.HasPrefix(m, prefix) { + kept = append(kept, m) + } else { + dropped = append(dropped, m) + } + } + return kept, dropped } -// ACLUserConfig contains Redis ACL user authentication configuration. -type ACLUserConfig struct { - Username string - Password string //nolint:gosec // G117: field legitimately holds sensitive data +// warnDroppedIndexMembers emits a warning for each foreign member returned by +// filterIndexMembersByPrefix so operators can identify the source of cluster- +// incompatible entries. +func warnDroppedIndexMembers(operation, indexKey, expectedPrefix string, dropped []string) { + for _, m := range dropped { + slog.Warn("dropping foreign index member to prevent CROSSSLOT", + "operation", operation, "indexKey", indexKey, + "expectedPrefix", expectedPrefix, "member", m) + } } -// RedisStorage implements the Storage interface with a Redis-compatible backend. -// It provides distributed storage for OAuth2 tokens, authorization codes, -// user data, and pending authorizations, enabling horizontal scaling. +// RedisStorage implements the Storage interface backed by Redis. +// Supports standalone mode (single endpoint), Sentinel failover mode, and +// Cluster mode. It provides distributed storage for OAuth2 tokens, authorization +// codes, user data, and pending authorizations, enabling horizontal scaling. type RedisStorage struct { client redis.UniversalClient keyPrefix string @@ -127,157 +105,30 @@ type storedSession struct { Session json.RawMessage `json:"session"` } -// buildTLSConfig creates a *tls.Config from a RedisTLSConfig. -// Returns an error if a CA certificate is provided but cannot be parsed. -func buildTLSConfig(cfg *RedisTLSConfig) (*tls.Config, error) { - if cfg == nil { - return nil, nil - } - tc := &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // G402: configurable per-deployment - } - if len(cfg.CACert) > 0 { - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(cfg.CACert) { - return nil, fmt.Errorf("failed to parse CA certificate PEM data") - } - tc.RootCAs = pool - } - return tc, nil -} - -// newTLSDialer returns a dialer function that applies different TLS configs for -// master vs sentinel connections. When masterTLS is nil, master connections use -// plaintext. When sentinelTLS is nil, sentinel connections use plaintext. -// This is needed because go-redis applies a single TLSConfig to all connections. -func newTLSDialer( - masterTLS, sentinelTLS *tls.Config, - sentinelAddrs []string, - timeout time.Duration, -) func(ctx context.Context, network, addr string) (net.Conn, error) { - return func(_ context.Context, network, addr string) (net.Conn, error) { - d := &net.Dialer{Timeout: timeout} - isSentinel := slices.Contains(sentinelAddrs, addr) - - var tlsCfg *tls.Config - if isSentinel { - tlsCfg = sentinelTLS - } else { - tlsCfg = masterTLS - } - - if tlsCfg == nil { - return d.Dial(network, addr) - } - return tls.DialWithDialer(d, network, addr, tlsCfg) - } -} - -// configureTLSDialer sets up a custom dialer on the FailoverOptions when either -// master or sentinel TLS is enabled. go-redis applies a single TLSConfig to both -// sentinel and master connections, so when they need different treatment we install -// a custom dialer that selects the right config per target address. -func configureTLSDialer(opts *redis.FailoverOptions, masterCfg, sentinelCfg *RedisTLSConfig) error { - if masterCfg == nil && sentinelCfg == nil { - return nil - } - - var masterTLS *tls.Config - if masterCfg != nil { - var err error - masterTLS, err = buildTLSConfig(masterCfg) - if err != nil { - return fmt.Errorf("master TLS config: %w", err) - } - } - - var sentinelTLS *tls.Config - if sentinelCfg != nil { - var err error - sentinelTLS, err = buildTLSConfig(sentinelCfg) - if err != nil { - return fmt.Errorf("sentinel TLS config: %w", err) - } - } - - opts.Dialer = newTLSDialer(masterTLS, sentinelTLS, opts.SentinelAddrs, opts.DialTimeout) - return nil -} - -func buildRedisClient(ctx context.Context, cfg *RedisConfig) (redis.UniversalClient, error) { - // Apply defaults - if cfg.DialTimeout == 0 { - cfg.DialTimeout = DefaultDialTimeout - } - if cfg.ReadTimeout == 0 { - cfg.ReadTimeout = DefaultReadTimeout - } - if cfg.WriteTimeout == 0 { - cfg.WriteTimeout = DefaultWriteTimeout - } - - if cfg.SentinelConfig != nil { - opts := &redis.FailoverOptions{ - MasterName: cfg.SentinelConfig.MasterName, - SentinelAddrs: cfg.SentinelConfig.SentinelAddrs, - DB: cfg.SentinelConfig.DB, - Username: cfg.ACLUserConfig.Username, - Password: cfg.ACLUserConfig.Password, - DialTimeout: cfg.DialTimeout, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - } - - // Configure TLS dialer if either master or sentinel TLS is enabled. - if err := configureTLSDialer(opts, cfg.TLS, cfg.SentinelTLS); err != nil { - return nil, err - } - - client := redis.NewFailoverClient(opts) - if err := client.Ping(ctx).Err(); err != nil { - _ = client.Close() - return nil, fmt.Errorf("failed to connect to redis: %w", err) - } - return client, nil - } - - tlsCfg, err := buildTLSConfig(cfg.TLS) - if err != nil { - return nil, fmt.Errorf("master TLS config: %w", err) +// NewRedisStorage creates Redis-backed storage. Connection-mode topology, +// timeouts, TLS, and credentials are configured through cfg; keyPrefix is the +// per-tenant key prefix (e.g. "thv:auth:{ns}:{name}:"). The auth server +// requires ACL authentication, so cfg.Password and keyPrefix must be non-empty. +// +// Connection-mode validation, timeout defaults, client construction (standalone, +// cluster, or sentinel), TLS plumbing, and connectivity verification are +// delegated to the shared toolhive-core redis package. +func NewRedisStorage(ctx context.Context, cfg tcredis.Config, keyPrefix string) (*RedisStorage, error) { + if cfg.Password == "" { + return nil, errors.New("invalid redis configuration: ACL password is required") } - - client := redis.NewClient(&redis.Options{ - Addr: cfg.Address, - Username: cfg.ACLUserConfig.Username, - Password: cfg.ACLUserConfig.Password, - DialTimeout: cfg.DialTimeout, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - TLSConfig: tlsCfg, - }) - if err := client.Ping(ctx).Err(); err != nil { - _ = client.Close() - return nil, fmt.Errorf("failed to connect to redis: %w", err) + if keyPrefix == "" { + return nil, errors.New("invalid redis configuration: key prefix is required") } - return client, nil -} -// NewRedisStorage creates Redis-backed storage with optional Sentinel failover support. -// Returns error if configuration validation fails or connection cannot be established. -func NewRedisStorage(ctx context.Context, cfg RedisConfig) (*RedisStorage, error) { - if err := validateConfig(&cfg); err != nil { - return nil, fmt.Errorf("invalid redis configuration: %w", err) - } - - client, err := buildRedisClient(ctx, &cfg) + client, err := tcredis.NewClient(ctx, &cfg) if err != nil { return nil, err } return &RedisStorage{ client: client, - keyPrefix: cfg.KeyPrefix, + keyPrefix: keyPrefix, }, nil } @@ -297,42 +148,6 @@ func defaultSessionFactory(subject, idpSessionID, clientID string) fosite.Sessio return session.New(subject, idpSessionID, clientID, session.UserClaims{}) } -func validateConfig(cfg *RedisConfig) error { - hasAddress := cfg.Address != "" - hasSentinel := cfg.SentinelConfig != nil - - if !hasAddress && !hasSentinel { - return errors.New("redis address or sentinel configuration is required") - } - if hasAddress && hasSentinel { - return errors.New("redis address and sentinel configuration are mutually exclusive") - } - if hasSentinel { - if cfg.SentinelConfig.MasterName == "" { - return errors.New("sentinel master name is required") - } - if len(cfg.SentinelConfig.SentinelAddrs) == 0 { - return errors.New("at least one sentinel address is required") - } - } - if hasAddress && cfg.SentinelTLS != nil { - return errors.New("sentinel TLS configuration requires sentinel mode") - } - if cfg.ACLUserConfig == nil { - return errors.New("ACL user configuration is required") - } - if cfg.ACLUserConfig.Username == "" { - return errors.New("ACL username is required") - } - if cfg.ACLUserConfig.Password == "" { - return errors.New("ACL password is required") - } - if cfg.KeyPrefix == "" { - return errors.New("key prefix is required") - } - return nil -} - // Health checks Redis connectivity. func (s *RedisStorage) Health(ctx context.Context) error { if err := s.client.Ping(ctx).Err(); err != nil { @@ -396,7 +211,8 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) return fmt.Errorf("failed to marshal client: %w", err) } - // Public clients (from DCR) expire to prevent unbounded growth. + // Public clients (from DCR) expire to prevent unbounded growth; RenewClientTTL + // refreshes this on proven use so actively-used clients are not evicted. // Confidential clients don't expire. ttl := time.Duration(0) if client.IsPublic() { @@ -426,6 +242,26 @@ func (s *RedisStorage) GetClient(ctx context.Context, id string) (fosite.Client, return &redisClient{storedClient: stored}, nil } +// RenewClientTTL extends a public client's registration TTL to DefaultPublicClientTTL. +// +// Call this on a proven-use signal — a successful token exchange/refresh — not on a +// client read. GetClient is reached from the unauthenticated front-channel +// /oauth/authorize handler before any authentication, and public clients have no +// secret, so renewing there would let any caller who knows a public client_id keep +// its row alive indefinitely and defeat the anti-bloat TTL. Renewing on token +// issuance ties registration survival to actual use. +// +// Only public clients carry a TTL; confidential clients are stored without one and +// are left untouched. EXPIRE on a missing key is a no-op, so a client whose row has +// already been evicted (or a non-persisted CIMD client) is safely ignored. +func (s *RedisStorage) RenewClientTTL(ctx context.Context, client fosite.Client) error { + if client == nil || !client.IsPublic() { + return nil + } + key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID()) + return s.client.Expire(ctx, key, DefaultPublicClientTTL).Err() +} + // ClientAssertionJWTValid returns an error if the JTI is known. func (s *RedisStorage) ClientAssertionJWTValid(ctx context.Context, jti string) error { key := redisKey(s.keyPrefix, KeyTypeJWT, jti) @@ -836,19 +672,73 @@ func (s *RedisStorage) DeletePKCERequestSession(ctx context.Context, signature s // ----------------------- // storedUpstreamTokens is a serializable wrapper for UpstreamTokens. +// Time fields use int64 Unix epoch; 0 is the sentinel meaning "not set". +// Neither time field uses omitempty so that 0 is always present and the +// read path can use a consistent != 0 check for both. type storedUpstreamTokens struct { - ProviderID string `json:"provider_id"` - AccessToken string `json:"access_token"` //nolint:gosec // G117: field legitimately holds sensitive data - RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: field legitimately holds sensitive data - IDToken string `json:"id_token"` - ExpiresAt int64 `json:"expires_at"` - UserID string `json:"user_id"` - UpstreamSubject string `json:"upstream_subject"` - ClientID string `json:"client_id"` + ProviderID string `json:"provider_id"` + AccessToken string `json:"access_token"` //nolint:gosec // G117: field legitimately holds sensitive data + RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: field legitimately holds sensitive data + IDToken string `json:"id_token"` + ExpiresAt int64 `json:"expires_at"` + SessionExpiresAt int64 `json:"session_expires_at"` + UserID string `json:"user_id"` + UpstreamSubject string `json:"upstream_subject"` + ClientID string `json:"client_id"` +} + +// toUpstreamTokens converts the stored int64 epoch fields to time.Time and returns +// the populated UpstreamTokens. Zero epoch values are preserved as zero time.Time. +func (s *storedUpstreamTokens) toUpstreamTokens() *UpstreamTokens { + var expiresAt time.Time + if s.ExpiresAt != 0 { + expiresAt = time.Unix(s.ExpiresAt, 0) + } + var sessionExpiresAt time.Time + if s.SessionExpiresAt != 0 { + sessionExpiresAt = time.Unix(s.SessionExpiresAt, 0) + } + return &UpstreamTokens{ + ProviderID: s.ProviderID, + AccessToken: s.AccessToken, + RefreshToken: s.RefreshToken, + IDToken: s.IDToken, + ExpiresAt: expiresAt, + SessionExpiresAt: sessionExpiresAt, + UserID: s.UserID, + UpstreamSubject: s.UpstreamSubject, + ClientID: s.ClientID, + } } const maxStoreUpstreamTokensRetries = 5 +// queueUpstreamIndexTTL keeps the session index set alive at least as long as +// every per-provider key it points to. Mirrors upstream's storeUpstreamTokensScript +// (#5092). currentTTL must be read before the SADD in the same transaction, so a +// missing index (-2) is still told apart from one that a non-expiring member +// already persisted (-1). +func queueUpstreamIndexTTL(ctx context.Context, pipe redis.Pipeliner, idxKey string, currentTTL, ttl time.Duration) { + switch { + case ttl == 0: + // This member never expires, so the index must not either. + pipe.Persist(ctx, idxKey) + case currentTTL == redisPTTLMissing: + // Fresh index created by the SADD in this transaction: apply our TTL. + pipe.PExpire(ctx, idxKey, ttl) + case currentTTL == redisPTTLPersistent: + // A non-expiring member persisted the index; leave it alone. + case currentTTL < ttl: + pipe.PExpire(ctx, idxKey, ttl) + } +} + +// go-redis reports PTTL's special replies as these raw durations. +const ( + redisPTTLMissing time.Duration = -2 // key does not exist + redisPTTLPersistent time.Duration = -1 // key exists without a TTL +) + // extractStoredUpstreamUserID returns the stored UserID from a token payload. // Corrupt payloads are tolerated here because older writes may have already // succeeded; in that case we skip reverse-index cleanup and let TTL expiry bound @@ -879,15 +769,21 @@ func marshalUpstreamTokensWithTTL(tokens *UpstreamTokens) ([]byte, time.Duration expiresAtUnix = tokens.ExpiresAt.Unix() } + var sessionExpiresAtUnix int64 + if !tokens.SessionExpiresAt.IsZero() { + sessionExpiresAtUnix = tokens.SessionExpiresAt.Unix() + } + stored := storedUpstreamTokens{ - ProviderID: tokens.ProviderID, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - IDToken: tokens.IDToken, - ExpiresAt: expiresAtUnix, - UserID: tokens.UserID, - UpstreamSubject: tokens.UpstreamSubject, - ClientID: tokens.ClientID, + ProviderID: tokens.ProviderID, + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + IDToken: tokens.IDToken, + ExpiresAt: expiresAtUnix, + SessionExpiresAt: sessionExpiresAtUnix, + UserID: tokens.UserID, + UpstreamSubject: tokens.UpstreamSubject, + ClientID: tokens.ClientID, } data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users @@ -897,11 +793,20 @@ func marshalUpstreamTokensWithTTL(tokens *UpstreamTokens) ([]byte, time.Duration // Add DefaultRefreshTokenTTL beyond access token expiry so the refresh token // survives in storage for transparent token refresh by the middleware. - ttl := DefaultAccessTokenTTL + DefaultRefreshTokenTTL + // Zero ExpiresAt means the token never expires; return ttl=0 so the Lua script + // stores the key without a Redis TTL. + var ttl time.Duration // zero means no Redis TTL (non-expiring token with no known session bound) if !tokens.ExpiresAt.IsZero() { ttl = time.Until(tokens.ExpiresAt) + DefaultRefreshTokenTTL - if ttl < 0 { - ttl = DefaultRefreshTokenTTL + if ttl <= 0 { + // Access token and its refresh grace period have both passed — evict promptly. + ttl = time.Second + } + } else if !tokens.SessionExpiresAt.IsZero() { + ttl = time.Until(tokens.SessionExpiresAt) + DefaultRefreshTokenTTL + if ttl <= 0 { + // Session bound and its refresh grace period have both passed — evict promptly. + ttl = time.Second } } @@ -962,9 +867,7 @@ func (s *RedisStorage) StoreUpstreamTokens(ctx context.Context, sessionID, provi pipe.Set(ctx, key, data, ttl) pipe.SAdd(ctx, idxKey, key) - if ttl > 0 && currentTTL < ttl { - pipe.PExpire(ctx, idxKey, ttl) - } + queueUpstreamIndexTTL(ctx, pipe, idxKey, currentTTL, ttl) if oldUserSetKey != "" { pipe.SRem(ctx, oldUserSetKey, key) @@ -1021,6 +924,9 @@ func (s *RedisStorage) GetAllUpstreamTokens(ctx context.Context, sessionID strin return nil, fmt.Errorf("failed to get upstream token index: %w", err) } + providerKeys, dropped := filterIndexMembersByPrefix(s.keyPrefix, providerKeys) + warnDroppedIndexMembers("GetAllUpstreamTokens", idxKey, s.keyPrefix, dropped) + if len(providerKeys) == 0 { return result, nil } @@ -1083,6 +989,9 @@ func (s *RedisStorage) DeleteUpstreamTokens(ctx context.Context, sessionID strin return fmt.Errorf("failed to get upstream token index: %w", err) } + providerKeys, dropped := filterIndexMembersByPrefix(s.keyPrefix, providerKeys) + warnDroppedIndexMembers("DeleteUpstreamTokens", idxKey, s.keyPrefix, dropped) + if len(providerKeys) == 0 { return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) } @@ -1119,6 +1028,117 @@ func (s *RedisStorage) DeleteUpstreamTokens(ctx context.Context, sessionID strin return nil } +// GetLatestUpstreamTokensForUser returns the upstream token row for (userID, providerID) +// with the highest ExpiresAt across all sessions. +// +// Expired tokens (past ExpiresAt) are returned so callers can use the refresh +// token; filtering by access-token expiry is the caller's responsibility. +// See the interface declaration in types.go for the full contract. +func (s *RedisStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userID, providerID string) (*UpstreamTokens, error) { + if userID == "" { + return nil, fosite.ErrInvalidRequest.WithHint("user ID cannot be empty") + } + if providerID == "" { + return nil, fosite.ErrInvalidRequest.WithHint("provider ID cannot be empty") + } + + setKey := redisSetKey(s.keyPrefix, KeyTypeUserUpstream, userID) + + members, err := s.client.SMembers(ctx, setKey).Result() + if err != nil && !errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("failed to get user upstream index: %w", err) + } + + members, dropped := filterIndexMembersByPrefix(s.keyPrefix, members) + warnDroppedIndexMembers("GetLatestUpstreamTokensForUser", setKey, s.keyPrefix, dropped) + + if len(members) == 0 { + return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + } + + values, err := s.client.MGet(ctx, members...).Result() + if err != nil { + return nil, fmt.Errorf("failed to get upstream tokens: %w", err) + } + + var winner *storedUpstreamTokens + for i, val := range values { + stored, ok := parseUserUpstreamEntry(val, providerID, members[i]) + if !ok { + continue + } + + // Tie-breaker: prefer non-expiring rows (ExpiresAt == 0, the no-expiry + // sentinel for providers like Slack and GitHub OAuth Apps). Among finite + // expiries, the latest wins. This aligns with the rest of the package + // treating zero ExpiresAt as "alive forever". + if winner == nil || compareExpiryInt64(stored.ExpiresAt, winner.ExpiresAt) > 0 { + winner = stored + } + } + + if winner == nil { + return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + } + + return winner.toUpstreamTokens(), nil +} + +// compareExpiryInt64 is the int64 (Unix-epoch) variant of compareExpiry. Zero +// (no-expiry sentinel) ranks latest; among finite epochs, the larger ranks +// latest. Returns -1/0/+1. +func compareExpiryInt64(a, b int64) int { + switch { + case a == 0 && b == 0: + return 0 + case a == 0: + return 1 + case b == 0: + return -1 + case a > b: + return 1 + case a < b: + return -1 + } + return 0 +} + +// parseUserUpstreamEntry parses one raw Redis value from the user-upstream index +// and returns the decoded storedUpstreamTokens together with a match flag. +// It returns (nil, false) for nil values, type mismatches, deletion tombstones, +// JSON decode errors, and rows whose ProviderID does not match providerID. +// keyName is used only for warning log messages. +func parseUserUpstreamEntry(val any, providerID, keyName string) (*storedUpstreamTokens, bool) { + if val == nil { + // Dangling set member: the per-provider key has been TTL-evicted. + // Skip it; the next write will clean up the index entry (best-effort). + return nil, false + } + + data, ok := val.(string) + if !ok { + slog.Warn("skipping upstream token entry: unexpected type", "key", keyName) + return nil, false + } + + if data == nullMarker { + // Deletion tombstone — treat as absent. + return nil, false + } + + var stored storedUpstreamTokens + if unmarshalErr := json.Unmarshal([]byte(data), &stored); unmarshalErr != nil { + slog.Warn("skipping corrupt upstream token entry", "key", keyName, "error", unmarshalErr) + return nil, false + } + + if stored.ProviderID != providerID { + return nil, false + } + + return &stored, true +} + // getUpstreamTokensFromKey retrieves and deserializes upstream tokens from a specific Redis key. func (s *RedisStorage) getUpstreamTokensFromKey(ctx context.Context, key string) (*UpstreamTokens, error) { data, err := s.client.Get(ctx, key).Bytes() @@ -1144,34 +1164,181 @@ func unmarshalUpstreamTokens(data []byte) (*UpstreamTokens, error) { return nil, fmt.Errorf("failed to unmarshal upstream tokens: %w", err) } - // Convert stored ExpiresAt back to time.Time. - // stored.ExpiresAt == 0 is a sentinel meaning "no expiry" — the write path - // stores 0 for zero time. Skip the expiry check in this case since Redis TTL - // handles the actual expiration. - var expiresAt time.Time - var expired bool - if stored.ExpiresAt != 0 { - expiresAt = time.Unix(stored.ExpiresAt, 0) - expired = time.Now().After(expiresAt) + tokens := stored.toUpstreamTokens() + + // tokens.ExpiresAt is zero when the stored epoch was 0 (the write path stores + // 0 for zero time.Time, which toUpstreamTokens leaves as the zero time). Skip + // the expiry check in that case since Redis TTL handles the actual expiration. + // Return tokens along with ErrExpired so callers can use the refresh token. + if !tokens.ExpiresAt.IsZero() && time.Now().After(tokens.ExpiresAt) { + return tokens, ErrExpired } - tokens := &UpstreamTokens{ - ProviderID: stored.ProviderID, - AccessToken: stored.AccessToken, - RefreshToken: stored.RefreshToken, - IDToken: stored.IDToken, - ExpiresAt: expiresAt, - UserID: stored.UserID, - UpstreamSubject: stored.UpstreamSubject, - ClientID: stored.ClientID, + return tokens, nil +} + +// ----------------------- +// DCR Credentials Storage +// ----------------------- + +// storedDCRCredentials is the on-the-wire JSON representation of DCRCredentials. +// Time fields use int64 Unix epoch; 0 is the sentinel meaning "not set", matching +// the storedUpstreamTokens convention. ClientSecretExpiresAt == 0 specifically +// encodes the RFC 7591 §3.2.1 "client_secret does not expire" semantics, in +// which case StoreDCRCredentials persists the entry without a Redis TTL. +type storedDCRCredentials struct { + // Embed the canonical key so a row recovered without its lookup key + // (e.g. via SCAN during diagnostics) still self-identifies. + KeyIssuer string `json:"key_issuer"` + KeyRedirectURI string `json:"key_redirect_uri"` + KeyScopesHash string `json:"key_scopes_hash"` + + ProviderName string `json:"provider_name,omitempty"` + + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec // G117: field legitimately holds sensitive data + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // Bearer token for the RFC 7592 management endpoint. + //nolint:gosec // G117: field legitimately holds sensitive data + RegistrationAccessToken string `json:"registration_access_token,omitempty"` + RegistrationClientURI string `json:"registration_client_uri,omitempty"` + + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + TokenEndpoint string `json:"token_endpoint,omitempty"` + + CreatedAt int64 `json:"created_at"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at"` +} + +// toDCRCredentials decodes the stored form back into the public type, mirroring +// storedUpstreamTokens.toUpstreamTokens. Zero epoch values become the zero +// time.Time, preserving the "not set" sentinel. +func (s *storedDCRCredentials) toDCRCredentials() *DCRCredentials { + var createdAt time.Time + if s.CreatedAt != 0 { + createdAt = time.Unix(s.CreatedAt, 0) + } + var clientSecretExpiresAt time.Time + if s.ClientSecretExpiresAt != 0 { + clientSecretExpiresAt = time.Unix(s.ClientSecretExpiresAt, 0) + } + return &DCRCredentials{ + Key: DCRKey{ + Issuer: s.KeyIssuer, + RedirectURI: s.KeyRedirectURI, + ScopesHash: s.KeyScopesHash, + }, + ProviderName: s.ProviderName, + ClientID: s.ClientID, + ClientSecret: s.ClientSecret, + TokenEndpointAuthMethod: s.TokenEndpointAuthMethod, + RegistrationAccessToken: s.RegistrationAccessToken, + RegistrationClientURI: s.RegistrationClientURI, + AuthorizationEndpoint: s.AuthorizationEndpoint, + TokenEndpoint: s.TokenEndpoint, + CreatedAt: createdAt, + ClientSecretExpiresAt: clientSecretExpiresAt, + } +} + +// StoreDCRCredentials persists DCR credentials, overwriting any existing entry +// for the same Key. Defensive copy is provided implicitly by JSON serialisation — +// caller mutations after the call cannot reach the persisted bytes. +// +// # TTL +// +// When creds.ClientSecretExpiresAt is non-zero (the upstream advertised an +// RFC 7591 §3.2.1 client_secret_expires_at), the entry is stored with a Redis +// TTL derived from time.Until(ClientSecretExpiresAt) so the row evicts before +// the upstream rejects the secret at the token endpoint. When zero (RFC 7591 +// "never"), Set with TTL=0 is used and the entry is long-lived. +// +// If ClientSecretExpiresAt is already in the past at call time, the entry is +// written with the bounded TTL pastExpiryDCRTTL (1 second) rather than rejected +// or stored long-lived. This keeps the store's "already-expired secret" window +// narrow even if the resolver never re-reads the row, and matches the +// fail-loud-but-tolerant posture: the caller's expiry timestamp round-trips so +// a downstream reader can still observe it and trigger re-registration. +// +// Validation is delegated to validateDCRCredentialsForStore so the rejection +// set stays in sync with MemoryStorage and any future backend. +func (s *RedisStorage) StoreDCRCredentials(ctx context.Context, creds *DCRCredentials) error { + if err := validateDCRCredentialsForStore(creds); err != nil { + return err } - // Return tokens along with ErrExpired so callers can use the refresh token - if expired { - return tokens, ErrExpired + key := redisDCRKey(s.keyPrefix, creds.Key) + + stored := storedDCRCredentials{ + KeyIssuer: creds.Key.Issuer, + KeyRedirectURI: creds.Key.RedirectURI, + KeyScopesHash: creds.Key.ScopesHash, + ProviderName: creds.ProviderName, + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecret, + TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod, + RegistrationAccessToken: creds.RegistrationAccessToken, + RegistrationClientURI: creds.RegistrationClientURI, + AuthorizationEndpoint: creds.AuthorizationEndpoint, + TokenEndpoint: creds.TokenEndpoint, + } + if !creds.CreatedAt.IsZero() { + stored.CreatedAt = creds.CreatedAt.Unix() + } + if !creds.ClientSecretExpiresAt.IsZero() { + stored.ClientSecretExpiresAt = creds.ClientSecretExpiresAt.Unix() } - return tokens, nil + data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users + if err != nil { + return fmt.Errorf("failed to marshal dcr credentials: %w", err) + } + + // Derive Redis TTL from ClientSecretExpiresAt: + // * Zero (unset) -> TTL=0 (no expiration) per RFC 7591 §3.2.1 "never". + // * Future expiry -> TTL = time.Until(expiry). + // * Past expiry -> TTL = pastExpiryDCRTTL (bounded eviction window). + // See the function docstring for the past-expiry rationale. + ttl := time.Duration(0) + if !creds.ClientSecretExpiresAt.IsZero() { + if until := time.Until(creds.ClientSecretExpiresAt); until > 0 { + ttl = until + } else { + ttl = pastExpiryDCRTTL + } + } + + if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil { + return fmt.Errorf("failed to store dcr credentials: %w", err) + } + return nil +} + +// GetDCRCredentials retrieves the credentials previously persisted under key. +// Returns ErrNotFound (wrapped) when no entry exists. The returned value is a +// fresh struct decoded from JSON, which acts as a defensive copy. +// +// An unpopulated key (empty Issuer, RedirectURI, or ScopesHash) cannot match +// any stored row because StoreDCRCredentials rejects such keys, so a Get +// against one is a normal miss — ErrNotFound — matching +// MemoryStorage.GetDCRCredentials and the DCRCredentialStore interface contract. +func (s *RedisStorage) GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRCredentials, error) { + redisKey := redisDCRKey(s.keyPrefix, key) + data, err := s.client.Get(ctx, redisKey).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("DCR credentials not found")) + } + return nil, fmt.Errorf("failed to get dcr credentials: %w", err) + } + + var stored storedDCRCredentials + if err := json.Unmarshal(data, &stored); err != nil { + return nil, fmt.Errorf("failed to unmarshal dcr credentials: %w", err) + } + + return stored.toDCRCredentials(), nil } // ----------------------- @@ -1684,4 +1851,5 @@ var ( _ ClientRegistry = (*RedisStorage)(nil) _ UpstreamTokenStorage = (*RedisStorage)(nil) _ UserStorage = (*RedisStorage)(nil) + _ DCRCredentialStore = (*RedisStorage)(nil) ) diff --git a/pkg/authserver/storage/redis_integration_test.go b/pkg/authserver/storage/redis_integration_test.go index 9552bc3fa4..5cd08f5e19 100644 --- a/pkg/authserver/storage/redis_integration_test.go +++ b/pkg/authserver/storage/redis_integration_test.go @@ -1413,3 +1413,175 @@ func TestIntegration_MigrateLegacyUpstreamData(t *testing.T) { }) }) } + +// --- DCR Credentials --- +// +// dcrFixtureKey is defined in redis_test.go (no build tag) and is therefore +// visible here under the `integration` build tag as well — sharing a single +// source of truth for the canonical DCR fixture key across unit and +// integration tests. + +func TestIntegration_DCRCredentials_RoundTrip(t *testing.T) { + t.Parallel() + + t.Run("store and get all fields", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + createdAt := time.Now().Truncate(time.Second) + expiresAt := createdAt.Add(24 * time.Hour) + + creds := &DCRCredentials{ + Key: dcrFixtureKey(), + ProviderName: "atlassian", + ClientID: "client-int-abc", + ClientSecret: "secret-int-xyz", + TokenEndpointAuthMethod: "client_secret_basic", + RegistrationAccessToken: "rat-int-123", + RegistrationClientURI: "https://idp.example.com/register/client-int-abc", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + CreatedAt: createdAt, + ClientSecretExpiresAt: expiresAt, + } + + require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + + got, err := s.GetDCRCredentials(ctx, creds.Key) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, *creds, *got) + }) + }) + + t.Run("get non-existent", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + _, err := s.GetDCRCredentials(ctx, dcrFixtureKey()) + requireRedisNotFoundError(t, err) + }) + }) +} + +func TestIntegration_DCRCredentials_DistinctKeysCoexist(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + mkKey := func(issuer, redirect string, scopes []string) DCRKey { + return DCRKey{Issuer: issuer, RedirectURI: redirect, ScopesHash: ScopesHash(scopes)} + } + mk := func(key DCRKey, clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + entries := []*DCRCredentials{ + mk(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid"}), "a"), + mk(mkKey("https://idp-b.example.com", "https://x/cb", []string{"openid"}), "b"), + mk(mkKey("https://idp-a.example.com", "https://y/cb", []string{"openid"}), "c"), + mk(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid", "email"}), "d"), + } + for _, e := range entries { + require.NoError(t, s.StoreDCRCredentials(ctx, e)) + } + + for _, want := range entries { + got, err := s.GetDCRCredentials(ctx, want.Key) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want.ClientID, got.ClientID) + } + }) +} + +func TestIntegration_DCRCredentials_OverwriteSemantics(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + key := dcrFixtureKey() + mk := func(clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + require.NoError(t, s.StoreDCRCredentials(ctx, mk("first"))) + require.NoError(t, s.StoreDCRCredentials(ctx, mk("second"))) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "second", got.ClientID) + }) +} + +// TestIntegration_DCRCredentials_TTL pins the RFC 7591 §3.2.1 TTL contract +// against a real Redis Sentinel cluster: TTL command observes the expected +// state for both the expiring and the never-expires cases. The unit-level +// miniredis test pins the in-process behaviour; this test pins the wire +// behaviour against real Redis where TTL returns -1 for "no TTL" and -2 for +// "key does not exist". +func TestIntegration_DCRCredentials_TTL(t *testing.T) { + t.Parallel() + + t.Run("non-zero expiry sets observable TTL", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + key := dcrFixtureKey() + expires := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "client-with-expiry", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: expires, + })) + + ttl, err := s.client.TTL(ctx, redisDCRKey(s.keyPrefix, key)).Result() + require.NoError(t, err) + assert.Greater(t, ttl, time.Duration(0), "TTL must be positive when ClientSecretExpiresAt is in the future") + // Allow 1 second of slack: the wall-clock value is truncated to + // second precision and Redis itself reports TTL with second + // granularity, so an exact 24h bound would be off by up to a + // second on busy CI without changing the underlying behaviour. + assert.LessOrEqual(t, ttl, 24*time.Hour+time.Second, "TTL must not exceed the configured expiry window") + }) + }) + + t.Run("zero expiry means no TTL", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + key := dcrFixtureKey() + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "client-no-expiry", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + // ClientSecretExpiresAt deliberately zero. + })) + + ttl, err := s.client.TTL(ctx, redisDCRKey(s.keyPrefix, key)).Result() + require.NoError(t, err) + // go-redis maps Redis "TTL -1" (no expiry) to time.Duration(-1). + assert.Equal(t, time.Duration(-1), ttl, "real Redis must report -1 for a row stored without a TTL") + }) + }) +} + +// TestIntegration_DCRCredentials_ConcurrentAccess pins race-freedom against +// real Redis. Mirrors the unit-test sibling (overlapping + disjoint +// keyspaces) using the shared runDCRConcurrentAccess helper from +// redis_test.go (visible across build tags), with a longer timeout to +// absorb real-Redis network latency. Run with -race to validate the +// data-race detector is clean. +func TestIntegration_DCRCredentials_ConcurrentAccess(t *testing.T) { + t.Parallel() + + t.Run("overlapping_key", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + runDCRConcurrentAccess(ctx, t, s, dcrConcurrentOverlappingKey, 30*time.Second) + }) + }) + + t.Run("disjoint_keys", func(t *testing.T) { + withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { + runDCRConcurrentAccess(ctx, t, s, dcrConcurrentDisjointKeys, 30*time.Second) + }) + }) +} diff --git a/pkg/authserver/storage/redis_keys.go b/pkg/authserver/storage/redis_keys.go index 32f5855a3e..45f8978d7a 100644 --- a/pkg/authserver/storage/redis_keys.go +++ b/pkg/authserver/storage/redis_keys.go @@ -58,6 +58,12 @@ const ( // KeyTypeUserProviders is the key type for user to provider identity reverse lookups. KeyTypeUserProviders = "user:providers" + + // KeyTypeDCR is the key type for RFC 7591 Dynamic Client Registration credentials + // persisted by an authserver upstream-DCR resolver. Distinct from KeyTypeClient, + // which holds the authserver's *own* OAuth clients — DCR entries are credentials + // that *this* authserver registered against an *upstream* authorization server. + KeyTypeDCR = "dcr" ) // DeriveKeyPrefix creates the key prefix from the Kubernetes namespace and MCP server name. @@ -88,6 +94,36 @@ func redisProviderKey(prefix, providerID, providerSubject string) string { return fmt.Sprintf("%s%s:%d:%s:%s", prefix, KeyTypeProvider, len(providerID), providerID, providerSubject) } +// redisDCRKey generates a Redis key for a DCR credential entry, identifying the +// (Issuer, RedirectURI, ScopesHash) tuple that DCRKey canonicalises. +// +// Format: "{prefix}dcr:::::" +// +// The first two segments are length-prefixed to handle colons in RedirectURI +// (and, for symmetry, Issuer) without ambiguity, mirroring redisProviderKey. +// ScopesHash is expected to be a SHA-256 hex digest produced by +// storage.ScopesHash — only [0-9a-f] and never colon-bearing — so it is +// appended without a length prefix. The format is robust for that domain; +// validateDCRCredentialsForStore (called by every Store path) already +// rejects an empty ScopesHash, and callers are required to compute the hash +// via storage.ScopesHash. Length-prefix collision-safety is preserved on +// the leading segments either way. +// +// The public-vs-confidential client distinction is intentionally NOT +// encoded here — see DCRKey's doc for the rationale. Today's two consumers +// register on disjoint RedirectURI address spaces (AS-origin vs RFC 8252 +// loopback) so the persisted key cannot collide across profiles. A future +// consumer that defaults its RedirectURI into either space would need to +// add the distinguishing component back to the key format alongside an +// explicit migration story for existing Redis-cached entries. +func redisDCRKey(prefix string, key DCRKey) string { + return fmt.Sprintf("%s%s:%d:%s:%d:%s:%s", + prefix, KeyTypeDCR, + len(key.Issuer), key.Issuer, + len(key.RedirectURI), key.RedirectURI, + key.ScopesHash) +} + // redisUpstreamKey generates a Redis key for a per-provider upstream token entry. // Format: "{prefix}upstream:{sessionID}:{providerName}" // This enables storing tokens from multiple upstream providers per session. diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index 3038b49f35..5464a05e9e 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -8,10 +8,14 @@ package storage import ( + "bytes" "context" "fmt" + "log/slog" "net/url" + "strings" "sync" + "sync/atomic" "testing" "time" @@ -21,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + tcredis "github.com/stacklok/toolhive-core/redis" "github.com/stacklok/toolhive/pkg/authserver/server/session" ) @@ -42,10 +47,10 @@ func withRedisStorage(t *testing.T, fn func(context.Context, *RedisStorage, *min t.Helper() t.Parallel() storage, mr := newTestRedisStorage(t) - defer func() { + t.Cleanup(func() { _ = storage.Close() mr.Close() - }() + }) fn(context.Background(), storage, mr) } @@ -92,139 +97,125 @@ func requireRedisNotFoundError(t *testing.T, err error) { // --- Configuration Tests --- -func TestRedisConfig_Validation(t *testing.T) { +// TestNewRedisStorage_Validation covers the auth-server-specific invariants +// enforced by NewRedisStorage. Connection-mode topology (Addr XOR Sentinel, +// cluster requires Addr, sentinel master name and addresses) is validated by +// the shared toolhive-core redis package and exercised in its own tests. +func TestNewRedisStorage_Validation(t *testing.T) { t.Parallel() + validACL := func() tcredis.Config { + return tcredis.Config{ + Addr: "localhost:6379", + Username: "user", + Password: "pass", + } + } + tests := []struct { - name string - cfg RedisConfig - wantErr string + name string + cfg tcredis.Config + keyPrefix string + wantErr string }{ { - name: "missing redis connection config", - cfg: RedisConfig{ACLUserConfig: &ACLUserConfig{}, KeyPrefix: "test:"}, - wantErr: "redis address or sentinel configuration is required", + name: "missing ACL password", + cfg: tcredis.Config{Addr: "localhost:6379", Username: "user"}, + keyPrefix: "test:", + wantErr: "ACL password is required", }, { - name: "address and sentinel config are mutually exclusive", - cfg: RedisConfig{ - Address: "dragonfly:6379", - SentinelConfig: &SentinelConfig{MasterName: "mymaster", SentinelAddrs: []string{"localhost:26379"}}, - ACLUserConfig: &ACLUserConfig{}, - KeyPrefix: "test:", - }, - wantErr: "redis address and sentinel configuration are mutually exclusive", - }, - { - name: "missing sentinel master name", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{SentinelAddrs: []string{"localhost:26379"}}, ACLUserConfig: &ACLUserConfig{}, KeyPrefix: "test:"}, - wantErr: "sentinel master name is required", - }, - { - name: "missing sentinel addresses", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "mymaster"}, ACLUserConfig: &ACLUserConfig{}, KeyPrefix: "test:"}, - wantErr: "at least one sentinel address is required", - }, - { - name: "missing ACL user config", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "mymaster", SentinelAddrs: []string{"localhost:26379"}}, KeyPrefix: "test:"}, - wantErr: "ACL user configuration is required", - }, - { - name: "missing ACL username", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "mymaster", SentinelAddrs: []string{"localhost:26379"}}, ACLUserConfig: &ACLUserConfig{Password: "pass"}, KeyPrefix: "test:"}, - wantErr: "ACL username is required", - }, - { - name: "missing ACL password", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "mymaster", SentinelAddrs: []string{"localhost:26379"}}, ACLUserConfig: &ACLUserConfig{Username: "user"}, KeyPrefix: "test:"}, - wantErr: "ACL password is required", - }, - { - name: "missing key prefix", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "mymaster", SentinelAddrs: []string{"localhost:26379"}}, ACLUserConfig: &ACLUserConfig{Username: "user", Password: "pass"}}, - wantErr: "key prefix is required", - }, - { - name: "sentinel TLS requires sentinel mode", - cfg: RedisConfig{ - Address: "dragonfly:6379", - ACLUserConfig: &ACLUserConfig{Username: "user", Password: "pass"}, - KeyPrefix: "test:", - SentinelTLS: &RedisTLSConfig{}, - }, - wantErr: "sentinel TLS configuration requires sentinel mode", + name: "missing key prefix", + cfg: validACL(), + keyPrefix: "", + wantErr: "key prefix is required", }, } + ctx := context.Background() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateConfig(&tt.cfg) + _, err := NewRedisStorage(ctx, tt.cfg, tt.keyPrefix) require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) }) } } -func TestRedisConfig_Validation_AcceptsDirectAddress(t *testing.T) { - t.Parallel() - - cfg := RedisConfig{ - Address: "dragonfly.default.svc.cluster.local:6379", - ACLUserConfig: &ACLUserConfig{ - Username: "user", - Password: "pass", - }, - KeyPrefix: "test:", - } - - require.NoError(t, validateConfig(&cfg)) -} - func TestNewRedisStorage_ConnectionFailure(t *testing.T) { t.Parallel() - cfg := RedisConfig{ - SentinelConfig: &SentinelConfig{ - MasterName: "mymaster", - SentinelAddrs: []string{"localhost:99999"}, // Invalid port + tests := []struct { + name string + cfg tcredis.Config + }{ + { + name: "sentinel mode", + cfg: tcredis.Config{ + SentinelConfig: &tcredis.SentinelConfig{ + MasterName: "mymaster", + SentinelAddrs: []string{"localhost:99999"}, // Invalid port + }, + Username: "user", + Password: "pass", + DialTimeout: 100 * time.Millisecond, + }, }, - ACLUserConfig: &ACLUserConfig{ - Username: "user", - Password: "pass", + { + name: "standalone mode", + cfg: tcredis.Config{ + Addr: "localhost:19999", + Username: "user", + Password: "pass", + DialTimeout: 100 * time.Millisecond, + }, + }, + { + name: "cluster mode", + cfg: tcredis.Config{ + Addr: "localhost:19998", + ClusterMode: true, + Username: "user", + Password: "pass", + DialTimeout: 100 * time.Millisecond, + }, }, - KeyPrefix: "test:", - DialTimeout: 100 * time.Millisecond, } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + t.Cleanup(cancel) - _, err := NewRedisStorage(ctx, cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect to redis") + _, err := NewRedisStorage(ctx, tt.cfg, "test:") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to connect") + }) + } } -func TestNewRedisStorage_DirectConnectionFailure(t *testing.T) { +func TestNewRedisStorage_Standalone_WithMiniredis(t *testing.T) { t.Parallel() - cfg := RedisConfig{ - Address: "localhost:99999", // Invalid port - ACLUserConfig: &ACLUserConfig{ - Username: "user", - Password: "pass", - }, - KeyPrefix: "test:", - DialTimeout: 100 * time.Millisecond, + mr := miniredis.RunT(t) + // Register a non-default ACL user. miniredis enforces ACL when credentials are + // supplied, so we use RequireUserAuth to match the configured username/password. + mr.RequireUserAuth("testuser", "testpass") + + cfg := tcredis.Config{ + Addr: mr.Addr(), + Username: "testuser", + Password: "testpass", } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() + ctx := context.Background() + s, err := NewRedisStorage(ctx, cfg, "test:") + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) - _, err := NewRedisStorage(ctx, cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect to redis") + require.NoError(t, s.Health(ctx)) } // --- Client Tests --- @@ -273,6 +264,57 @@ func TestRedisStorage_RegisterClient(t *testing.T) { }) } +// TestRedisStorage_RenewClientTTL verifies that RenewClientTTL extends the +// registration TTL for public (DCR) clients — keeping actively-used clients from +// being evicted mid-lifecycle — while leaving confidential clients (which have no +// TTL) untouched, and that renewing an unknown/evicted client is a safe no-op. +func TestRedisStorage_RenewClientTTL(t *testing.T) { + t.Parallel() + + t.Run("public client TTL is renewed", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := redisKey(s.keyPrefix, KeyTypeClient, "public-client") + client := &mockClient{id: "public-client", public: true} + require.NoError(t, s.RegisterClient(ctx, client)) + + // Age the registration so only a small slice of the TTL remains. + mr.FastForward(DefaultPublicClientTTL - time.Hour) + ttlBefore := mr.TTL(key) + require.Positive(t, ttlBefore) + require.Less(t, ttlBefore, 2*time.Hour, "precondition: TTL should be near expiry") + + require.NoError(t, s.RenewClientTTL(ctx, client)) + + ttlAfter := mr.TTL(key) + assert.Greater(t, ttlAfter, ttlBefore, "RenewClientTTL should extend the public client TTL") + assert.InDelta(t, DefaultPublicClientTTL.Seconds(), ttlAfter.Seconds(), 60, + "renewed TTL should be ~DefaultPublicClientTTL") + }) + }) + + t.Run("confidential client is left without a TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := redisKey(s.keyPrefix, KeyTypeClient, "conf-client") + client := &mockClient{id: "conf-client", public: false} + require.NoError(t, s.RegisterClient(ctx, client)) + require.Equal(t, time.Duration(0), mr.TTL(key), "confidential clients must not have a TTL") + + require.NoError(t, s.RenewClientTTL(ctx, client)) + + assert.Equal(t, time.Duration(0), mr.TTL(key), + "RenewClientTTL must not introduce a TTL on a confidential client") + }) + }) + + t.Run("unknown client is a safe no-op", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := redisKey(s.keyPrefix, KeyTypeClient, "ghost") + require.NoError(t, s.RenewClientTTL(ctx, &mockClient{id: "ghost", public: true})) + assert.False(t, mr.Exists(key), "renewing an unknown client must not create a key") + }) + }) +} + func TestRedisStorage_ClientAssertionJWT(t *testing.T) { t.Parallel() @@ -726,10 +768,10 @@ func TestRedisStorage_UpstreamTokens(t *testing.T) { }) }) - t.Run("zero ExpiresAt tokens are retrievable (no expiry)", func(t *testing.T) { - withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { - // Store tokens with zero ExpiresAt (no expiry set). - // These should be retrievable — Redis TTL handles actual expiration. + t.Run("zero ExpiresAt tokens are stored without Redis TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + // Non-expiring tokens (ExpiresAt zero) must be stored without a Redis TTL + // so they are never automatically evicted. require.NoError(t, s.StoreUpstreamTokens(ctx, "no-expiry", "provider-a", &UpstreamTokens{ AccessToken: "no-expiry-token", ProviderID: "test-provider", @@ -741,6 +783,252 @@ func TestRedisStorage_UpstreamTokens(t *testing.T) { assert.Equal(t, "no-expiry-token", retrieved.AccessToken) assert.Equal(t, "test-provider", retrieved.ProviderID) assert.True(t, retrieved.ExpiresAt.IsZero()) + + // Verify the key has no Redis TTL (miniredis returns 0 for keys without expiry). + key := redisUpstreamKey(s.keyPrefix, "no-expiry", "provider-a") + assert.Equal(t, time.Duration(0), mr.TTL(key), "non-expiring token must have no Redis TTL") + }) + }) + + t.Run("mixed-expiry session: non-expiring write removes index set TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + // First store an expiring token — this sets a TTL on the index set. + err := s.StoreUpstreamTokens(ctx, "mixed-session", "provider-expiring", &UpstreamTokens{ + AccessToken: "expiring-token", + ProviderID: "provider-expiring", + ExpiresAt: time.Now().Add(time.Hour), + }) + require.NoError(t, err) + + // Then store a non-expiring token for the same session. + err = s.StoreUpstreamTokens(ctx, "mixed-session", "provider-nonexpiring", &UpstreamTokens{ + AccessToken: "non-expiring-token", + ProviderID: "provider-nonexpiring", + // ExpiresAt intentionally zero + }) + require.NoError(t, err) + + // The index set must now have no TTL (PERSIST removed it). + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "mixed-session") + assert.Equal(t, time.Duration(0), mr.TTL(idxKey), + "index set TTL must be removed when a non-expiring token is added to the session") + }) + }) + + t.Run("mixed-expiry session: expiring write after non-expiring keeps index persistent", func(t *testing.T) { + // Regression test for the inverse ordering of the prior subtest. When a + // non-expiring token is written first and an expiring token follows, the + // Lua script must NOT re-apply a TTL to the index set — that would evict + // the index and orphan the non-expiring token's per-provider key. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + // Non-expiring first: index gets PERSIST'd. + err := s.StoreUpstreamTokens(ctx, "inverse-session", "provider-nonexpiring", &UpstreamTokens{ + AccessToken: "non-expiring-token", + ProviderID: "provider-nonexpiring", + // ExpiresAt intentionally zero + }) + require.NoError(t, err) + + // Expiring second: must NOT re-apply a TTL. + err = s.StoreUpstreamTokens(ctx, "inverse-session", "provider-expiring", &UpstreamTokens{ + AccessToken: "expiring-token", + ProviderID: "provider-expiring", + ExpiresAt: time.Now().Add(time.Hour), + }) + require.NoError(t, err) + + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "inverse-session") + assert.Equal(t, time.Duration(0), mr.TTL(idxKey), + "index set TTL must remain unset after an expiring write follows a non-expiring one") + + // Fast-forward past the expiring token's TTL. The expiring per-provider + // key evicts; the non-expiring one remains; the index stays intact. + mr.FastForward(time.Hour + DefaultRefreshTokenTTL + time.Second) + + all, err := s.GetAllUpstreamTokens(ctx, "inverse-session") + require.NoError(t, err) + require.Contains(t, all, "provider-nonexpiring", + "non-expiring token must remain reachable through the index") + assert.NotContains(t, all, "provider-expiring", + "expiring token must have been evicted by Redis TTL") + }) + }) + + t.Run("fresh expiring write applies index TTL", func(t *testing.T) { + // Regression guard: the Lua script must apply a TTL on the very first + // expiring write to a session (where the index set is being created + // fresh by the SADD). Without this, the index would never expire. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "fresh-session", "provider-a", &UpstreamTokens{ + AccessToken: "expiring-token", + ProviderID: "provider-a", + ExpiresAt: time.Now().Add(time.Hour), + })) + + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "fresh-session") + ttl := mr.TTL(idxKey) + assert.Greater(t, ttl, time.Duration(0), + "index set must have a TTL after a fresh expiring write") + }) + }) + + t.Run("longer expiring write after shorter extends index TTL", func(t *testing.T) { + // Locks down the idxTTL < ttlMs branch: when a member with longer TTL + // is added, the index TTL must be extended to match. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "extend-session", "provider-short", &UpstreamTokens{ + AccessToken: "short-token", + ProviderID: "provider-short", + ExpiresAt: time.Now().Add(15 * time.Minute), + })) + + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "extend-session") + shortTTL := mr.TTL(idxKey) + require.Greater(t, shortTTL, time.Duration(0)) + + // Add a member with a much longer TTL. + require.NoError(t, s.StoreUpstreamTokens(ctx, "extend-session", "provider-long", &UpstreamTokens{ + AccessToken: "long-token", + ProviderID: "provider-long", + ExpiresAt: time.Now().Add(24 * time.Hour), + })) + + longTTL := mr.TTL(idxKey) + assert.Greater(t, longTTL, shortTTL, + "index TTL must be extended when a longer-lived member is added") + }) + }) + + t.Run("shorter expiring write after longer leaves index TTL unchanged", func(t *testing.T) { + // Locks down the idxTTL >= ttlMs no-op branch: shorter-TTL members must + // not shrink the index — the longest-lived member governs. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "noshrink-session", "provider-long", &UpstreamTokens{ + AccessToken: "long-token", + ProviderID: "provider-long", + ExpiresAt: time.Now().Add(24 * time.Hour), + })) + + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "noshrink-session") + longTTL := mr.TTL(idxKey) + require.Greater(t, longTTL, time.Duration(0)) + + // Add a member with a shorter TTL. + require.NoError(t, s.StoreUpstreamTokens(ctx, "noshrink-session", "provider-short", &UpstreamTokens{ + AccessToken: "short-token", + ProviderID: "provider-short", + ExpiresAt: time.Now().Add(15 * time.Minute), + })) + + afterTTL := mr.TTL(idxKey) + // Allow tiny clock-drift tolerance: TTL must not have shrunk meaningfully. + assert.GreaterOrEqual(t, afterTTL, longTTL-time.Second, + "index TTL must not shrink when a shorter-lived member is added") + }) + }) + + t.Run("same provider rewrite from non-expiring to expiring keeps PERSIST'd until rewrite", func(t *testing.T) { + // When the SAME provider rewrites from non-expiring to expiring, the + // index set is no longer intentionally persistent (the only non-expiring + // member is gone). With the current "leave PERSIST'd alone" rule, the + // index stays without a TTL until something else evicts the entry. This + // is acceptable for now — documented limitation, not a leak in practice + // because DeleteUpstreamTokens cleans up the whole session. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "rewrite-session", "provider-a", &UpstreamTokens{ + AccessToken: "non-expiring", + ProviderID: "provider-a", + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "rewrite-session", "provider-a", &UpstreamTokens{ + AccessToken: "now-expiring", + ProviderID: "provider-a", + ExpiresAt: time.Now().Add(time.Hour), + })) + + idxKey := redisSetKey(s.keyPrefix, KeyTypeUpstreamIdx, "rewrite-session") + // Pre-existing PERSIST is preserved; we accept this trade-off rather + // than tracking per-member TTL state in Lua. DeleteUpstreamTokens + // remains the cleanup path. + assert.Equal(t, time.Duration(0), mr.TTL(idxKey), + "index TTL is left alone on same-provider rewrite (acceptable limitation)") + + // The new value is reachable. + retrieved, err := s.GetUpstreamTokens(ctx, "rewrite-session", "provider-a") + require.NoError(t, err) + require.NotNil(t, retrieved) + assert.Equal(t, "now-expiring", retrieved.AccessToken) + }) + }) + + t.Run("non-expiring token with SessionExpiresAt gets proper Redis TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + sessionExpiry := time.Now().Add(time.Hour) + require.NoError(t, s.StoreUpstreamTokens(ctx, "sess-bound", "github", &UpstreamTokens{ + AccessToken: "pat-token", + ProviderID: "github", + SessionExpiresAt: sessionExpiry, + })) + + retrieved, err := s.GetUpstreamTokens(ctx, "sess-bound", "github") + require.NoError(t, err) + require.NotNil(t, retrieved) + assert.Equal(t, "pat-token", retrieved.AccessToken) + assert.True(t, retrieved.ExpiresAt.IsZero(), "ExpiresAt must remain zero for non-expiring token") + // Assert field survives JSON round-trip (Unix truncation → 1s tolerance). + // RefreshAndStore carries SessionExpiresAt forward; silent zeroing here + // would cause every token refresh to lose the session bound. + assert.WithinDuration(t, sessionExpiry, retrieved.SessionExpiresAt, time.Second, + "SessionExpiresAt must survive Store→Get round-trip") + + // Fast-forward past SessionExpiresAt + DefaultRefreshTokenTTL + mr.FastForward(time.Hour + DefaultRefreshTokenTTL + time.Second) + + _, err = s.GetUpstreamTokens(ctx, "sess-bound", "github") + requireRedisNotFoundError(t, err) + }) + }) + + t.Run("deeply stale ExpiresAt branch clamps TTL to one second", func(t *testing.T) { + // Regression guard for the clamp introduced in marshalUpstreamTokensWithTTL. + // Pre-fix, a token whose access expiry + DefaultRefreshTokenTTL had both + // elapsed was stored with a full 30-day grace (DefaultRefreshTokenTTL), + // retaining stale tokens far longer than necessary. The fix clamps to + // time.Second so deeply-stale rows evict promptly. Cold-path callers + // (refresher races, admin rewrites, legacy-row migrations) must observe + // the 1-second lifetime — not the old 30-day grace — so this test pins + // the behavior and will fail loudly if the clamp is reverted. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + stalePast := time.Now().Add(-(DefaultRefreshTokenTTL + time.Hour)) + require.NoError(t, s.StoreUpstreamTokens(ctx, "stale-expires-session", "provider-a", &UpstreamTokens{ + AccessToken: "stale-token", + ProviderID: "provider-a", + ExpiresAt: stalePast, + })) + + key := redisUpstreamKey(s.keyPrefix, "stale-expires-session", "provider-a") + assert.LessOrEqual(t, mr.TTL(key), 2*time.Second, + "deeply-stale ExpiresAt token must be stored with a 1s TTL, not the full DefaultRefreshTokenTTL grace") + }) + }) + + t.Run("deeply stale SessionExpiresAt branch clamps TTL to one second", func(t *testing.T) { + // Mirror of the previous subtest for the SessionExpiresAt branch. + // When ExpiresAt is zero but SessionExpiresAt + DefaultRefreshTokenTTL + // have both elapsed, the same clamp must apply so that session-bound + // non-expiring tokens (e.g. GitHub PATs) don't linger for 30 days after + // the session itself has long expired. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + stalePast := time.Now().Add(-(DefaultRefreshTokenTTL + time.Hour)) + require.NoError(t, s.StoreUpstreamTokens(ctx, "stale-session-expires-session", "github", &UpstreamTokens{ + AccessToken: "stale-pat", + ProviderID: "github", + SessionExpiresAt: stalePast, + // ExpiresAt intentionally zero — exercises the SessionExpiresAt branch + })) + + key := redisUpstreamKey(s.keyPrefix, "stale-session-expires-session", "github") + assert.LessOrEqual(t, mr.TTL(key), 2*time.Second, + "deeply-stale SessionExpiresAt token must be stored with a 1s TTL, not the full DefaultRefreshTokenTTL grace") }) }) @@ -849,6 +1137,46 @@ func TestRedisStorage_UpstreamTokens(t *testing.T) { }) }) + t.Run("legacy JSON without session_expires_at decodes with zero SessionExpiresAt", func(t *testing.T) { + // Pin the wire-shape contract: pre-PR Redis data that has no + // "session_expires_at" key must deserialise to SessionExpiresAt.IsZero(). + // A future DisallowUnknownFields flip or JSON tag rename would break this + // without failing any other test in the suite. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + futureExpiry := time.Now().Add(time.Hour).Unix() + legacyJSON := fmt.Sprintf(`{ + "provider_id": "github", + "access_token": "legacy-access", + "refresh_token": "legacy-refresh", + "id_token": "legacy-id", + "expires_at": %d, + "user_id": "legacy-user-uuid", + "upstream_subject": "github-sub-123", + "client_id": "legacy-client" + }`, futureExpiry) + + // Inject directly into miniredis, bypassing the Store path, to simulate + // a pre-PR row written without "session_expires_at". + key := redisUpstreamKey(s.keyPrefix, "legacy-session", "github") + require.NoError(t, mr.Set(key, legacyJSON)) + + retrieved, err := s.GetUpstreamTokens(ctx, "legacy-session", "github") + require.NoError(t, err) + require.NotNil(t, retrieved) + + assert.Equal(t, "github", retrieved.ProviderID) + assert.Equal(t, "legacy-access", retrieved.AccessToken) + assert.Equal(t, "legacy-refresh", retrieved.RefreshToken) + assert.Equal(t, "legacy-id", retrieved.IDToken) + assert.Equal(t, "legacy-user-uuid", retrieved.UserID) + assert.Equal(t, "github-sub-123", retrieved.UpstreamSubject) + assert.Equal(t, "legacy-client", retrieved.ClientID) + assert.Equal(t, time.Unix(futureExpiry, 0), retrieved.ExpiresAt) + assert.True(t, retrieved.SessionExpiresAt.IsZero(), + "SessionExpiresAt must be zero when absent from legacy JSON") + }) + }) + } // --- Bulk Migration Tests --- @@ -1654,6 +1982,95 @@ func TestRedisKeyGeneration(t *testing.T) { result := redisSetKey("test:auth:", KeyTypeReqIDAccess, "req-123") assert.Equal(t, "test:auth:reqid:access:req-123", result) }) + + t.Run("redisDCRKey", func(t *testing.T) { + t.Parallel() + result := redisDCRKey("test:auth:", DCRKey{ + Issuer: "https://thv.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: "abc123", + }) + // 23 = len("https://thv.example.com"), 38 = len("https://thv.example.com/oauth/callback") + assert.Equal(t, + "test:auth:dcr:23:https://thv.example.com:38:https://thv.example.com/oauth/callback:abc123", + result) + }) +} + +// TestRedisDCRKey_Distinct pins the colon-safe lookup contract: any pair of +// distinct DCRKey tuples must serialise to distinct Redis keys, even when one +// component contains the literal substring of another. This is the property +// the length-prefixed encoding exists to guarantee — a plain +// fmt.Sprintf("%s:%s:%s", ...) form would collide for these inputs. +func TestRedisDCRKey_Distinct(t *testing.T) { + t.Parallel() + + mk := func(issuer, redirect, scopes string) DCRKey { + return DCRKey{Issuer: issuer, RedirectURI: redirect, ScopesHash: scopes} + } + + tests := []struct { + name string + a, b DCRKey + }{ + { + name: "different issuer", + a: mk("https://idp-a.example.com", "https://x/cb", "h1"), + b: mk("https://idp-b.example.com", "https://x/cb", "h1"), + }, + { + name: "different redirect_uri", + a: mk("https://idp.example.com", "https://x/cb", "h1"), + b: mk("https://idp.example.com", "https://y/cb", "h1"), + }, + { + name: "different scopes hash", + a: mk("https://idp.example.com", "https://x/cb", "h1"), + b: mk("https://idp.example.com", "https://x/cb", "h2"), + }, + { + // Without length prefixing, ("ab", "cd") and ("a", "bcd") would + // both yield ":ab:cd:" as the issuer/redirect segment after a + // fmt.Sprintf collapse. The length prefix prevents that. + name: "redirect_uri-issuer boundary collision (length-prefix property)", + a: mk("ab", "cd", "h1"), + b: mk("a", "bcd", "h1"), + }, + { + // RedirectURI legitimately contains colons (e.g. ":443"). A plain + // "%s:%s:%s" key would be ambiguous; the length prefix is not. + name: "colons inside redirect_uri", + a: mk("https://idp.example.com", "https://x.example.com:443/cb", "h1"), + b: mk("https://idp.example.com", "https://x.example.com/cb:443", "h1"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ka := redisDCRKey("test:auth:", tc.a) + kb := redisDCRKey("test:auth:", tc.b) + assert.NotEqual(t, ka, kb, "distinct DCRKey tuples must produce distinct Redis keys") + }) + } +} + +// TestRedisDCRKey_Deterministic pins that the key helper is a pure function: +// the same DCRKey produces the same Redis key on every call, with no hidden +// state (e.g. accidental use of map iteration order). +func TestRedisDCRKey_Deterministic(t *testing.T) { + t.Parallel() + + key := DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: ScopesHash([]string{"openid", "profile", "email"}), + } + + first := redisDCRKey("test:auth:", key) + for i := 0; i < 4; i++ { + assert.Equal(t, first, redisDCRKey("test:auth:", key)) + } } // --- Health Check Tests --- @@ -1680,3 +2097,800 @@ func TestRedisStorage_Health_ConnectionFailure(t *testing.T) { err := storage.Health(context.Background()) require.Error(t, err) } + +// --- GetLatestUpstreamTokensForUser Tests --- + +func TestRedisStorage_GetLatestUpstreamTokensForUser(t *testing.T) { + t.Parallel() + + t.Run("no_match", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("one_match_returns_record", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-1", + ExpiresAt: time.Now().Add(time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "rt-1", got.RefreshToken) + }) + }) + + t.Run("multiple_sessions_pick_latest_expires_at", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + now := time.Now() + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-1h", + ExpiresAt: now.Add(time.Hour), + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-2", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-2h", + ExpiresAt: now.Add(2 * time.Hour), + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-3", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-3h", + ExpiresAt: now.Add(3 * time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + assert.Equal(t, "rt-3h", got.RefreshToken) + }) + }) + + t.Run("different_user_not_matched", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // This case exits via the empty-SMEMBERS short-circuit (no user-upstream + // index for the queried userID), not the per-row ProviderID filter. The + // different_provider_not_matched case below exercises the row-level filter. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-B", + ExpiresAt: time.Now().Add(time.Hour), + })) + + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("different_provider_not_matched", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-Y", &UpstreamTokens{ + ProviderID: "prov-Y", + UserID: "user-A", + ExpiresAt: time.Now().Add(time.Hour), + })) + + _, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("tolerate_access_token_expired", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // ExpiresAt is 1 minute in the past. TTL = time.Until(-1min) + DefaultRefreshTokenTTL + // which is still large and positive, so Redis keeps the key. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-expired-at", + ExpiresAt: time.Now().Add(-time.Minute), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "rt-expired-at", got.RefreshToken) + }) + }) + + t.Run("zero_expires_at_wins_over_nonzero", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // Zero ExpiresAt is the no-expiry sentinel for providers like Slack and + // GitHub OAuth Apps. Treated as "alive forever" — beats any finite expiry. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero", + ExpiresAt: time.Time{}, + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-nonzero", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-nonzero", + ExpiresAt: time.Now().Add(time.Hour), + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + assert.Equal(t, "rt-zero", got.RefreshToken) + }) + }) + + t.Run("two_zero_expires_at_rows", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // Both rows have zero ExpiresAt. The winner is whichever is encountered + // first during iteration — iteration order over Redis set members is + // non-deterministic, so we assert only that one of them is returned. + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero-1", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero-1", + ExpiresAt: time.Time{}, + })) + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-zero-2", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-zero-2", + ExpiresAt: time.Time{}, + })) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Contains(t, []string{"rt-zero-1", "rt-zero-2"}, got.RefreshToken) + }) + }) + + // stale_index_entry is Redis-specific: a SMEMBER entry pointing at a key that + // was never written (simulating a TTL-evicted per-provider key). The real row + // must still be returned; the dangling member must not cause an error. + t.Run("stale_index_entry", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + // Store a real row for (user-A, prov-X). + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-real", "prov-X", &UpstreamTokens{ + ProviderID: "prov-X", + UserID: "user-A", + RefreshToken: "rt-real", + ExpiresAt: time.Now().Add(time.Hour), + })) + + // Inject a dangling member directly into the user reverse-index set. + // The key "test:auth:upstream:phantom-session:prov-X" was never written. + setKey := redisSetKey("test:auth:", KeyTypeUserUpstream, "user-A") + phantomKey := redisUpstreamKey("test:auth:", "phantom-session", "prov-X") + mr.SAdd(setKey, phantomKey) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "rt-real", got.RefreshToken) + }) + }) + + t.Run("returns_all_fields_round_trip", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // Truncate to second precision: Redis stores time as int64 unix seconds. + now := time.Now().Truncate(time.Second) + fixture := UpstreamTokens{ + ProviderID: "prov-X", + AccessToken: "access-tok", + RefreshToken: "refresh-tok", + IDToken: "id-tok", + ExpiresAt: now.Add(time.Hour), + SessionExpiresAt: now.Add(2 * time.Hour), + UserID: "user-A", + UpstreamSubject: "sub-upstream", + ClientID: "client-1", + } + + require.NoError(t, s.StoreUpstreamTokens(ctx, "session-rt", "prov-X", &fixture)) + + got, err := s.GetLatestUpstreamTokensForUser(ctx, "user-A", "prov-X") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, fixture, *got) + }) + }) +} + +// --- DCR Credentials Storage --- + +// dcrFixtureKey returns a populated DCRKey for use in DCR tests. +func dcrFixtureKey() DCRKey { + return DCRKey{ + Issuer: "https://thv.example.com", + RedirectURI: "https://thv.example.com/oauth/callback", + ScopesHash: ScopesHash([]string{"openid", "profile"}), + } +} + +func TestRedisStorage_DCRCredentials_RoundTrip(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + // Truncate to second precision: time fields are stored as int64 unix seconds. + createdAt := time.Now().Truncate(time.Second) + expiresAt := createdAt.Add(24 * time.Hour) + + creds := &DCRCredentials{ + Key: dcrFixtureKey(), + ProviderName: "atlassian", + ClientID: "client-abc", + ClientSecret: "secret-xyz", + TokenEndpointAuthMethod: "client_secret_basic", + RegistrationAccessToken: "rat-123", + RegistrationClientURI: "https://idp.example.com/register/client-abc", + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + CreatedAt: createdAt, + ClientSecretExpiresAt: expiresAt, + } + + require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + + got, err := s.GetDCRCredentials(ctx, creds.Key) + require.NoError(t, err) + require.NotNil(t, got) + // Every field round-trips, including the embedded Key. + assert.Equal(t, *creds, *got) + }) +} + +func TestRedisStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + key := dcrFixtureKey() + mk := func(clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + require.NoError(t, s.StoreDCRCredentials(ctx, mk("first"))) + require.NoError(t, s.StoreDCRCredentials(ctx, mk("second"))) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "second", got.ClientID) + }) +} + +func TestRedisStorage_DCRCredentials_NotFound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key DCRKey + }{ + { + name: "populated key with no stored entry", + key: dcrFixtureKey(), + }, + // Unpopulated keys cannot match any stored row (Store rejects them), + // so a Get against one is a normal miss — not a separate error class. + // This pins consistency with MemoryStorage.GetDCRCredentials. + { + name: "empty issuer", + key: DCRKey{Issuer: "", RedirectURI: "https://x/cb"}, + }, + { + name: "empty redirect_uri", + key: DCRKey{Issuer: "https://idp.example.com", RedirectURI: ""}, + }, + { + name: "empty scopes_hash", + key: DCRKey{Issuer: "https://idp.example.com", RedirectURI: "https://x/cb", ScopesHash: ""}, + }, + { + name: "fully empty key", + key: DCRKey{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + _, err := s.GetDCRCredentials(ctx, tc.key) + requireRedisNotFoundError(t, err) + }) + }) + } +} + +func TestRedisStorage_DCRCredentials_DistinctKeysCoexist(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + mkKey := func(issuer, redirect string, scopes []string) DCRKey { + return DCRKey{Issuer: issuer, RedirectURI: redirect, ScopesHash: ScopesHash(scopes)} + } + mk := func(key DCRKey, clientID string) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: clientID, + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + entries := []*DCRCredentials{ + mk(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid"}), "a"), + mk(mkKey("https://idp-b.example.com", "https://x/cb", []string{"openid"}), "b"), + mk(mkKey("https://idp-a.example.com", "https://y/cb", []string{"openid"}), "c"), + mk(mkKey("https://idp-a.example.com", "https://x/cb", []string{"openid", "email"}), "d"), + } + for _, e := range entries { + require.NoError(t, s.StoreDCRCredentials(ctx, e)) + } + + for _, want := range entries { + got, err := s.GetDCRCredentials(ctx, want.Key) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want.ClientID, got.ClientID) + } + }) +} + +// TestRedisStorage_DCRCredentials_StoreInvalidInputRejected mirrors +// TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected: every input +// rejected by validateDCRCredentialsForStore must produce +// fosite.ErrInvalidRequest and leave no row behind in Redis. +func TestRedisStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) { + t.Parallel() + + // validCreds returns a fully-populated DCRCredentials that subtests + // mutate to isolate a single missing field. Keeping every other field + // valid ensures the assertion proves which field was rejected. + validCreds := func() *DCRCredentials { + return &DCRCredentials{ + Key: DCRKey{ + Issuer: "https://idp.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + }, + ClientID: "abc", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + tests := []struct { + name string + mutator func(*DCRCredentials) *DCRCredentials + }{ + { + name: "nil creds", + mutator: func(*DCRCredentials) *DCRCredentials { return nil }, + }, + { + name: "empty issuer", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.Issuer = "" + return c + }, + }, + { + name: "empty redirect_uri", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.RedirectURI = "" + return c + }, + }, + { + name: "empty scopes_hash", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.Key.ScopesHash = "" + return c + }, + }, + { + name: "empty client_id", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.ClientID = "" + return c + }, + }, + { + name: "empty authorization_endpoint", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.AuthorizationEndpoint = "" + return c + }, + }, + { + name: "empty token_endpoint", + mutator: func(c *DCRCredentials) *DCRCredentials { + c.TokenEndpoint = "" + return c + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + err := s.StoreDCRCredentials(ctx, tc.mutator(validCreds())) + assert.ErrorIs(t, err, fosite.ErrInvalidRequest) + // Pin the fail-loud contract: a rejected Store must not leave + // any row behind, even under a partially-populated key. This + // mirrors MemoryStorage's `s.Stats().DCRCredentials == 0` guard + // (see TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected). + assert.Empty(t, mr.Keys(), "rejected Store must not leave any DCR row behind") + }) + }) + } +} + +// TestRedisStorage_DCRCredentials_GetReturnsDefensiveCopy pins the +// defensive-copy contract: mutating a returned value must not be visible to +// subsequent reads. The Redis backend gets this for free from JSON +// deserialisation, but the test pins the contract so a future change (e.g. +// caching) cannot silently break it. +func TestRedisStorage_DCRCredentials_GetReturnsDefensiveCopy(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + key := dcrFixtureKey() + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "orig", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + })) + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + got.ClientID = "mutated" + + refetched, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "orig", refetched.ClientID) + }) +} + +// TestRedisStorage_DCRCredentials_TTL pins the RFC 7591 §3.2.1 +// client_secret_expires_at semantics: +// - When ClientSecretExpiresAt is non-zero, the Redis row carries a TTL +// so it evicts before the upstream rejects the secret. +// - When ClientSecretExpiresAt is zero ("never"), the row is persistent +// (Redis TTL of -1). +// - When ClientSecretExpiresAt is in the past at write time, the row +// is written with the bounded `pastExpiryDCRTTL` (1 second) so an +// already-expired secret self-evicts almost immediately rather than +// persisting forever (see StoreDCRCredentials docstring). +func TestRedisStorage_DCRCredentials_TTL(t *testing.T) { + t.Parallel() + + t.Run("non-zero expiry sets a TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := dcrFixtureKey() + expires := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "client-with-expiry", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: expires, + })) + + ttl := mr.TTL(redisDCRKey("test:auth:", key)) + assert.Greater(t, ttl, time.Duration(0), "TTL should be positive when ClientSecretExpiresAt is in the future") + // Allow some slack for elapsed time between Set and TTL read. + assert.LessOrEqual(t, ttl, 24*time.Hour) + }) + }) + + t.Run("zero expiry means no TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := dcrFixtureKey() + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "client-no-expiry", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + // ClientSecretExpiresAt deliberately zero. + })) + + // miniredis returns 0 (not -1) for "no TTL"; the integration test + // asserts the real Redis -1 behaviour separately. + assert.Equal(t, time.Duration(0), mr.TTL(redisDCRKey("test:auth:", key)), + "row should be persistent when ClientSecretExpiresAt is zero") + }) + }) + + t.Run("past expiry uses bounded TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := dcrFixtureKey() + past := time.Now().Add(-time.Hour).Truncate(time.Second) + require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + Key: key, + ClientID: "client-past-expiry", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: past, + })) + + // Pin the bounded-TTL contract for past-expiry writes: + // the row exists immediately after the write (so a resolver that + // re-reads can observe the expiry timestamp and re-register), the + // stored ClientSecretExpiresAt round-trips, and the TTL is exactly + // pastExpiryDCRTTL — not 0 (which would persist forever) and not + // the negative time.Until() value. + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, past.Unix(), got.ClientSecretExpiresAt.Unix()) + assert.Equal(t, pastExpiryDCRTTL, mr.TTL(redisDCRKey("test:auth:", key)), + "past-expiry write must use the bounded pastExpiryDCRTTL, not TTL=0") + }) + }) +} + +// TestRedisStorage_DCRCredentials_ConcurrentAccess pins race-freedom of +// concurrent Put/Get under -race. Mirrors the Memory baseline by exercising +// both an overlapping keyspace (every goroutine hammers the same key, so +// reads can observe any goroutine's last write) and a disjoint keyspace +// (per-goroutine key, so each goroutine's Get must always hit). With go +// test -race this catches a future change that drops the lock or returns +// an internal pointer instead of a defensive copy. +// +// Errors from spawned goroutines are reported via an atomic counter checked +// from the test goroutine after wg.Wait() — calling require.NoError / +// FailNow from a goroutine other than the one running the test function +// is undefined behaviour per the testing.T docs. +func TestRedisStorage_DCRCredentials_ConcurrentAccess(t *testing.T) { + t.Parallel() + + t.Run("overlapping_key", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + runDCRConcurrentAccess(ctx, t, s, dcrConcurrentOverlappingKey, 10*time.Second) + }) + }) + + t.Run("disjoint_keys", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + runDCRConcurrentAccess(ctx, t, s, dcrConcurrentDisjointKeys, 10*time.Second) + }) + }) +} + +// dcrConcurrentMode selects the keyspace strategy used by +// runDCRConcurrentAccess. Two strategies — overlapping (every goroutine +// writes/reads the same key) and disjoint (each goroutine has its own key) — +// mirror the Memory baseline rationale at +// TestMemoryStorage_DCRCredentials_ConcurrentAccess. +type dcrConcurrentMode int + +const ( + dcrConcurrentOverlappingKey dcrConcurrentMode = iota + dcrConcurrentDisjointKeys +) + +// runDCRConcurrentAccess fans out goroutines doing alternating +// StoreDCRCredentials / GetDCRCredentials and asserts no Store errored and, +// when the keyspace is disjoint, that every Get hit. Shared between the +// unit-test (miniredis) and integration-test (real Redis) suites — the +// integration suite passes a longer deadline. +func runDCRConcurrentAccess( + ctx context.Context, + t *testing.T, + s *RedisStorage, + mode dcrConcurrentMode, + deadline time.Duration, +) { + t.Helper() + + const ( + goroutines = 8 + iterations = 16 + ) + + keyFor := func(gid, _ int) DCRKey { + switch mode { + case dcrConcurrentOverlappingKey: + return dcrFixtureKey() + case dcrConcurrentDisjointKeys: + return DCRKey{ + Issuer: fmt.Sprintf("https://idp-%d.example.com", gid), + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + } + t.Fatalf("unknown dcrConcurrentMode %d", mode) + return DCRKey{} + } + + mkCreds := func(key DCRKey, gid, i int) *DCRCredentials { + return &DCRCredentials{ + Key: key, + ClientID: fmt.Sprintf("client-%d-%d", gid, i), + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + } + } + + var ( + storeErrCount int32 + getErrCount int32 + ) + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + gid := g + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + key := keyFor(gid, i) + if err := s.StoreDCRCredentials(ctx, mkCreds(key, gid, i)); err != nil { + atomic.AddInt32(&storeErrCount, 1) + continue + } + if _, err := s.GetDCRCredentials(ctx, key); err != nil { + // In the disjoint keyspace, every goroutine just wrote its own + // key; a miss is a real error. In the overlapping keyspace, + // the immediate Get can race with another goroutine's + // rewrite-then-evict only if a TTL expires mid-test, which + // none of these credentials use, so a miss there is also an + // error to track. + atomic.AddInt32(&getErrCount, 1) + } + } + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(deadline): + t.Fatal("timeout waiting for concurrent DCR access goroutines") + } + + assert.Zero(t, atomic.LoadInt32(&storeErrCount), "no concurrent Store should have errored") + assert.Zero(t, atomic.LoadInt32(&getErrCount), "no concurrent Get should have errored") +} + +// captureWarnLogs swaps in a buffered slog handler at warn level for the +// duration of the test and returns the captured output. Process-global, not +// safe for t.Parallel(). +func captureWarnLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + orig := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(orig) }) + return &buf +} + +// assertForeignDropWarn checks that the warn log emitted for a filtered +// CROSSSLOT-defending op names the operation, the dropped member, and the +// expected key prefix. +func assertForeignDropWarn(t *testing.T, out, operation, member, prefix string) { + t.Helper() + assert.Contains(t, out, "dropping foreign index member to prevent CROSSSLOT", + "expected warn log emitted by %s; got: %s", operation, out) + assert.Contains(t, out, member, "warn log should name the dropped member") + assert.Contains(t, out, operation, "warn log should name the operation") + assert.Contains(t, out, strings.TrimSuffix(prefix, ":"), + "warn log should reference the expected prefix") +} + +// TestForeignMembersFilteredFromIndexOps verifies that the three multi-key +// SMEMBERS-fed operations (GetAllUpstreamTokens, DeleteUpstreamTokens, +// GetLatestUpstreamTokensForUser) drop foreign members before MGet/Del so a +// stray un-prefixed entry in an index set cannot escalate into a CROSSSLOT +// failure on Redis Cluster, and emit a warn log naming the dropped member. +// +// This test uses slog.SetDefault (process-global) and therefore does not run +// in parallel. +func TestForeignMembersFilteredFromIndexOps(t *testing.T) { //nolint:paralleltest // captures slog default + storage, mr := newTestRedisStorage(t) + t.Cleanup(func() { + _ = storage.Close() + mr.Close() + }) + ctx := context.Background() + + tokens := &UpstreamTokens{ + ProviderID: "github", + AccessToken: "real-access", + RefreshToken: "real-refresh", + UserID: "user-A", + ExpiresAt: time.Now().Add(time.Hour), + } + require.NoError(t, storage.StoreUpstreamTokens(ctx, "session-X", "github", tokens)) + + sessionIdxKey := redisSetKey(storage.keyPrefix, KeyTypeUpstreamIdx, "session-X") + userIdxKey := redisSetKey(storage.keyPrefix, KeyTypeUserUpstream, "user-A") + const foreignMember = "other-tenant:auth:{ns:other}:upstream:s:p" + mr.SAdd(sessionIdxKey, foreignMember) + mr.SAdd(userIdxKey, foreignMember) + + t.Run("GetAllUpstreamTokens", func(t *testing.T) { + buf := captureWarnLogs(t) + got, err := storage.GetAllUpstreamTokens(ctx, "session-X") + require.NoError(t, err) + require.Len(t, got, 1) + require.Contains(t, got, "github") + assert.Equal(t, "real-access", got["github"].AccessToken) + assertForeignDropWarn(t, buf.String(), "GetAllUpstreamTokens", foreignMember, storage.keyPrefix) + }) + + t.Run("GetLatestUpstreamTokensForUser", func(t *testing.T) { + buf := captureWarnLogs(t) + got, err := storage.GetLatestUpstreamTokensForUser(ctx, "user-A", "github") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "real-refresh", got.RefreshToken) + assertForeignDropWarn(t, buf.String(), "GetLatestUpstreamTokensForUser", foreignMember, storage.keyPrefix) + }) + + // DeleteUpstreamTokens runs last because it removes the index set. + t.Run("DeleteUpstreamTokens", func(t *testing.T) { + buf := captureWarnLogs(t) + require.NoError(t, storage.DeleteUpstreamTokens(ctx, "session-X")) + assertForeignDropWarn(t, buf.String(), "DeleteUpstreamTokens", foreignMember, storage.keyPrefix) + }) +} + +func TestFilterIndexMembersByPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + members []string + wantKept []string + wantDropped []string + }{ + { + name: "all members share prefix", + prefix: "thv:auth:{ns:name}:", + members: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"}, + wantKept: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"}, + wantDropped: nil, + }, + { + name: "stray un-prefixed member is dropped", + prefix: "thv:auth:{ns:name}:", + members: []string{"thv:auth:{ns:name}:upstream:s1:p1", "legacy-key", "thv:auth:{ns:name}:upstream:s1:p2"}, + wantKept: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"}, + wantDropped: []string{"legacy-key"}, + }, + { + name: "different-tenant member is dropped", + prefix: "thv:auth:{ns-a:srv}:", + members: []string{"thv:auth:{ns-a:srv}:upstream:s1:p1", "thv:auth:{ns-b:srv}:upstream:s1:p1"}, + wantKept: []string{"thv:auth:{ns-a:srv}:upstream:s1:p1"}, + wantDropped: []string{"thv:auth:{ns-b:srv}:upstream:s1:p1"}, + }, + { + name: "empty input", + prefix: "thv:auth:{ns:name}:", + members: nil, + wantKept: nil, + wantDropped: nil, + }, + { + name: "all members dropped", + prefix: "thv:auth:{ns:name}:", + members: []string{"foo", "bar"}, + wantKept: nil, + wantDropped: []string{"foo", "bar"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + kept, dropped := filterIndexMembersByPrefix(tc.prefix, tc.members) + assert.Equal(t, tc.wantKept, kept, "kept slice mismatch") + assert.Equal(t, tc.wantDropped, dropped, "dropped slice mismatch") + }) + } +} diff --git a/pkg/authserver/storage/redis_tls_test.go b/pkg/authserver/storage/redis_tls_test.go deleted file mode 100644 index 2477c4729e..0000000000 --- a/pkg/authserver/storage/redis_tls_test.go +++ /dev/null @@ -1,275 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package storage - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "net" - "testing" - "time" - - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildTLSConfig(t *testing.T) { - t.Parallel() - - caCert, caPEM := generateTestCACert(t) - - tests := []struct { - name string - cfg *RedisTLSConfig - wantErr bool - check func(t *testing.T, tc *tls.Config) - }{ - { - name: "nil config returns nil", - cfg: nil, - check: func(t *testing.T, tc *tls.Config) { - t.Helper() - assert.Nil(t, tc) - }, - }, - { - name: "empty config returns basic TLS config", - cfg: &RedisTLSConfig{}, - check: func(t *testing.T, tc *tls.Config) { - t.Helper() - require.NotNil(t, tc) - assert.Equal(t, uint16(tls.VersionTLS12), tc.MinVersion) - assert.False(t, tc.InsecureSkipVerify) - assert.Nil(t, tc.RootCAs) - }, - }, - { - name: "insecure skip verify", - cfg: &RedisTLSConfig{InsecureSkipVerify: true}, - check: func(t *testing.T, tc *tls.Config) { - t.Helper() - require.NotNil(t, tc) - assert.True(t, tc.InsecureSkipVerify) - }, - }, - { - name: "custom CA cert", - cfg: &RedisTLSConfig{CACert: caPEM}, - check: func(t *testing.T, tc *tls.Config) { - t.Helper() - require.NotNil(t, tc) - require.NotNil(t, tc.RootCAs) - _, err := caCert.Verify(x509.VerifyOptions{Roots: tc.RootCAs}) - assert.NoError(t, err, "CA cert should be verifiable with the pool") - }, - }, - { - name: "invalid CA cert returns error", - cfg: &RedisTLSConfig{CACert: []byte("not-a-cert")}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - tc, err := buildTLSConfig(tt.cfg) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - tt.check(t, tc) - }) - } -} - -func TestRedisTLSConfig_SeparateMasterAndSentinel(t *testing.T) { - t.Parallel() - - masterCfg := &RedisTLSConfig{} - sentinelCfg := &RedisTLSConfig{ - InsecureSkipVerify: true, - } - - masterTLS, err := buildTLSConfig(masterCfg) - require.NoError(t, err) - sentinelTLS, err := buildTLSConfig(sentinelCfg) - require.NoError(t, err) - - require.NotNil(t, masterTLS) - require.NotNil(t, sentinelTLS) - - assert.False(t, masterTLS.InsecureSkipVerify, "master should verify certs") - assert.True(t, sentinelTLS.InsecureSkipVerify, "sentinel should skip verification") -} - -func TestNewTLSDialer(t *testing.T) { - t.Parallel() - - timeout := 5 * time.Second - - t.Run("master TLS only: sentinel addr uses plaintext", func(t *testing.T) { - t.Parallel() - masterTLS := &tls.Config{MinVersion: tls.VersionTLS12} - - // Start a plaintext listener to simulate sentinel - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - - localSentinelAddrs := []string{ln.Addr().String()} - dialer := newTLSDialer(masterTLS, nil, localSentinelAddrs, timeout) - require.NotNil(t, dialer) - - conn, err := dialer(context.Background(), "tcp", ln.Addr().String()) - require.NoError(t, err) - conn.Close() - }) - - t.Run("sentinel TLS only: non-sentinel addr uses plaintext", func(t *testing.T) { - t.Parallel() - sentinelTLS := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // test - sentinelAddrs := []string{"sentinel.example.com:26379"} - - // Start a plaintext listener to simulate master - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - - dialer := newTLSDialer(nil, sentinelTLS, sentinelAddrs, timeout) - require.NotNil(t, dialer) - - conn, err := dialer(context.Background(), "tcp", ln.Addr().String()) - require.NoError(t, err) - conn.Close() - }) - - t.Run("sentinel address matching uses slices.Contains", func(t *testing.T) { - t.Parallel() - addrs := []string{"10.0.0.1:26379", "10.0.0.2:26379", "sentinel.redis.svc:26379"} - - // Start plaintext listener — not in sentinel list, so master config applies. - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - - sentinelTLS := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // test - dialer := newTLSDialer(nil, sentinelTLS, addrs, timeout) - - conn, err := dialer(context.Background(), "tcp", ln.Addr().String()) - require.NoError(t, err) - conn.Close() - }) -} - -func TestConfigureTLSDialer(t *testing.T) { - t.Parallel() - - _, caPEM := generateTestCACert(t) - - tests := []struct { - name string - masterCfg *RedisTLSConfig - sentinelCfg *RedisTLSConfig - wantDialer bool - wantErr bool - }{ - { - name: "no TLS configs — no dialer", - wantDialer: false, - }, - { - name: "master TLS only — installs dialer", - masterCfg: &RedisTLSConfig{}, - wantDialer: true, - }, - { - name: "sentinel TLS only — installs dialer", - sentinelCfg: &RedisTLSConfig{}, - wantDialer: true, - }, - { - name: "both TLS — installs dialer", - masterCfg: &RedisTLSConfig{}, - sentinelCfg: &RedisTLSConfig{InsecureSkipVerify: true}, - wantDialer: true, - }, - { - name: "master TLS with invalid CA cert — returns error", - masterCfg: &RedisTLSConfig{CACert: []byte("bad-pem")}, - wantErr: true, - }, - { - name: "sentinel TLS with invalid CA cert — returns error", - sentinelCfg: &RedisTLSConfig{CACert: []byte("bad-pem")}, - wantErr: true, - }, - { - name: "both TLS with valid CA certs — installs dialer", - masterCfg: &RedisTLSConfig{CACert: caPEM}, - sentinelCfg: &RedisTLSConfig{CACert: caPEM}, - wantDialer: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := &redis.FailoverOptions{ - SentinelAddrs: []string{"sentinel:26379"}, - DialTimeout: 5 * time.Second, - } - - err := configureTLSDialer(opts, tt.masterCfg, tt.sentinelCfg) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - if tt.wantDialer { - assert.NotNil(t, opts.Dialer, "expected custom dialer to be set") - } else { - assert.Nil(t, opts.Dialer, "expected no custom dialer") - } - }) - } -} - -// generateTestCACert creates a self-signed CA certificate for testing. -func generateTestCACert(t *testing.T) (*x509.Certificate, []byte) { - t.Helper() - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "test-ca"}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour), - IsCA: true, - KeyUsage: x509.KeyUsageCertSign, - } - - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) - require.NoError(t, err) - - cert, err := x509.ParseCertificate(certDER) - require.NoError(t, err) - - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - - return cert, certPEM -} diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 1e4c99f9ff..9cc699d6eb 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -16,11 +16,15 @@ // OAuth authorization server. package storage -//go:generate mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage +//go:generate mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore import ( "context" + "crypto/sha256" + "encoding/hex" "errors" + "slices" + "sort" "time" "github.com/ory/fosite" @@ -61,7 +65,19 @@ type UpstreamTokens struct { AccessToken string //nolint:gosec // G117: field legitimately holds sensitive data RefreshToken string //nolint:gosec // G117: field legitimately holds sensitive data IDToken string - ExpiresAt time.Time + // ExpiresAt is when the access token expires. Zero value means the provider + // did not assert an expiry; callers must treat it as non-expiring. + ExpiresAt time.Time + // SessionExpiresAt is the Fosite session expiry time. The callback handler + // sets it from RefreshTokenLifespan on every initial write; the refresher + // carries it forward unchanged on each subsequent refresh, and defensively + // re-anchors it on legacy rows where both ExpiresAt and SessionExpiresAt + // are zero. Storage backends use it as a fallback storage lifetime when + // ExpiresAt is zero (non-expiring upstream token), bounding the row to the + // Fosite session. Set unconditionally — including for tokens with their own + // ExpiresAt — so the refresh path is safe even if the upstream provider stops + // asserting expires_in on a later rotation. + SessionExpiresAt time.Time // Security binding fields - validated on lookup to prevent cross-session attacks @@ -81,9 +97,262 @@ type UpstreamTokens struct { } // IsExpired returns true if the access token has expired. +// Returns true for nil receivers (treating nil tokens as expired). +// Zero ExpiresAt means the provider did not assert an expiry; returns false. // The now parameter allows for deterministic testing. +// +// This method is intended for storage eviction decisions only — it checks exact +// expiry with no preemptive buffer. For "should I refresh before using?" decisions, +// use upstream.Tokens.IsExpiredAt which includes a preemptive buffer window. func (t *UpstreamTokens) IsExpired(now time.Time) bool { - return now.After(t.ExpiresAt) + if t == nil { + return true + } + return !t.ExpiresAt.IsZero() && now.After(t.ExpiresAt) +} + +// DCRKey is the canonical lookup key for a DCR registration. The tuple is +// designed so that any backend (in-memory or Redis) serialises it identically +// without redefining the canonical form. ScopesHash is used rather than a raw +// scope slice so the key is comparable, fixed-size, and order-insensitive. +// +// The key lives in the storage package because both MemoryStorage and the +// future Redis backend must hash keys identically; keeping the canonical form +// next to the persistence implementations prevents drift. +type DCRKey struct { + // Issuer is the registration consumer's issuer identifier. The dual + // semantic depends on the consumer profile: + // - Embedded authorization server: its OWN local issuer (the embedded + // authserver that performed the registration). + // - CLI / direct OAuth flow: the UPSTREAM authorization server's + // issuer, because the CLI has no separate local issuer of its own. + // The cache is keyed by this value because two different consumers + // registering against the same upstream are distinct OAuth clients and + // must not share credentials. The (Issuer, RedirectURI, ScopesHash) + // tuple keeps the two consumer profiles' entries apart via the + // RedirectURI component (the embedded authserver registers an + // AS-origin callback while the CLI registers a loopback callback per + // RFC 8252 §7.3 — the two address spaces are disjoint), so a collision + // between profiles is impossible by construction even when the + // upstream is the same. Public-client vs confidential-client + // separation rides on that same disjoint-RedirectURI property at both + // the persistent-cache and in-process singleflight layers; encoding it on + // the key would invalidate every existing Redis-cached entry across a + // deployment without buying additional protection. If a future + // consumer brings the two address spaces into collision the key + // format must gain a consumer-identifier component alongside an + // explicit migration story. + Issuer string + + // RedirectURI is the redirect URI registered with the upstream + // authorization server. Embedded-authserver callers register an + // AS-origin callback; CLI callers register an RFC 8252 loopback + // callback. The two address spaces are disjoint, which is what makes + // the per-consumer cache namespace structurally safe today. + RedirectURI string + + // ScopesHash is the SHA-256 hex digest of the sorted, deduplicated scope + // list. Use ScopesHash() to compute this value — do NOT hash scopes by + // hand at call sites; the canonical form must be a single source of truth + // so the key matches across processes and backends. + ScopesHash string +} + +// ScopesHash returns the SHA-256 hex digest of the canonical OAuth scope set, +// suitable for use as DCRKey.ScopesHash. +// +// Canonicalisation: +// 1. Sort ascending so the digest is order-insensitive — e.g. +// []string{"openid", "profile"} and []string{"profile", "openid"} hash to +// the same value. +// 2. Deduplicate so that []string{"openid"} and []string{"openid", "openid"} +// hash to the same value. An OAuth scope set is a set, not a multiset +// (RFC 6749 §3.3), and without deduplication a caller that accidentally +// duplicated a scope would miss cache entries and trigger redundant +// RFC 7591 registrations. +// 3. Join with newlines (a character not valid in OAuth scope tokens per +// RFC 6749 §3.3) to avoid collision between e.g. ["ab", "c"] and +// ["a", "bc"]. +// +// nil and empty slice both canonicalise to the same hash. +func ScopesHash(scopes []string) string { + sorted := slices.Clone(scopes) + sort.Strings(sorted) + sorted = slices.Compact(sorted) + + h := sha256.New() + for i, s := range sorted { + if i > 0 { + _, _ = h.Write([]byte("\n")) + } + _, _ = h.Write([]byte(s)) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// validateDCRCredentialsForStore enforces the rejection contract that every +// DCRCredentialStore implementation must apply before persisting. Extracting +// it as a free function called by every backend prevents the validation set +// from drifting across implementations: a record that fails loud against one +// backend cannot silently persist against another. +// +// Rejected inputs: nil creds, an unpopulated Key (empty Issuer, RedirectURI, +// or ScopesHash), and missing RFC 7591 mandatory response fields (ClientID, +// AuthorizationEndpoint, TokenEndpoint). An empty ScopesHash is rejected +// because the canonical digest of any scope set — including the empty-scope +// set via ScopesHash(nil) — is non-empty, so an empty string can only be a +// caller bug. ClientSecret is left permissive because RFC 7591 §2 public +// clients (auth method "none") legitimately register without a secret. +func validateDCRCredentialsForStore(creds *DCRCredentials) error { + if creds == nil { + return fosite.ErrInvalidRequest.WithHint("dcr credentials cannot be nil") + } + if creds.Key.Issuer == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials key issuer cannot be empty") + } + if creds.Key.RedirectURI == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials key redirect_uri cannot be empty") + } + if creds.Key.ScopesHash == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials key scopes_hash cannot be empty") + } + if creds.ClientID == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials client_id cannot be empty") + } + if creds.AuthorizationEndpoint == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials authorization_endpoint cannot be empty") + } + if creds.TokenEndpoint == "" { + return fosite.ErrInvalidRequest.WithHint("dcr credentials token_endpoint cannot be empty") + } + return nil +} + +// DCRCredentials is the persisted form of an RFC 7591 Dynamic Client +// Registration result. All fields are populated from the upstream's DCR +// response. The RFC 7592 management fields (RegistrationAccessToken, +// RegistrationClientURI) are preserved verbatim so future rotation / +// management flows can use them. +// +// # Defensive copy +// +// Callers receive a defensive copy from the store. Mutations on the returned +// value do not affect persisted state, and mutations on a value passed to +// StoreDCRCredentials are not observed by subsequent reads. This matches the +// UpstreamTokens contract. +// +// # Lifetime +// +// Entries are long-lived — RFC 7591 client registrations do not expire unless +// the upstream asserts client_secret_expires_at. The in-memory backend +// retains entries for the process lifetime and is intentionally excluded from +// the periodic cleanup loop. The Redis backend applies TTL via SET with a +// duration when ClientSecretExpiresAt is non-zero. +// +// # Converter contract +// +// MUST update both converters in +// pkg/auth/dcr/store.go (resolutionToCredentials and +// credentialsToResolution) when adding, renaming, or removing a field +// here. The two converters are the only translation seam between this +// persisted type and the dcr-package *dcr.Resolution; a field added here +// without a paired converter update will silently fail to round-trip +// across an authserver restart. The round-trip behaviour is pinned by +// TestResolutionCredentialsRoundTrip in +// pkg/auth/dcr/store_test.go. +type DCRCredentials struct { + // Key is the canonical cache key: (Issuer, RedirectURI, ScopesHash). + Key DCRKey + + // ProviderName is the upstream's UpstreamRunConfig.Name. Debug / audit + // only — never used as a primary key. Two upstreams with different + // ProviderName but identical Key share one credential record. + ProviderName string + + ClientID string + ClientSecret string //nolint:gosec // G117: field legitimately holds sensitive data + TokenEndpointAuthMethod string + + // RegistrationAccessToken and RegistrationClientURI are RFC 7592 fields + // captured for future management operations (rotation, deletion). + RegistrationAccessToken string //nolint:gosec // G117: field legitimately holds sensitive data + RegistrationClientURI string + + AuthorizationEndpoint string + TokenEndpoint string + + // CreatedAt is the wall-clock time at which the registration completed. + // Used to compute staleness for observability — the cache itself does + // not expire entries based on age (see ClientSecretExpiresAt for the + // authoritative expiry signal). + CreatedAt time.Time + + // ClientSecretExpiresAt is the RFC 7591 §3.2.1 client_secret_expires_at + // value converted from int64 epoch seconds to time.Time. The wire + // convention is that 0 means "the secret does not expire"; this struct + // represents that as the zero time.Time so callers can use IsZero() + // rather than special-casing 0. + // + // When non-zero, this is the authoritative signal a backend uses to TTL + // the persisted entry: the Redis backend plumbs it through SET with a + // duration so the row evicts before the upstream rejects the secret at the + // token endpoint. The in-memory backend ignores this field — entries + // persist for the process lifetime and the resolver re-checks the + // expiry on read. + ClientSecretExpiresAt time.Time +} + +// DCRCredentialStore is a narrow, segregated interface for persisting +// dynamic-client-registration credentials. Both MemoryStorage and +// RedisStorage implement it; an authserver backed by Redis shares DCR +// credentials across replicas and restarts. +// +// # Cross-replica limitation +// +// Sharing DCR credentials does NOT imply cross-replica session / token +// delivery. Callers that need that must still route through the proxy runner +// and (if applicable) pin sessions to a replica. +// +// # Defensive copy +// +// Implementations MUST defensively copy on both Store and Get so caller +// mutations cannot reach persisted state and vice versa, mirroring the +// UpstreamTokens contract. +// +// # TTL handling +// +// Implementations SHOULD honor a non-zero DCRCredentials.ClientSecretExpiresAt +// as a backend-level TTL when the underlying store supports one (e.g. Redis +// SET with a duration) so an entry evicts before the upstream rejects the +// secret at the token endpoint. Backends without a native TTL (e.g. the +// in-memory backend) retain the field verbatim and rely on the caller — +// typically the runner's resolver — to re-check expiry on read; see +// MemoryStorage.GetDCRCredentials. +// A zero ClientSecretExpiresAt means the upstream did not assert an expiry +// and no TTL is applied. +// +// # Why the key is embedded in DCRCredentials +// +// StoreDCRCredentials takes a single (ctx, creds) argument rather than the +// (ctx, key, value) shape used by sibling Store* methods on Storage. The +// DCRKey is embedded as DCRCredentials.Key so the persisted blob is +// self-describing: a Redis SCAN, an admin-tool dump, or a cross-replica +// reconciliation path can identify a record's logical cache slot +// (Issuer, RedirectURI, ScopesHash) from the value alone, without +// reconstructing it from a separately-passed key. This is a deliberate +// asymmetry with the rest of the package — callers must populate creds.Key +// before Store, and implementations validate it (see MemoryStorage docs +// for the rejected-input list). +type DCRCredentialStore interface { + // GetDCRCredentials returns the credentials for the given key. + // Returns ErrNotFound (wrapped) if no entry exists for the key. + // The returned value is a defensive copy. + GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRCredentials, error) + + // StoreDCRCredentials persists the credentials, overwriting any existing + // entry for the same Key. See the interface-level "TTL handling" section + // for the contract on ClientSecretExpiresAt. + StoreDCRCredentials(ctx context.Context, creds *DCRCredentials) error } // User represents a user account in the authorization server. @@ -212,6 +481,15 @@ type ClientRegistry interface { // This supports both static configuration and dynamic client registration (RFC 7591). // Returns ErrAlreadyExists if a client with the same ID already exists. RegisterClient(ctx context.Context, client fosite.Client) error + + // RenewClientTTL extends the registration TTL of a public (DCR) client so an + // actively-used client is not evicted mid-lifecycle and forced to re-register. + // Call it on a proven-use signal (e.g. a successful token exchange), NOT on an + // unauthenticated client read such as the /oauth/authorize lookup. Implementations + // renew only public clients; confidential clients have no TTL. Backends without a + // native TTL (the in-memory backend) treat this as a no-op. A renewal failure is + // non-fatal to the caller's primary operation. + RenewClientTTL(ctx context.Context, client fosite.Client) error } // UpstreamTokenStorage provides storage for tokens obtained from upstream identity providers. @@ -219,8 +497,10 @@ type ClientRegistry interface { // middleware that needs to retrieve upstream tokens (e.g., token swap middleware that // replaces JWT auth with upstream IDP tokens for backend requests). // -// Tokens are keyed by (sessionID, providerName) to support multiple upstream providers -// per session. Each provider's tokens are stored and retrieved independently. +// Tokens are keyed primarily by (sessionID, providerName) to support multiple upstream +// providers per session. Each provider's tokens are stored and retrieved independently. +// A secondary lookup by (userID, providerID) is exposed via GetLatestUpstreamTokensForUser; +// see that method for usage and security contract. type UpstreamTokenStorage interface { // StoreUpstreamTokens stores the upstream IDP tokens for a session and provider. // The providerName identifies which upstream provider these tokens belong to. @@ -242,6 +522,42 @@ type UpstreamTokenStorage interface { // DeleteUpstreamTokens removes all upstream IDP tokens for a session (all providers). // Returns ErrNotFound if the session does not exist. DeleteUpstreamTokens(ctx context.Context, sessionID string) error + + // GetLatestUpstreamTokensForUser returns the most recently stored upstream tokens + // for (userID, providerID) across any session. The "latest" winner is determined + // by treating non-expiring rows (zero ExpiresAt — providers like Slack and GitHub + // OAuth Apps that genuinely never expire) as the strongest candidate, falling + // back to ExpiresAt descending among finite-expiry rows. This aligns with the + // rest of the package treating zero ExpiresAt as "alive forever" (see IsExpired, + // cleanupExpired, marshalUpstreamTokensWithTTL). Both backends use the same + // rule so the carry-forward decision is consistent regardless of deployment + // shape. + // + // This is a secondary lookup that intentionally bypasses the primary + // (sessionID, providerName) key. It is used by the OAuth callback to preserve a + // previously-issued refresh token when the upstream IdP omits refresh_token on + // re-authorization (e.g. Google without prompt=consent). When a sessionID is + // available, callers should use GetUpstreamTokens instead. This method mirrors + // the preservation pattern in upstreamTokenRefresher.RefreshAndStore. + // + // # Liveness + // + // The returned tokens may be expired. Callers MUST NOT assume liveness; they + // must handle the expired case (typically by reading only the RefreshToken, + // which remains usable past the access token's expiry). This method does NOT + // return ErrExpired and the implementation MUST NOT filter expired rows. + // + // # Cross-identity safety + // + // The returned record is NOT filtered by upstream subject. The same internal + // UserID can in principle map to multiple upstream subjects on the same provider + // (account-linking edge cases or data-integrity issues). Callers MUST verify + // that prior.UpstreamSubject == currentProviderSubject before reusing any + // credential from the returned record. The OAuth callback applies this guard; + // other future callers must do the same. + // + // Returns ErrNotFound if no record exists for the (userID, providerID) pair. + GetLatestUpstreamTokensForUser(ctx context.Context, userID, providerID string) (*UpstreamTokens, error) } // UpstreamTokenRefresher can refresh expired upstream tokens using their stored refresh token. @@ -339,6 +655,19 @@ type UserStorage interface { type Storage interface { // Embed segregated interfaces for IDP tokens, pending authorizations, client registry, // and user management for multi-IDP support. + // + // DCRCredentialStore is intentionally NOT embedded here: doing so would + // promote GetDCRCredentials / StoreDCRCredentials onto every consumer of + // storage.Storage (handlers, server, registration, etc.), broadening the + // surface that can read raw client_secret / registration_access_token even + // when those consumers have no DCR responsibility. Code that legitimately + // needs DCR access (the runner and authserver constructors) obtains it + // via an explicit `stor.(DCRCredentialStore)` type assertion at the + // boundary; the per-backend `var _ DCRCredentialStore = (*MemoryStorage)(nil)` + // and `var _ DCRCredentialStore = (*RedisStorage)(nil)` checks in + // memory.go / redis.go provide the compile-time guarantee that production + // backends satisfy the interface, so the runtime assertion is provably + // safe at the boundary while keeping the wider Storage surface narrow. UpstreamTokenStorage PendingAuthorizationStorage ClientRegistry diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index a4425a80e6..2248d91488 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -61,10 +61,10 @@ func TestUpstreamTokens_IsExpired(t *testing.T) { want: false, }, { - name: "expired - zero expiration time", + name: "zero expiration time treated as non-expiring", expiresAt: time.Time{}, checkTime: now, - want: true, + want: false, }, { name: "not expired - zero check time with future expiration", diff --git a/pkg/authserver/upstream/identity_from_token.go b/pkg/authserver/upstream/identity_from_token.go new file mode 100644 index 0000000000..9a3c4bf434 --- /dev/null +++ b/pkg/authserver/upstream/identity_from_token.go @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upstream + +import ( + "encoding/base64" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/tidwall/gjson" +) + +// partialIdentity holds identity fields extracted from a token response body. +// It is used internally to pass the extracted subject, name, and email +// between extractIdentityFromTokenResponse and the provider layer. +type partialIdentity struct { + Subject string + Name string + Email string +} + +// IdentityFromTokenConfig is the runtime configuration for extracting user +// identity directly from an OAuth2 token endpoint response body. +// +// Each path is a gjson dot-notation path (e.g. "username" or +// "associated_user.id") into the raw JSON body returned by the token +// endpoint. Path semantics, trust-model warnings, and uniqueness notes are +// documented on the corresponding CRD type +// (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig). +type IdentityFromTokenConfig struct { + // SubjectPath is the gjson path to the unique user identifier (required). + SubjectPath string + + // NamePath is the gjson path to the user's display name (optional). + // Leave empty to skip name extraction. + NamePath string + + // EmailPath is the gjson path to the user's email address (optional). + // Leave empty to skip email extraction. + EmailPath string +} + +// registerOnce ensures gjson modifiers are registered exactly once, even +// when RegisterModifiers is called concurrently from multiple goroutines +// (e.g. parallel auth-server constructors in tests or during thundering-herd +// restarts). gjson.AddModifier writes to a global map without internal +// synchronization, so concurrent calls race without this guard. +var registerOnce sync.Once + +// RegisterModifiers registers the gjson custom modifiers used by this +// package's path-based identity extractors. Call once during application +// or test wire-up before invoking any extractor that consumes a +// modifier-bearing path. Repeated calls are safe — the registration is +// protected by a sync.Once and executes at most once per process. +// +// Modifiers registered: +// - @upstreamjwt: see upstreamJWTModifier. +func RegisterModifiers() { + registerOnce.Do(func() { + gjson.AddModifier("upstreamjwt", upstreamJWTModifier) + }) +} + +// extractIdentityFromTokenResponse extracts user identity fields from a raw +// OAuth2 token endpoint response body using the paths in cfg. +// +// SubjectPath must resolve to a string or number value; objects, arrays, null, +// and missing paths are rejected with ErrIdentityResolutionFailed. NamePath +// and EmailPath are optional: type mismatches or missing paths produce a +// slog.Warn and leave the respective field empty. Empty NamePath/EmailPath in +// cfg means "do not extract" and are skipped silently. +func extractIdentityFromTokenResponse(body []byte, cfg *IdentityFromTokenConfig) (partialIdentity, error) { + if cfg == nil { + return partialIdentity{}, errors.New("identity extraction config is required") + } + + subjectResult := gjson.GetBytes(body, cfg.SubjectPath) + if err := validateIdentityField(subjectResult); err != nil { + return partialIdentity{}, fmt.Errorf("%w: subjectPath %q %s", ErrIdentityResolutionFailed, cfg.SubjectPath, err.Error()) + } + + name := extractOptionalField(body, cfg.NamePath, "namePath") + email := extractOptionalField(body, cfg.EmailPath, "emailPath") + + return partialIdentity{ + Subject: scalarToString(subjectResult), + Name: name, + Email: email, + }, nil +} + +// scalarToString returns the string representation of a gjson scalar value. +// For Number, it returns the raw JSON token rather than gjson.Result.String(), +// which formats via float64 and would lose precision for integer IDs larger +// than 2^53 (e.g., some upstream providers return 64-bit numeric subjects). +// For String, gjson.Result.String() correctly strips the surrounding quotes. +// The caller must already have validated the type. +func scalarToString(r gjson.Result) string { + if r.Type == gjson.Number { + return r.Raw + } + return r.String() +} + +// validateIdentityField checks that a gjson result is a non-empty scalar +// (string or number). Returns a descriptive error on failure. +func validateIdentityField(result gjson.Result) error { + if !result.Exists() { + return errors.New("path not found in token response") + } + switch result.Type { + case gjson.String: + if result.String() == "" { + return errors.New("resolved to an empty string") + } + return nil + case gjson.Number: + return nil + case gjson.JSON: + return errors.New("resolved to an object or array, expected a scalar") + case gjson.Null, gjson.False, gjson.True: + return errors.New("resolved to null or unsupported type") + } + // Unreachable: all gjson.Type cases are handled above. + return errors.New("unrecognised gjson result type") +} + +// extractOptionalField extracts an optional identity field from the token body. +// Returns an empty string if the path is not configured, missing, or has an +// unexpected type (with a slog.Warn for unexpected types). +func extractOptionalField(body []byte, path, fieldName string) string { + if path == "" { + return "" + } + result := gjson.GetBytes(body, path) + if !result.Exists() { + slog.Warn("optional identity field not found in token response", "field", fieldName, "path", path) + return "" + } + switch result.Type { + case gjson.String, gjson.Number: + return scalarToString(result) + case gjson.JSON, gjson.Null, gjson.False, gjson.True: + slog.Warn("optional identity field has unexpected type, skipping", "field", fieldName, "path", path) + return "" + } + // Unreachable: all gjson.Type cases are handled above. + return "" +} + +// upstreamJWTModifier is a gjson modifier that decodes the payload of a +// JWT-shaped string value and returns it as JSON, enabling further gjson +// path drilling (e.g. "access_token|@upstreamjwt|sub"). +// +// Trust model: NO signature verification. Use only for JWTs received over +// a TLS-authenticated channel directly from the upstream AS's token +// endpoint, where the channel itself provides integrity. For JWTs that +// have transited an untrusted hop, configure the upstream as OIDC and +// use the existing signed-ID-token path instead. +// +// Failure modes (all return ""): +// - input is not a JSON string +// - input does not contain exactly three dot-separated parts +// - the second part is not valid base64url +// +// Returning "" causes the next pipe stage to resolve to gjson.Null, which +// the caller's validateIdentityField rejects as "path not found". +func upstreamJWTModifier(jsonValue, _ string) string { + token := gjson.Parse(jsonValue).String() + if token == "" { + return "" + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + return string(payload) +} diff --git a/pkg/authserver/upstream/identity_from_token_test.go b/pkg/authserver/upstream/identity_from_token_test.go new file mode 100644 index 0000000000..3688c32be0 --- /dev/null +++ b/pkg/authserver/upstream/identity_from_token_test.go @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upstream + +import ( + "encoding/base64" + "errors" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + RegisterModifiers() + os.Exit(m.Run()) +} + +func makeJWT(payload string) string { + h := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + b := base64.RawURLEncoding.EncodeToString([]byte(payload)) + return h + "." + b + ".sig" +} + +func TestExtractIdentityFromTokenResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body []byte + cfg *IdentityFromTokenConfig + want partialIdentity + wantErr bool + wantErrIs error + }{ + { + name: "snowflake flat happy path", + body: []byte(`{"access_token":"opaque-blob","expires_in":600,"refresh_token":"r","token_type":"Bearer","username":"user1"}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + want: partialIdentity{Subject: "user1"}, + }, + { + name: "slack nested happy path", + body: []byte(`{"ok":true,"access_token":"xoxb-...","authed_user":{"id":"U1234"},"team":{"id":"T1"}}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "authed_user.id"}, + want: partialIdentity{Subject: "U1234"}, + }, + { + name: "shopify nested with all three fields", + body: []byte(`{"access_token":"a","associated_user":{"id":902541635,"email":"john@example.com","first_name":"John"}}`), + cfg: &IdentityFromTokenConfig{ + SubjectPath: "associated_user.id", + NamePath: "associated_user.first_name", + EmailPath: "associated_user.email", + }, + want: partialIdentity{Subject: "902541635", Name: "John", Email: "john@example.com"}, + }, + { + name: "numeric subject explicit", + body: []byte(`{"user_id":42}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "user_id"}, + want: partialIdentity{Subject: "42"}, + }, + { + name: "missing optional name path in body", + body: []byte(`{"sub":"user1"}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "sub", NamePath: "display_name"}, + want: partialIdentity{Subject: "user1"}, + }, + { + name: "name path not configured", + body: []byte(`{"sub":"user1","display_name":"Alice"}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "sub"}, + want: partialIdentity{Subject: "user1"}, + }, + { + name: "optional name path resolves to object, skipped", + body: []byte(`{"sub":"u1","profile":{"first":"Alice"}}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "sub", NamePath: "profile"}, + want: partialIdentity{Subject: "u1"}, + }, + { + name: "jwt-embedded subject happy path", + body: []byte(`{"access_token":"` + makeJWT(`{"sub":"u1"}`) + `"}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "access_token|@upstreamjwt|sub"}, + want: partialIdentity{Subject: "u1"}, + }, + { + name: "jwt-embedded subject, malformed jwt", + body: []byte(`{"access_token":"not.a.jwt.really"}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "access_token|@upstreamjwt|sub"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "empty subject value", + body: []byte(`{"username":""}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "subject path resolves to object", + body: []byte(`{"user":{"id":"x"}}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "user"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "subject path resolves to null", + body: []byte(`{"username":null}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "large numeric subject preserves integer precision beyond 2^53", + // 9007199254740993 = 2^53 + 1; not exactly representable as float64. + // We use the raw JSON token rather than formatting via gjson.Result.String() + // (which goes through float64) to keep the digits exact. + body: []byte(`{"user_id":9007199254740993}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "user_id"}, + want: partialIdentity{Subject: "9007199254740993"}, + }, + { + name: "subject path missing from body", + body: []byte(`{}`), + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "empty body", + body: []byte{}, + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "malformed json body", + body: []byte("not-json"), + cfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: true, + wantErrIs: ErrIdentityResolutionFailed, + }, + { + name: "nil cfg", + body: []byte(`{"username":"user1"}`), + cfg: nil, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := extractIdentityFromTokenResponse(tc.body, tc.cfg) + if tc.wantErr { + require.Error(t, err) + if tc.wantErrIs != nil { + assert.True(t, errors.Is(err, tc.wantErrIs), "expected error to wrap %v, got: %v", tc.wantErrIs, err) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractIdentityFromTokenResponse_ErrorDoesNotLeakBody(t *testing.T) { + t.Parallel() + + const secretMarker = "DO-NOT-LEAK-ME-XYZ" + body := []byte(`{"username":"","secret":"` + secretMarker + `"}`) + + _, err := extractIdentityFromTokenResponse(body, &IdentityFromTokenConfig{SubjectPath: "username"}) + require.Error(t, err) + assert.False(t, strings.Contains(err.Error(), secretMarker), + "error message must not contain body content, but got: %s", err.Error()) +} diff --git a/pkg/authserver/upstream/oauth2.go b/pkg/authserver/upstream/oauth2.go index f740d0c53a..7d18b196b3 100644 --- a/pkg/authserver/upstream/oauth2.go +++ b/pkg/authserver/upstream/oauth2.go @@ -18,6 +18,8 @@ package upstream import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -28,13 +30,12 @@ import ( "net/url" "os" "strings" - "time" "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/authserver/oauthparams" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( @@ -90,9 +91,6 @@ type OAuth2Provider interface { RefreshTokens(ctx context.Context, refreshToken, expectedSubject string) (*Tokens, error) } -// defaultTokenExpiration is the default token lifetime when expires_in is not specified. -const defaultTokenExpiration = time.Hour - // CommonOAuthConfig contains fields shared by all OAuth provider types. // This provides compile-time type safety by separating OIDC and OAuth2 configuration. type CommonOAuthConfig struct { @@ -154,7 +152,16 @@ type OAuth2Config struct { // When set, the provider performs the token exchange HTTP call directly (bypassing // golang.org/x/oauth2) and extracts fields using gjson dot-notation paths. // When nil, standard OAuth 2.0 token response parsing is used. + // See also: IdentityFromToken for extracting user identity from the same response. TokenResponseMapping *TokenResponseMapping `json:"token_response_mapping,omitempty" yaml:"token_response_mapping,omitempty"` + + // IdentityFromToken extracts user identity from the token-endpoint response + // body when the upstream provider includes identity claims there (e.g., + // Snowflake's `username`, Slack's `authed_user.id`). When set, the embedded + // auth server skips the userinfo HTTP call entirely. See the CRD type + // (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) for the + // authoritative trust-model and uniqueness documentation. + IdentityFromToken *IdentityFromTokenConfig `json:"identity_from_token,omitempty" yaml:"identity_from_token,omitempty"` } // TokenResponseMapping configures extraction of token fields from non-standard @@ -197,6 +204,11 @@ func (c *OAuth2Config) Validate() error { return errors.New("token_response_mapping.access_token_path is required when token_response_mapping is set") } } + if c.IdentityFromToken != nil { + if c.IdentityFromToken.SubjectPath == "" { + return errors.New("identity_from_token.subject_path is required when identity_from_token is set") + } + } return c.CommonOAuthConfig.Validate() } @@ -220,13 +232,6 @@ func convertOAuth2Token(token *oauth2.Token) (*Tokens, error) { return nil, fmt.Errorf("unexpected token_type: expected \"Bearer\", got %q", token.TokenType) } - // Calculate expiration time - expiresAt := token.Expiry - if expiresAt.IsZero() { - // Default to 1 hour if not specified - expiresAt = time.Now().Add(defaultTokenExpiration) - } - // Extract ID token from extras (OIDC providers include it here) var idToken string if idTokenVal := token.Extra("id_token"); idTokenVal != nil { @@ -239,7 +244,7 @@ func convertOAuth2Token(token *oauth2.Token) (*Tokens, error) { AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken, - ExpiresAt: expiresAt, + ExpiresAt: token.Expiry, }, nil } @@ -331,6 +336,16 @@ func NewOAuth2Provider(config *OAuth2Config, opts ...OAuth2ProviderOption) (*Bas "token_endpoint", config.TokenEndpoint, ) + if config.UserInfo == nil && config.IdentityFromToken == nil { + // Surface synthesis mode at construction so operators see it once + // per provider rather than only inferring from missing claims later. + slog.Warn("oauth2 upstream has no userinfo configured; using synthesis mode "+ + "(non-PII subject from access token, no Name/Email). Configure "+ + "userInfo if a real identity endpoint exists.", + "authorization_endpoint", config.AuthorizationEndpoint, + ) + } + return p, nil } @@ -399,46 +414,175 @@ func (p *BaseOAuth2Provider) buildAuthorizationURL( } // ExchangeCodeForIdentity exchanges an authorization code for tokens and resolves -// the user's identity in a single atomic operation. -// For pure OAuth2 providers, identity is resolved via the UserInfo endpoint. -// The nonce parameter is ignored for pure OAuth2 providers (no ID token validation). +// the user's identity in a single atomic operation. For pure OAuth2 providers +// (no ID token) the priority chain is: +// +// 1. IdentityFromToken (operator opt-in): extract identity claims from the +// token-endpoint response body using gjson paths. The userinfo HTTP call +// is skipped entirely. +// 2. UserInfo endpoint: fetch identity from the configured userinfo URL. +// 3. Synthesis: when neither is configured, synthesizeIdentity derives a +// non-PII Subject from the access token (rejects empty tokens to prevent +// the well-known sha256("") collision). Name and Email are empty; +// Synthetic=true tells the callback handler to bypass UserResolver +// because the subject rotates per access token. +// +// The nonce parameter is ignored (no ID token to validate). func (p *BaseOAuth2Provider) ExchangeCodeForIdentity(ctx context.Context, code, codeVerifier, _ string) (*Identity, error) { - tokens, err := p.exchangeCodeForTokens(ctx, code, codeVerifier) + exchanged, err := p.exchangeCodeForTokens(ctx, code, codeVerifier) if err != nil { return nil, err } - userInfo, err := p.fetchUserInfo(ctx, tokens.AccessToken) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrIdentityResolutionFailed, err) - } - if userInfo == nil || userInfo.Subject == "" { - return nil, ErrIdentityResolutionFailed - } + // Priority 1: identityFromToken (configured by operator). + if p.config.IdentityFromToken != nil { + if exchanged.identity == nil { + // The rewriter logged the extraction failure at WARN with the + // operator-supplied path. Surface the same error here so callers + // can report it without requiring WARN-level log access. + if exchanged.extractionErr != nil { + return nil, exchanged.extractionErr + } + // Defense-in-depth: the rewriter guarantees one of extractedIdentity or + // extractionErr is set when the token endpoint returns 200. This branch + // fires only if that invariant is ever relaxed (e.g., a non-200-success + // status the oauth2 library accepts, or a future rewriter change). + return nil, fmt.Errorf( + "%w: identityFromToken configured but extraction failed", + ErrIdentityResolutionFailed, + ) + } + return &Identity{ + Tokens: exchanged.tokens, + Subject: exchanged.identity.Subject, + Name: exchanged.identity.Name, + Email: exchanged.identity.Email, + }, nil + } + + // Priority 2: userInfo (existing behavior). + if p.config.UserInfo != nil { + userInfo, err := p.fetchUserInfo(ctx, exchanged.tokens.AccessToken) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrIdentityResolutionFailed, err) + } + if userInfo == nil || userInfo.Subject == "" { + return nil, ErrIdentityResolutionFailed + } + return &Identity{ + Tokens: exchanged.tokens, + Subject: userInfo.Subject, + Name: userInfo.Name, + Email: userInfo.Email, + }, nil + } + + // Priority 3: synthesis (PR 5094). Subject derived from access token; rotates + // per token. The callback handler treats Synthetic=true as opt-out from the + // user-resolver to avoid creating a fresh users row on every re-auth. + return synthesizeIdentity(exchanged.tokens) +} + +// synthesizedSubjectPrefix tags subjects produced by +// synthesizeSubjectFromAccessToken. The prefix is part of the package's +// externally observable contract; downstream code should recognize +// synthesized subjects via the exported IsSynthesizedSubject predicate +// rather than this constant. +const synthesizedSubjectPrefix = "tk-" + +// IsSynthesizedSubject reports whether subject was produced by the +// synthesis-mode fallback (vs. resolved from a userinfo endpoint or ID +// token). Use this for code paths that only see the bare subject string — +// e.g., JWT claim consumers, audit pipelines, status conditions. Callers +// holding an *Identity should prefer Identity.Synthetic, which is set at +// the same source of truth. +// +// Purely structural — checks the prefix only, does not validate the digest. +func IsSynthesizedSubject(subject string) bool { + return strings.HasPrefix(subject, synthesizedSubjectPrefix) +} + +// synthesizeSubjectFromAccessToken returns a stable, opaque identifier +// derived from an access token, for OAuth2 upstreams with no userinfo +// endpoint. Output: synthesizedSubjectPrefix + lowercase hex of the first +// 16 bytes of SHA-256(accessToken) — 35 chars total, e.g. +// "tk-89abcdef0123456789abcdef01234567". +// +// The output is non-PII assuming the upstream issues opaque (non-JWT) +// bearer tokens; the digest reveals nothing about the input beyond what an +// attacker holding a candidate token could confirm by re-hashing. 16 bytes +// is sufficient collision resistance for a session-key role. +// +// Only reached when OAuth2Config.UserInfo is nil. OIDC providers always +// have an ID-token-derived subject. Callers must reject empty access +// tokens — sha256("") is a well-known constant, and synthesizing a +// subject from it would collapse distinct sessions onto a single +// storage bucket. synthesizeIdentity enforces this invariant. +func synthesizeSubjectFromAccessToken(accessToken string) string { + sum := sha256.Sum256([]byte(accessToken)) + return synthesizedSubjectPrefix + hex.EncodeToString(sum[:16]) +} +// synthesizeIdentity builds a synthesized Identity for an OAuth2 upstream +// with no userinfo endpoint. Returns ErrIdentityResolutionFailed when the +// access token is empty: sha256("") is the well-known constant +// e3b0c44298fc1c14…, so synthesizing a subject from an empty token would +// collapse every affected session onto a single (UserID, ProviderID) +// storage bucket — a cross-tenant state-mixing hazard. Defense-in-depth: +// convertOAuth2Token already rejects empty AccessToken at exchange time, +// so this guard is unreachable today through ExchangeCodeForIdentity. It +// exists so a future code path (e.g., a custom token-response mapping +// that drops the field) cannot bypass the invariant. +func synthesizeIdentity(tokens *Tokens) (*Identity, error) { + if tokens.AccessToken == "" { + return nil, fmt.Errorf("%w: empty access token, cannot synthesize subject", ErrIdentityResolutionFailed) + } return &Identity{ - Tokens: tokens, - Subject: userInfo.Subject, - Name: userInfo.Name, - Email: userInfo.Email, + Tokens: tokens, + Subject: synthesizeSubjectFromAccessToken(tokens.AccessToken), + Synthetic: true, }, nil } +// tokenExchangeResult bundles the outputs of a successful token exchange: +// the obtained tokens, any identity extracted from the token response body, +// and any error from the identity extraction step. The exchange-level error +// is returned as the function's own error return value. +// +// extractionErr is populated when IdentityFromToken is configured but the +// extractor could not resolve the subject path. It carries operator-actionable +// diagnostics (path name, type description) and already wraps +// ErrIdentityResolutionFailed. The caller must check extractionErr when +// identity is nil and IdentityFromToken is set. +type tokenExchangeResult struct { + tokens *Tokens + identity *partialIdentity + extractionErr error +} + // exchangeCodeForTokens exchanges an authorization code for tokens with the upstream IDP. -func (p *BaseOAuth2Provider) exchangeCodeForTokens(ctx context.Context, code, codeVerifier string) (*Tokens, error) { +// It returns a tokenExchangeResult containing the tokens, any identity extracted from +// the token response body, and any extraction error. The function error is the +// exchange-level error (network, HTTP, token parsing). +func (p *BaseOAuth2Provider) exchangeCodeForTokens( + ctx context.Context, code, codeVerifier string, +) (*tokenExchangeResult, error) { if code == "" { return nil, errors.New("authorization code is required") } - slog.Info("exchanging authorization code for tokens", + slog.Debug("exchanging authorization code for tokens", "token_endpoint", p.config.TokenEndpoint, "has_pkce_verifier", codeVerifier != "", ) - // Wrap HTTP client with token response rewriter if mapping is configured. - // This normalizes non-standard responses (e.g., GovSlack's nested fields) - // before the oauth2 library parses them, keeping the standard exchange flow. - httpClient := wrapHTTPClientWithMapping(p.httpClient, p.config.TokenResponseMapping, p.config.TokenEndpoint) + // Wrap HTTP client with token response rewriter if mapping or identity extraction + // is configured. At auth-code time, both mapping (field normalization) and + // identityCfg (identity extraction) may be active together. Keep a reference to + // the rewriter so we can read extractedIdentity and extractionErr after Exchange returns. + httpClient, rewriter := wrapHTTPClientForTokenExchange( + p.httpClient, p.config.TokenResponseMapping, p.config.IdentityFromToken, p.config.TokenEndpoint, + ) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) // Build exchange options @@ -457,37 +601,62 @@ func (p *BaseOAuth2Provider) exchangeCodeForTokens(ctx context.Context, code, co return nil, err } - slog.Info("authorization code exchange successful", + slog.Debug("authorization code exchange successful", "has_refresh_token", tokens.RefreshToken != "", - "expires_at", tokens.ExpiresAt.Format(time.RFC3339), + "expires_at", expiresAtLogValue(tokens.ExpiresAt), ) - return tokens, nil + // Read any identity and extraction error captured by the rewriter during the + // token round-trip. rewriter is nil when neither mapping nor identityCfg is + // configured; nil-safe. + result := &tokenExchangeResult{tokens: tokens} + if rewriter != nil { + result.identity = rewriter.extractedIdentity + result.extractionErr = rewriter.extractionErr + } + + return result, nil } // RefreshTokens refreshes the upstream IDP tokens. +// +// Sends `scope` explicitly. RFC 6749 §6 makes the param optional and says the +// AS SHOULD preserve original scopes on omission, but some ASes (notably +// Entra ID v1) silently narrow refreshed tokens to the user's default consent +// set when `scope` is omitted, dropping custom resource scopes. +// +// Uses oauth2.Config.Exchange with SetAuthURLParam overrides (mirroring the +// pattern in pkg/auth/oauth/non_caching_refresher.go) because the standard +// library's TokenSource refresh path doesn't expose a way to inject scope. +// The empty code= side-effect is tolerated — ASes dispatch on grant_type first. func (p *BaseOAuth2Provider) RefreshTokens(ctx context.Context, refreshToken, _ string) (*Tokens, error) { if refreshToken == "" { return nil, errors.New("refresh token is required") } - slog.Info("refreshing tokens", + slog.Debug("refreshing tokens", "token_endpoint", p.config.TokenEndpoint, + "scope_count", len(p.oauth2Config.Scopes), ) // Wrap HTTP client with token response rewriter if mapping is configured. - httpClient := wrapHTTPClientWithMapping(p.httpClient, p.config.TokenResponseMapping, p.config.TokenEndpoint) + // Identity extraction (identityCfg) is intentionally nil here: per Snowflake's + // contract and the general design, the username/identity field is only present + // in the initial auth-code response and is omitted on refresh. Identity is + // cached at auth-code time and read from session storage on subsequent requests. + // The rewriter is discarded because refresh does not produce identity. + httpClient, _ := wrapHTTPClientForTokenExchange(p.httpClient, p.config.TokenResponseMapping, nil, p.config.TokenEndpoint) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - // Create an expired token with the refresh token to trigger refresh - expiredToken := &oauth2.Token{ - RefreshToken: refreshToken, - Expiry: time.Now().Add(-time.Hour), // Expired token forces refresh + opts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", "refresh_token"), + oauth2.SetAuthURLParam("refresh_token", refreshToken), + } + if len(p.oauth2Config.Scopes) > 0 { + opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(p.oauth2Config.Scopes, " "))) } - // Use TokenSource to get a new token via refresh - tokenSource := p.oauth2Config.TokenSource(ctx, expiredToken) - token, err := tokenSource.Token() + token, err := p.oauth2Config.Exchange(ctx, "", opts...) if err != nil { return nil, formatOAuth2Error(err, "token request failed") } @@ -496,10 +665,16 @@ func (p *BaseOAuth2Provider) RefreshTokens(ctx context.Context, refreshToken, _ if err != nil { return nil, err } + // AS may not issue a new refresh token (RFC 6749 §6); preserve the old one. + if tokens.RefreshToken == "" { + tokens.RefreshToken = refreshToken + } - slog.Info("token refresh successful", - "has_new_refresh_token", tokens.RefreshToken != "", - "expires_at", tokens.ExpiresAt.Format(time.RFC3339), + slog.Debug("token refresh successful", + // Read from token (pre-§6-fallback) so the log accurately reflects + // whether the AS rotated the refresh token vs the fallback kicking in. + "has_new_refresh_token", token.RefreshToken != "", + "expires_at", expiresAtLogValue(tokens.ExpiresAt), ) return tokens, nil diff --git a/pkg/authserver/upstream/oauth2_test.go b/pkg/authserver/upstream/oauth2_test.go index b7df055427..49f8fb933f 100644 --- a/pkg/authserver/upstream/oauth2_test.go +++ b/pkg/authserver/upstream/oauth2_test.go @@ -16,11 +16,16 @@ package upstream import ( "context" + "encoding/base64" + "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "net/url" + "strings" + "sync/atomic" "testing" "time" @@ -406,7 +411,7 @@ func TestBaseOAuth2Provider_exchangeCodeForTokens(t *testing.T) { provider, err := NewOAuth2Provider(config) require.NoError(t, err) - tokens, err := provider.exchangeCodeForTokens(ctx, "test-auth-code", "test-verifier") + result, err := provider.exchangeCodeForTokens(ctx, "test-auth-code", "test-verifier") require.NoError(t, err) // Verify request parameters @@ -418,12 +423,12 @@ func TestBaseOAuth2Provider_exchangeCodeForTokens(t *testing.T) { assert.Equal(t, "http://localhost:8080/callback", receivedParams.Get("redirect_uri")) // Verify response - assert.Equal(t, "exchanged-access-token", tokens.AccessToken) - assert.Equal(t, "exchanged-refresh-token", tokens.RefreshToken) + assert.Equal(t, "exchanged-access-token", result.tokens.AccessToken) + assert.Equal(t, "exchanged-refresh-token", result.tokens.RefreshToken) // Verify expiration is set approximately correctly expectedExpiry := time.Now().Add(7200 * time.Second) - assert.WithinDuration(t, expectedExpiry, tokens.ExpiresAt, 10*time.Second) + assert.WithinDuration(t, expectedExpiry, result.tokens.ExpiresAt, 10*time.Second) }) t.Run("handles error response from token endpoint", func(t *testing.T) { @@ -627,7 +632,7 @@ func TestBaseOAuth2Provider_exchangeCodeForTokens(t *testing.T) { assert.Contains(t, err.Error(), "unexpected token_type") }) - t.Run("default expiry when not specified", func(t *testing.T) { + t.Run("zero expiry when expires_in absent", func(t *testing.T) { t.Parallel() mock := newMockOAuth2Server() @@ -638,7 +643,7 @@ func TestBaseOAuth2Provider_exchangeCodeForTokens(t *testing.T) { resp := testTokenResponse{ AccessToken: "token", TokenType: "Bearer", - // ExpiresIn intentionally missing + // ExpiresIn intentionally missing — provider issues a non-expiring token } if err := json.NewEncoder(w).Encode(resp); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -658,12 +663,11 @@ func TestBaseOAuth2Provider_exchangeCodeForTokens(t *testing.T) { provider, err := NewOAuth2Provider(config) require.NoError(t, err) - tokens, err := provider.exchangeCodeForTokens(ctx, "test-code", "") + result, err := provider.exchangeCodeForTokens(ctx, "test-code", "") require.NoError(t, err) - // Should default to 1 hour - expectedExpiry := time.Now().Add(time.Hour) - assert.WithinDuration(t, expectedExpiry, tokens.ExpiresAt, 10*time.Second) + // No expires_in in the response means the token has no expiry. + assert.True(t, result.tokens.ExpiresAt.IsZero(), "ExpiresAt should be zero for non-expiring tokens") }) } @@ -812,6 +816,102 @@ func TestBaseOAuth2Provider_RefreshTokens(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "token request failed") }) + + t.Run("refresh request includes configured scopes", func(t *testing.T) { + t.Parallel() + + mock := newMockOAuth2Server() + t.Cleanup(mock.Close) + + var receivedParams url.Values + mock.tokenHandler = func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + receivedParams = r.PostForm + + w.Header().Set("Content-Type", "application/json") + resp := testTokenResponse{ + AccessToken: "refreshed-access-token", + TokenType: "Bearer", + RefreshToken: "new-refresh-token", + ExpiresIn: 3600, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + Scopes: []string{ + "openid", + "profile", + "api://example.com/custom:scope", + }, + }, + AuthorizationEndpoint: mock.URL + "/authorize", + TokenEndpoint: mock.URL + "/token", + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + _, err = provider.RefreshTokens(ctx, "old-refresh-token", "") + require.NoError(t, err) + + // Scope must be sent on refresh; some ASes drop custom scopes otherwise. + sentScopes := strings.Fields(receivedParams.Get("scope")) + assert.ElementsMatch(t, + []string{"openid", "profile", "api://example.com/custom:scope"}, + sentScopes, + "refresh request must include the configured scope set verbatim", + ) + }) + + t.Run("refresh preserves existing refresh_token when AS does not issue a new one", func(t *testing.T) { + t.Parallel() + + mock := newMockOAuth2Server() + t.Cleanup(mock.Close) + + mock.tokenHandler = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Response omits refresh_token (allowed by RFC 6749 §6). + resp := testTokenResponse{ + AccessToken: "refreshed-access-token", + TokenType: "Bearer", + ExpiresIn: 3600, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: mock.URL + "/authorize", + TokenEndpoint: mock.URL + "/token", + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + tokens, err := provider.RefreshTokens(ctx, "still-valid-refresh-token", "") + require.NoError(t, err) + + assert.Equal(t, "refreshed-access-token", tokens.AccessToken) + assert.Equal(t, "still-valid-refresh-token", tokens.RefreshToken, + "original refresh token must be preserved when response omits one") + }) } func TestBaseOAuth2Provider_WithOAuth2HTTPClient(t *testing.T) { @@ -838,9 +938,9 @@ func TestBaseOAuth2Provider_WithOAuth2HTTPClient(t *testing.T) { // Verify the provider works with custom client ctx := context.Background() - tokens, err := provider.exchangeCodeForTokens(ctx, "test-code", "") + result, err := provider.exchangeCodeForTokens(ctx, "test-code", "") require.NoError(t, err) - assert.NotEmpty(t, tokens.AccessToken) + assert.NotEmpty(t, result.tokens.AccessToken) } func TestBaseOAuth2Provider_TokenTypeValidation(t *testing.T) { @@ -972,11 +1072,11 @@ func TestBaseOAuth2Provider_IDToken(t *testing.T) { provider, err := NewOAuth2Provider(config) require.NoError(t, err) - tokens, err := provider.exchangeCodeForTokens(ctx, "test-code", "") + result, err := provider.exchangeCodeForTokens(ctx, "test-code", "") require.NoError(t, err) // OAuth2 providers can also return ID tokens if they support hybrid flows - assert.Equal(t, "test-id-token.payload.signature", tokens.IDToken) + assert.Equal(t, "test-id-token.payload.signature", result.tokens.IDToken) } func Test_validateRedirectURI(t *testing.T) { @@ -1198,6 +1298,179 @@ func TestBaseOAuth2Provider_ExchangeCodeForIdentity(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "authorization code is required") }) + + // When UserInfo is nil, ExchangeCodeForIdentity must synthesize Subject + // from the access token. The prefix-tagged Subject + empty Name/Email + // are the observable signals that the synthesis branch ran — the + // userinfo path populates Name/Email and would never emit a "tk-…" sub. + t.Run("synthesizes identity when UserInfo is nil", func(t *testing.T) { + t.Parallel() + + mock := newMockOAuth2Server() + t.Cleanup(mock.Close) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: mock.URL + "/authorize", + TokenEndpoint: mock.URL + "/token", + // UserInfo intentionally nil. + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + result, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + + require.NotNil(t, result) + assert.NotEmpty(t, result.Tokens.AccessToken) + // Subject is the prefix-tagged hash of the access token. + assert.True(t, strings.HasPrefix(result.Subject, synthesizedSubjectPrefix), + "synthesized subject must carry the %q prefix; got %q", + synthesizedSubjectPrefix, result.Subject) + assert.Equal(t, + synthesizeSubjectFromAccessToken(result.Tokens.AccessToken), + result.Subject, + "subject must be deterministic given the same access token") + // Synthesized identities expose no display surface. + assert.Empty(t, result.Name) + assert.Empty(t, result.Email) + // Synthetic=true is what tells the callback handler to bypass UserResolver. + assert.True(t, result.Synthetic, "synthesized identities must set Synthetic=true") + }) +} + +func TestSynthesizeSubjectFromAccessToken(t *testing.T) { + t.Parallel() + + t.Run("is deterministic for a given token", func(t *testing.T) { + t.Parallel() + token := "atlassian-mcp-style-opaque-token-93c" + assert.Equal(t, + synthesizeSubjectFromAccessToken(token), + synthesizeSubjectFromAccessToken(token), + ) + }) + + t.Run("differs for different tokens", func(t *testing.T) { + t.Parallel() + assert.NotEqual(t, + synthesizeSubjectFromAccessToken("token-a"), + synthesizeSubjectFromAccessToken("token-b"), + ) + }) + + t.Run("output shape: prefix + 32 hex chars", func(t *testing.T) { + t.Parallel() + got := synthesizeSubjectFromAccessToken("any-input") + assert.True(t, strings.HasPrefix(got, synthesizedSubjectPrefix)) + hexPart := strings.TrimPrefix(got, synthesizedSubjectPrefix) + assert.Len(t, hexPart, 32, "first 16 bytes of SHA-256 in hex is 32 chars") + // Must be valid hex. + _, err := hex.DecodeString(hexPart) + assert.NoError(t, err) + }) + +} + +// TestSynthesizeIdentity exercises the synthesis-mode helper directly, +// including the empty-access-token guard. The synthesizer itself is a pure +// hash and would happily emit the well-known sha256("") constant for "" — +// synthesizeIdentity is the layer that refuses to do so, preventing distinct +// sessions from collapsing onto a single (UserID, ProviderID) storage bucket. +func TestSynthesizeIdentity(t *testing.T) { + t.Parallel() + + t.Run("rejects empty access token", func(t *testing.T) { + t.Parallel() + // Defense-in-depth: convertOAuth2Token already catches empty + // AccessToken at exchange time, so this guard is unreachable + // through the public API today. The test asserts the invariant + // regardless, so a future code path that bypasses + // convertOAuth2Token cannot silently synthesize the constant + // sha256("") subject. + got, err := synthesizeIdentity(&Tokens{AccessToken: ""}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrIdentityResolutionFailed) + assert.Nil(t, got) + }) + + t.Run("synthesizes for non-empty access token", func(t *testing.T) { + t.Parallel() + tokens := &Tokens{AccessToken: "atlassian-mcp-style-opaque-token"} + got, err := synthesizeIdentity(tokens) + require.NoError(t, err) + require.NotNil(t, got) + assert.True(t, got.Synthetic, "synthesized identities must set Synthetic=true") + assert.Equal(t, + synthesizeSubjectFromAccessToken(tokens.AccessToken), + got.Subject, + "subject must be deterministic given the same access token") + assert.Empty(t, got.Name) + assert.Empty(t, got.Email) + assert.Same(t, tokens, got.Tokens, "tokens reference is preserved") + }) +} + +func TestIsSynthesizedSubject(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subject string + want bool + }{ + // Round-trip: predicate must recognize anything the synthesizer emits. + { + name: "round-trip on synthesized subject", + subject: synthesizeSubjectFromAccessToken("any-opaque-token"), + want: true, + }, + // Real upstream subjects (UUIDs, integer IDs) must not classify as synthesized. + { + name: "uuid-shaped subject is not synthesized", + subject: "11012b90-98d0-4594-916e-54db832ebe8f", + want: false, + }, + { + name: "integer-shaped subject is not synthesized", + subject: "1234567890", + want: false, + }, + { + name: "atlassian-shaped account_id is not synthesized", + subject: "5e1234567890abcdef123456", + want: false, + }, + { + name: "empty string is not synthesized", + subject: "", + want: false, + }, + // HasPrefix, not substring search. + { + name: "tk- in middle of subject is not synthesized", + subject: "user-tk-abc", + want: false, + }, + // Predicate is a fast prefix guard, not a digest validator. + { + name: "prefix-only string is treated as synthesized", + subject: "tk-", + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, IsSynthesizedSubject(tc.subject)) + }) + } } func TestBaseOAuth2Provider_fetchUserInfo(t *testing.T) { @@ -1867,3 +2140,388 @@ func TestAuthorizationURL_AdditionalAuthorizationParams(t *testing.T) { assert.Equal(t, "caller-value", parsed.Query().Get("custom")) }) } + +// tokenBodyWithUsername is a minimal valid token response body that includes a +// "username" field used across several identityFromToken test cases. +const tokenBodyWithUsername = `{"access_token":"a","token_type":"Bearer","username":"u1"}` + +// newTokenResponseServer is a test helper that starts an httptest.Server whose +// /token endpoint returns tokenBody as the JSON response body, and whose +// /authorize endpoint always returns 200 OK. +func newTokenResponseServer(t *testing.T, tokenBody string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/authorize", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenBody)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestBaseOAuth2Provider_ExchangeCodeForIdentity_IdentityFromToken covers the +// priority-1 path where IdentityFromToken is configured. +func TestBaseOAuth2Provider_ExchangeCodeForIdentity_IdentityFromToken(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("identityFromToken resolves subject name email", func(t *testing.T) { + t.Parallel() + + tokenBody := `{"access_token":"a","token_type":"Bearer","username":"u1","display_name":"User One","email":"u1@example.com"}` + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "username", + NamePath: "display_name", + EmailPath: "email", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + assert.Equal(t, "u1", identity.Subject) + assert.Equal(t, "User One", identity.Name) + assert.Equal(t, "u1@example.com", identity.Email) + assert.NotEmpty(t, identity.Tokens.AccessToken) + assert.False(t, identity.Synthetic, "IdentityFromToken path must not produce synthetic identities") + }) + + t.Run("@upstreamjwt modifier resolves identity from JWT-shaped access token", func(t *testing.T) { + t.Parallel() + + // RegisterModifiers is called once by TestMain before all tests run. + // Do not call it again here: gjson.AddModifier writes to a shared map and + // races with concurrent reads in parallel subtests. + + accessToken := makeJWT(`{"sub":"u-jwt-1","name":"JWT User","email":"jwt@example.com"}`) + tokenBody := fmt.Sprintf(`{"access_token":%q,"token_type":"Bearer"}`, accessToken) + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "access_token|@upstreamjwt|sub", + NamePath: "access_token|@upstreamjwt|name", + EmailPath: "access_token|@upstreamjwt|email", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + assert.Equal(t, "u-jwt-1", identity.Subject) + assert.Equal(t, "JWT User", identity.Name) + assert.Equal(t, "jwt@example.com", identity.Email) + assert.False(t, identity.Synthetic) + }) + + t.Run("@upstreamjwt modifier accepts hand-forged unsigned JWT", func(t *testing.T) { + t.Parallel() + + // Construct a JWT with a garbage signature. The payload is valid base64url-encoded + // JSON, but the signature is not the real HMAC/RSA output. This test pins the + // intentional no-signature-check behavior. A future contributor who adds + // verification must update the trust model docs and this test. + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"forged-subject"}`)) + forgedJWT := header + "." + payload + ".INVALIDSIG" + + tokenBody := fmt.Sprintf(`{"access_token":%q,"token_type":"Bearer"}`, forgedJWT) + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "access_token|@upstreamjwt|sub", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + // No signature verification — extraction succeeds despite the forged signature. + assert.Equal(t, "forged-subject", identity.Subject) + }) + + t.Run("identityFromToken bypasses userinfo endpoint entirely", func(t *testing.T) { + t.Parallel() + + // httptest.Server dispatches each request on its own goroutine, so + // the counter must be accessed atomically — t.Errorf inside the + // handler is the load-bearing assertion; the counter just gives a + // numeric value the final assertion can read race-free. + var tripwireCallCount atomic.Int32 + tripwire := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + tripwireCallCount.Add(1) + t.Errorf("userinfo endpoint must NOT be called when identityFromToken is configured") + })) + t.Cleanup(tripwire.Close) + + tokenSrv := newTokenResponseServer(t, tokenBodyWithUsername) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "username", + }, + // UserInfo is also configured — identityFromToken must win and + // the tripwire userinfo server must never be contacted. + UserInfo: &UserInfoConfig{ + EndpointURL: tripwire.URL, + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + assert.Equal(t, "u1", identity.Subject) + assert.Equal(t, int32(0), tripwireCallCount.Load(), "userinfo endpoint must not be called") + }) + + t.Run("identityFromToken extraction failure returns ErrIdentityResolutionFailed without calling userinfo", func(t *testing.T) { + t.Parallel() + + var tripwireCallCount atomic.Int32 + tripwire := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + tripwireCallCount.Add(1) + t.Errorf("userinfo endpoint must NOT be called when identityFromToken is configured") + })) + t.Cleanup(tripwire.Close) + + // Token body does NOT contain "missing_path" + tokenSrv := newTokenResponseServer(t, tokenBodyWithUsername) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "missing_path", + }, + UserInfo: &UserInfoConfig{ + EndpointURL: tripwire.URL, + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + _, err = provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrIdentityResolutionFailed)) + assert.Equal(t, int32(0), tripwireCallCount.Load(), "userinfo endpoint must not be called on extraction failure") + }) + + t.Run("extraction failure error surfaces the misconfigured path name", func(t *testing.T) { + t.Parallel() + + tokenSrv := newTokenResponseServer(t, tokenBodyWithUsername) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + // Path that does not exist in the token response so the extractor + // produces a diagnostic that names the path and the failure reason. + SubjectPath: "nonexistent_field", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + _, err = provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrIdentityResolutionFailed)) + // The error must carry the operator-supplied path name so the + // misconfiguration is diagnosable without log access. + assert.Contains(t, err.Error(), "nonexistent_field", + "error must contain the misconfigured subject path name") + assert.Contains(t, err.Error(), "not found", + "error must describe why extraction failed") + }) + + t.Run("userInfo-only path is unchanged when identityFromToken is not set", func(t *testing.T) { + t.Parallel() + + userInfoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "u-456", + "name": "Bob", + "email": "bob@example.com", + }) + })) + t.Cleanup(userInfoSrv.Close) + + tokenBody := `{"access_token":"a","token_type":"Bearer"}` + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + UserInfo: &UserInfoConfig{ + EndpointURL: userInfoSrv.URL, + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + assert.Equal(t, "u-456", identity.Subject) + assert.Equal(t, "Bob", identity.Name) + assert.Equal(t, "bob@example.com", identity.Email) + }) + + t.Run("neither identityFromToken nor userInfo set falls through to synthesis", func(t *testing.T) { + t.Parallel() + + tokenBody := `{"access_token":"a","token_type":"Bearer"}` + tokenSrv := newTokenResponseServer(t, tokenBody) + + // Both identity surfaces absent — the priority chain falls through to + // synthesizeIdentity (PR 5094): a non-PII Subject derived from the + // access token, Synthetic=true, no Name/Email. + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + identity, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + assert.True(t, identity.Synthetic, "expected synthesized identity when neither IdentityFromToken nor UserInfo is set") + assert.True(t, IsSynthesizedSubject(identity.Subject), + "expected synthesized subject prefix; got %q", identity.Subject) + assert.Empty(t, identity.Name, "synthesized identity has no name") + assert.Empty(t, identity.Email, "synthesized identity has no email") + }) + + t.Run("IdentityFromToken configured does not cause RefreshTokens to fail when identity field is absent", func(t *testing.T) { + t.Parallel() + + // Token body intentionally does NOT include "username". If extraction ran on + // the refresh path, it would fail (path absent) and RefreshTokens would error. + // A successful return proves extraction was NOT run on the refresh path. + tokenBody := `{"access_token":"refreshed","token_type":"Bearer","refresh_token":"new-refresh"}` + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + SubjectPath: "username", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + // RefreshTokens must succeed even though "username" is absent from the + // response, proving that identity extraction is skipped on the refresh path. + tokens, err := provider.RefreshTokens(ctx, "old-refresh-token", "") + require.NoError(t, err) + assert.Equal(t, "refreshed", tokens.AccessToken) + }) + + t.Run("error message does not contain token body content", func(t *testing.T) { + t.Parallel() + + secretMarker := "SUPER_SECRET_TOKEN_BODY_MARKER_XYZ789" + tokenBody := `{"access_token":"` + secretMarker + `","token_type":"Bearer","username":"u1"}` + tokenSrv := newTokenResponseServer(t, tokenBody) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:8080/callback", + }, + AuthorizationEndpoint: tokenSrv.URL + "/authorize", + TokenEndpoint: tokenSrv.URL + "/token", + IdentityFromToken: &IdentityFromTokenConfig{ + // Deliberately wrong path to trigger extraction failure. + SubjectPath: "missing_field", + }, + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + _, err = provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrIdentityResolutionFailed)) + // The error must not leak any part of the token response body. + assert.NotContains(t, err.Error(), secretMarker, + "error message must not contain token body content") + }) +} diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index b22bac8f55..0b8bf47a56 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -11,13 +11,12 @@ import ( "net/http" "net/url" "slices" - "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( @@ -243,27 +242,36 @@ func (p *OIDCProviderImpl) ExchangeCodeForIdentity( return nil, errors.New("OIDC endpoints not discovered") } - tokens, err := p.exchangeCodeForTokens(ctx, code, codeVerifier) + // OIDC resolves identity from the validated ID token, not the token response + // body, so the extracted identity return value is intentionally discarded. + // Defense-in-depth: warn if the OAuth2 base config carries a non-nil + // IdentityFromToken — the field is structurally absent on the OIDC CRD type + // today, but a future config-loader bug or hand-built runtime config could + // silently drop the operator's intent without this signal. + if p.config.IdentityFromToken != nil { + slog.Debug("OIDC provider ignoring IdentityFromToken; identity is resolved from the validated ID token") + } + exchanged, err := p.exchangeCodeForTokens(ctx, code, codeVerifier) if err != nil { return nil, err } // OIDC-specific: ID token MUST be present per Section 3.1.3.3. - if tokens.IDToken == "" { + if exchanged.tokens.IDToken == "" { return nil, fmt.Errorf("%w: ID token required for OIDC provider", ErrIdentityResolutionFailed) } // Validate ID token with nonce in a single pass — no double-validation. - validatedToken, err := p.validateIDToken(ctx, tokens.IDToken, nonce) + validatedToken, err := p.validateIDToken(ctx, exchanged.tokens.IDToken, nonce) if err != nil { slog.Debug("id token validation failed", "error", err) return nil, fmt.Errorf("%w: %w", ErrIdentityResolutionFailed, err) } slog.Debug("authorization code exchange successful", - "has_refresh_token", tokens.RefreshToken != "", - "has_id_token", tokens.IDToken != "", - "expires_at", tokens.ExpiresAt.Format(time.RFC3339), + "has_refresh_token", exchanged.tokens.RefreshToken != "", + "has_id_token", exchanged.tokens.IDToken != "", + "expires_at", expiresAtLogValue(exchanged.tokens.ExpiresAt), ) // Extract optional standard claims (name, email) from ID token @@ -279,7 +287,7 @@ func (p *OIDCProviderImpl) ExchangeCodeForIdentity( } return &Identity{ - Tokens: tokens, + Tokens: exchanged.tokens, Subject: validatedToken.Subject, Name: idClaims.Name, Email: idClaims.Email, @@ -409,7 +417,7 @@ func (p *OIDCProviderImpl) RefreshTokens(ctx context.Context, refreshToken, expe slog.Debug("token refresh successful", "has_new_refresh_token", tokens.RefreshToken != "", - "expires_at", tokens.ExpiresAt.Format(time.RFC3339), + "expires_at", expiresAtLogValue(tokens.ExpiresAt), ) return tokens, nil diff --git a/pkg/authserver/upstream/oidc_test.go b/pkg/authserver/upstream/oidc_test.go index be3b580583..1e4f8777a8 100644 --- a/pkg/authserver/upstream/oidc_test.go +++ b/pkg/authserver/upstream/oidc_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( diff --git a/pkg/authserver/upstream/token_exchange.go b/pkg/authserver/upstream/token_exchange.go index 16fa65c1da..6e66bfd1d3 100644 --- a/pkg/authserver/upstream/token_exchange.go +++ b/pkg/authserver/upstream/token_exchange.go @@ -7,28 +7,50 @@ import ( "bytes" "encoding/json" "io" + "log/slog" "net/http" "github.com/tidwall/gjson" ) // tokenResponseRewriter is an http.RoundTripper that normalizes non-standard -// OAuth token responses before the golang.org/x/oauth2 library parses them. +// OAuth token responses before the golang.org/x/oauth2 library parses them, +// and optionally extracts user identity claims from the raw response body. // // Some providers (e.g., GovSlack) nest token fields under non-standard paths // like "authed_user.access_token" instead of the top-level "access_token". // This RoundTripper intercepts the response, extracts fields using gjson // dot-notation paths, and rewrites the response body with standard top-level // field names so the oauth2 library can parse them normally. +// +// When identityCfg is set, identity extraction runs on the RAW pre-rewrite +// body before any field relocation occurs. The extracted identity is stored +// in extractedIdentity and consumed by the caller after Exchange returns. +// If extraction fails, extractionErr holds the cause so callers can surface +// operator-actionable diagnostics (path names, type descriptions) without +// re-reading the response body. +// +// Single-use contract: each instance handles exactly one token exchange and +// must not be shared across goroutines or reused for multiple exchanges. The +// caller reads extractedIdentity and extractionErr after Exchange returns — +// that return is the synchronization edge, so no mutex is needed for this +// one-shot usage pattern. type tokenResponseRewriter struct { - base http.RoundTripper - mapping *TokenResponseMapping - tokenURL string + base http.RoundTripper + mapping *TokenResponseMapping + identityCfg *IdentityFromTokenConfig + tokenURL string + extractedIdentity *partialIdentity + extractionErr error } // RoundTrip intercepts HTTP responses from the token endpoint and rewrites // the JSON body to place mapped fields at the top level. Non-token-endpoint // requests (e.g., userInfo) pass through unchanged. +// +// When identityCfg is set, identity is extracted from the raw response body +// BEFORE the rewrite step so that identity paths are resolved against the +// original provider response, not the normalized form. func (t *tokenResponseRewriter) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.base.RoundTrip(req) if err != nil { @@ -51,7 +73,31 @@ func (t *tokenResponseRewriter) RoundTrip(req *http.Request) (*http.Response, er return nil, err } - rewritten := rewriteTokenResponse(body, t.mapping) + // Extract from the raw body before rewriteTokenResponse runs. The rewrite + // never touches identity-shaped fields today, but extracting first makes + // the ordering invariant independent of that assumption. + if t.identityCfg != nil { + result, extractErr := extractIdentityFromTokenResponse(body, t.identityCfg) + if extractErr != nil { + // WARN so an operator misconfiguration (e.g., wrong subjectPath) is + // visible without enabling DEBUG. The error is safe to log: it + // contains operator-supplied paths and type descriptions, never + // any portion of the response body. + slog.Warn("identity extraction from token response failed", "error", extractErr) + t.extractionErr = extractErr + } else { + t.extractedIdentity = &result + } + } + + // Only run the field-rewrite step when a mapping is configured. + // When mapping is nil (e.g. only identityCfg is set), pass the body through unchanged. + var rewritten []byte + if t.mapping != nil { + rewritten = rewriteTokenResponse(body, t.mapping) + } else { + rewritten = body + } resp.Body = io.NopCloser(bytes.NewReader(rewritten)) resp.ContentLength = int64(len(rewritten)) @@ -107,12 +153,20 @@ func rewriteTokenResponse(body []byte, mapping *TokenResponseMapping) []byte { return rewritten } -// wrapHTTPClientWithMapping wraps an HTTP client's transport with a -// tokenResponseRewriter when a TokenResponseMapping is configured. -// Returns the original client unchanged if mapping is nil. -func wrapHTTPClientWithMapping(client *http.Client, mapping *TokenResponseMapping, tokenURL string) *http.Client { - if mapping == nil { - return client +// wrapHTTPClientForTokenExchange wraps an HTTP client's transport with a +// tokenResponseRewriter when either a TokenResponseMapping or an +// IdentityFromTokenConfig is configured (or both). Returns the original client +// and nil rewriter when both are nil, so the standard oauth2 library path is +// used. The returned rewriter (when non-nil) can be read after Exchange returns +// to retrieve any identity extracted during the token round-trip. +func wrapHTTPClientForTokenExchange( + client *http.Client, + mapping *TokenResponseMapping, + identityCfg *IdentityFromTokenConfig, + tokenURL string, +) (*http.Client, *tokenResponseRewriter) { + if mapping == nil && identityCfg == nil { + return client, nil } base := client.Transport @@ -120,14 +174,17 @@ func wrapHTTPClientWithMapping(client *http.Client, mapping *TokenResponseMappin base = http.DefaultTransport } + rewriter := &tokenResponseRewriter{ + base: base, + mapping: mapping, + identityCfg: identityCfg, + tokenURL: tokenURL, + } + // Create a shallow copy to avoid mutating the original client wrapped := *client - wrapped.Transport = &tokenResponseRewriter{ - base: base, - mapping: mapping, - tokenURL: tokenURL, - } - return &wrapped + wrapped.Transport = rewriter + return &wrapped, rewriter } // pathOrDefault returns the path if non-empty, otherwise returns the default. diff --git a/pkg/authserver/upstream/token_exchange_test.go b/pkg/authserver/upstream/token_exchange_test.go index 125c78750e..d3b4888153 100644 --- a/pkg/authserver/upstream/token_exchange_test.go +++ b/pkg/authserver/upstream/token_exchange_test.go @@ -15,6 +15,14 @@ import ( "github.com/stretchr/testify/require" ) +// tokenEndpointHandler returns an HTTP handler that responds with the given JSON body. +func tokenEndpointHandler(body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + } +} + func TestRewriteTokenResponse(t *testing.T) { t.Parallel() @@ -154,7 +162,7 @@ func TestTokenResponseRewriter_TokenEndpoint(t *testing.T) { ScopePath: "authed_user.scope", } - client := wrapHTTPClientWithMapping(http.DefaultClient, mapping, tokenServer.URL) + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, mapping, nil, tokenServer.URL) req, err := http.NewRequest("POST", tokenServer.URL, strings.NewReader("grant_type=authorization_code")) require.NoError(t, err) @@ -187,7 +195,7 @@ func TestTokenResponseRewriter_NonTokenEndpoint(t *testing.T) { mapping := &TokenResponseMapping{AccessTokenPath: "authed_user.access_token"} // Token URL points elsewhere, so this server's responses should pass through unchanged - client := wrapHTTPClientWithMapping(http.DefaultClient, mapping, "https://other.example.com/token") + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, mapping, nil, "https://other.example.com/token") req, err := http.NewRequest("GET", server.URL, nil) require.NoError(t, err) @@ -207,12 +215,13 @@ func TestTokenResponseRewriter_NonTokenEndpoint(t *testing.T) { assert.False(t, hasAccessToken) } -func TestWrapHTTPClientWithMapping_NilMapping(t *testing.T) { +func TestWrapHTTPClientForTokenExchange_NilMapping(t *testing.T) { t.Parallel() original := &http.Client{} - result := wrapHTTPClientWithMapping(original, nil, "https://example.com/token") + result, rewriter := wrapHTTPClientForTokenExchange(original, nil, nil, "https://example.com/token") assert.Same(t, original, result) + assert.Nil(t, rewriter) } func TestTokenResponseRewriter_ErrorResponse(t *testing.T) { @@ -225,7 +234,7 @@ func TestTokenResponseRewriter_ErrorResponse(t *testing.T) { defer tokenServer.Close() mapping := &TokenResponseMapping{AccessTokenPath: "authed_user.access_token"} - client := wrapHTTPClientWithMapping(http.DefaultClient, mapping, tokenServer.URL) + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, mapping, nil, tokenServer.URL) req, err := http.NewRequest("POST", tokenServer.URL, strings.NewReader("grant_type=authorization_code")) require.NoError(t, err) @@ -280,3 +289,198 @@ func TestOAuth2Config_Validate_TokenResponseMapping(t *testing.T) { }) } } + +func TestOAuth2Config_Validate_IdentityFromToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identityCfg *IdentityFromTokenConfig + wantErr bool + errContains string + }{ + {name: "nil identity config is valid", identityCfg: nil, wantErr: false}, + { + name: "valid identity config with subject path", + identityCfg: &IdentityFromTokenConfig{SubjectPath: "username"}, + wantErr: false, + }, + { + name: "missing subject path", + identityCfg: &IdentityFromTokenConfig{NamePath: "name"}, + wantErr: true, + errContains: "identity_from_token.subject_path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ClientID: "test", RedirectURI: "http://localhost/callback"}, + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + IdentityFromToken: tt.identityCfg, + } + err := cfg.Validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestTokenResponseRewriter_IdentityCfgNil verifies that when identityCfg is nil, +// extractedIdentity remains nil after RoundTrip even when a mapping is configured. +func TestTokenResponseRewriter_IdentityCfgNil(t *testing.T) { + t.Parallel() + + body := `{"access_token":"tok","token_type":"Bearer","username":"u1"}` + server := httptest.NewServer(tokenEndpointHandler(body)) + t.Cleanup(server.Close) + + mapping := &TokenResponseMapping{AccessTokenPath: "access_token"} + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, mapping, nil, server.URL) + + transport, ok := client.Transport.(*tokenResponseRewriter) + require.True(t, ok, "expected *tokenResponseRewriter transport") + + req, err := http.NewRequest("POST", server.URL, strings.NewReader("grant_type=authorization_code")) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Nil(t, transport.extractedIdentity) +} + +// TestTokenResponseRewriter_IdentityCfgSet verifies that when identityCfg is set +// and the body contains the subject path, extractedIdentity is populated. +func TestTokenResponseRewriter_IdentityCfgSet(t *testing.T) { + t.Parallel() + + body := `{"access_token":"a","token_type":"Bearer","username":"u1"}` + server := httptest.NewServer(tokenEndpointHandler(body)) + t.Cleanup(server.Close) + + identityCfg := &IdentityFromTokenConfig{SubjectPath: "username"} + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, nil, identityCfg, server.URL) + + transport, ok := client.Transport.(*tokenResponseRewriter) + require.True(t, ok, "expected *tokenResponseRewriter transport") + + req, err := http.NewRequest("POST", server.URL, strings.NewReader("grant_type=authorization_code")) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + require.NotNil(t, transport.extractedIdentity) + assert.Equal(t, "u1", transport.extractedIdentity.Subject) +} + +// TestTokenResponseRewriter_IdentityFromRawBody verifies the ordering invariant: +// identity extraction runs against the pre-rewrite body, not the post-rewrite body. +// +// The fixture uses token_type as the identity field. rewriteTokenResponse +// unconditionally sets token_type to "Bearer", so: +// - extraction pre-rewrite reads "raw-marker" (the original value) +// - extraction post-rewrite would read "Bearer" (the rewritten value) +// +// Asserting Subject == "raw-marker" can only pass if extraction ran before the rewrite. +func TestTokenResponseRewriter_IdentityFromRawBody(t *testing.T) { + t.Parallel() + + rawBody := `{"access_token":"x","token_type":"raw-marker"}` + server := httptest.NewServer(tokenEndpointHandler(rawBody)) + t.Cleanup(server.Close) + + mapping := &TokenResponseMapping{AccessTokenPath: "access_token"} + identityCfg := &IdentityFromTokenConfig{SubjectPath: "token_type"} + client, _ := wrapHTTPClientForTokenExchange(http.DefaultClient, mapping, identityCfg, server.URL) + + transport, ok := client.Transport.(*tokenResponseRewriter) + require.True(t, ok, "expected *tokenResponseRewriter transport") + + req, err := http.NewRequest("POST", server.URL, strings.NewReader("grant_type=authorization_code")) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + // Subject must be "raw-marker" — this can only be true if extraction ran + // before rewriteTokenResponse replaced token_type with "Bearer". + require.NotNil(t, transport.extractedIdentity) + assert.Equal(t, "raw-marker", transport.extractedIdentity.Subject) + + // Confirm the rewrite did run (token_type in the response is now "Bearer"). + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(respBody, &parsed)) + assert.Equal(t, "x", parsed["access_token"]) + assert.Equal(t, "Bearer", parsed["token_type"]) +} + +// TestWrapHTTPClientForTokenExchange_OnlyIdentityCfg verifies that wrapping occurs +// when only identityCfg is set (mapping is nil). +func TestWrapHTTPClientForTokenExchange_OnlyIdentityCfg(t *testing.T) { + t.Parallel() + + original := &http.Client{} + identityCfg := &IdentityFromTokenConfig{SubjectPath: "username"} + result, rewriter := wrapHTTPClientForTokenExchange(original, nil, identityCfg, "https://example.com/token") + + assert.NotSame(t, original, result) + assert.NotNil(t, rewriter) + _, ok := result.Transport.(*tokenResponseRewriter) + assert.True(t, ok, "expected *tokenResponseRewriter transport when only identityCfg is set") +} + +// TestWrapHTTPClientForTokenExchange_OnlyMapping verifies that wrapping occurs +// when only mapping is set (identityCfg is nil), preserving existing behavior. +func TestWrapHTTPClientForTokenExchange_OnlyMapping(t *testing.T) { + t.Parallel() + + original := &http.Client{} + mapping := &TokenResponseMapping{AccessTokenPath: "access_token"} + result, rewriter := wrapHTTPClientForTokenExchange(original, mapping, nil, "https://example.com/token") + + assert.NotSame(t, original, result) + assert.NotNil(t, rewriter) + _, ok := result.Transport.(*tokenResponseRewriter) + assert.True(t, ok, "expected *tokenResponseRewriter transport when only mapping is set") +} + +// TestWrapHTTPClientForTokenExchange_BothSet verifies that wrapping occurs +// when both mapping and identityCfg are set. +func TestWrapHTTPClientForTokenExchange_BothSet(t *testing.T) { + t.Parallel() + + original := &http.Client{} + mapping := &TokenResponseMapping{AccessTokenPath: "access_token"} + identityCfg := &IdentityFromTokenConfig{SubjectPath: "username"} + result, rewriter := wrapHTTPClientForTokenExchange(original, mapping, identityCfg, "https://example.com/token") + + assert.NotSame(t, original, result) + assert.NotNil(t, rewriter) + _, ok := result.Transport.(*tokenResponseRewriter) + assert.True(t, ok, "expected *tokenResponseRewriter transport when both are set") +} + +// TestWrapHTTPClientForTokenExchange_BothNil verifies that the original client +// is returned unchanged when both mapping and identityCfg are nil. +func TestWrapHTTPClientForTokenExchange_BothNil(t *testing.T) { + t.Parallel() + + original := &http.Client{} + result, rewriter := wrapHTTPClientForTokenExchange(original, nil, nil, "https://example.com/token") + assert.Same(t, original, result) + assert.Nil(t, rewriter) +} diff --git a/pkg/authserver/upstream/tokens.go b/pkg/authserver/upstream/tokens.go index 85194d6ddc..2020c60813 100644 --- a/pkg/authserver/upstream/tokens.go +++ b/pkg/authserver/upstream/tokens.go @@ -15,6 +15,7 @@ package upstream import ( + "log/slog" "time" ) @@ -35,7 +36,8 @@ type Tokens struct { // IDToken is the ID token from the upstream IDP (for OIDC). IDToken string - // ExpiresAt is when the access token expires. + // ExpiresAt is when the access token expires. Zero value means the provider + // did not assert an expiry; callers must treat it as non-expiring. ExpiresAt time.Time } @@ -52,7 +54,26 @@ func (t *Tokens) IsExpiredAt(now time.Time) bool { if t == nil { return true } + if t.ExpiresAt.IsZero() { + return false + } // Token is expired if it expires at or before (now + buffer) // Using !After to include the equality case (expires exactly at boundary) return !t.ExpiresAt.After(now.Add(tokenExpirationBuffer)) } + +// expiresAtLogValue is a slog.LogValuer wrapper for an ExpiresAt time that +// renders zero time as "none" rather than the misleading year-0001 timestamp +// slog would otherwise produce. As a LogValuer, formatting is deferred until +// the log record is actually emitted, so DEBUG logs do no work when the +// handler level filters them out. +type expiresAtLogValue time.Time + +// LogValue implements slog.LogValuer. +func (e expiresAtLogValue) LogValue() slog.Value { + t := time.Time(e) + if t.IsZero() { + return slog.StringValue("none") + } + return slog.StringValue(t.Format(time.RFC3339)) +} diff --git a/pkg/authserver/upstream/tokens_test.go b/pkg/authserver/upstream/tokens_test.go index 2544df831c..d88637d077 100644 --- a/pkg/authserver/upstream/tokens_test.go +++ b/pkg/authserver/upstream/tokens_test.go @@ -63,6 +63,11 @@ func TestTokens_IsExpired(t *testing.T) { expiresAt: now.Add(1 * time.Hour), want: false, }, + { + name: "zero ExpiresAt treated as non-expiring", + expiresAt: time.Time{}, + want: false, + }, } for _, tt := range tests { diff --git a/pkg/authserver/upstream/types.go b/pkg/authserver/upstream/types.go index d9c44d08d7..c7ca8b0553 100644 --- a/pkg/authserver/upstream/types.go +++ b/pkg/authserver/upstream/types.go @@ -28,7 +28,17 @@ type Identity struct { // Tokens contains the tokens obtained from the upstream IDP. Tokens *Tokens - // Subject is the canonical user identifier from the upstream IDP (the "sub" claim). + // Subject is the canonical user identifier carried into session storage + // and issued JWTs. Source by provider type: + // - OIDC: "sub" from the validated ID token. + // - OAuth2 with userInfo: "sub" (or field-mapped) from userinfo. + // - OAuth2 without userInfo: synthesized "tk-…" value derived from the + // access token (Synthetic=true; see synthesizeSubjectFromAccessToken). + // + // Stability: stable across refresh-token rotation that preserves the + // access token; in synthesis mode it rotates per fresh authorization + // code flow, so callers must treat synthesized Subjects as ephemeral + // session keys, not stable per-user identifiers. Subject string // Name is the user's display name from the upstream IDP (optional). @@ -36,6 +46,13 @@ type Identity struct { // Email is the user's email address from the upstream IDP (optional). Email string + + // Synthetic is true when Subject was generated locally because the + // upstream has no userinfo or ID-token-derived identity. Synthetic + // subjects rotate per re-auth; callers MUST NOT persist them as stable + // per-user keys (doing so grows `users` without bound). Use the + // synthesized Subject as an ephemeral session key only. + Synthetic bool } // ErrIdentityResolutionFailed indicates identity could not be determined. diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index c81a254d78..384ec92cf9 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -201,9 +201,50 @@ type ConfigOptions struct { // RoleClaimName is the JWT claim key that contains role membership for the // principal. When set, the claim is extracted separately from GroupClaimName - // and both are mapped to Cedar THVGroup entities. + // and both are mapped to the configured group entity type (default "THVGroup"). // When empty, no role extraction is performed (backward compatible). RoleClaimName string `json:"role_claim_name,omitempty" yaml:"role_claim_name,omitempty"` + + // GroupEntityType is the Cedar entity type name used for Client parent UIDs + // synthesised from JWT group/role claims. Defaults to "THVGroup" when empty, + // preserving the original behaviour. Must be a valid Cedar identifier — namespaced + // names (e.g. "Platform::Group") are not yet supported and are rejected at + // construction. See issue #5072. + GroupEntityType string `json:"group_entity_type,omitempty" yaml:"group_entity_type,omitempty"` +} + +// validateGroupEntityType validates a GroupEntityType value. Empty string is +// valid — it means "use the default" and is resolved by NewEntityFactory. +// Non-empty values must: +// - not contain Cedar's "::" namespace separator (out of scope per #5072), and +// - parse cleanly as a Cedar identifier when used as an entity type in a +// synthetic policy. We delegate the identifier-grammar check to cedar-go's +// policy parser so that future grammar refinements in upstream cedar-go are +// picked up automatically — this is the source of truth for Cedar identifier +// validity. Hand-rolling the grammar (reserved words, ANYIDENT regex, +// __cedar prefix) duplicates rules cedar-go already enforces. +func validateGroupEntityType(s string) error { + if s == "" { + return nil + } + + // Check for namespace separator first: namespaced types are out of scope. + // This must run before the cedar-go round-trip because the Cedar parser + // accepts "Foo::Bar" as a valid namespaced type, but we reject it for + // project-specific reasons. + if strings.Contains(s, "::") { + return fmt.Errorf("group_entity_type %q contains \"::\": namespaced entity types are not yet supported", s) + } + + // Round-trip through cedar-go's policy parser. If the synthesized policy + // text fails to parse, the type name violates Cedar's identifier grammar + // (reserved word, invalid character, leading digit, etc.). + synth := fmt.Sprintf(`permit(principal in %s::"x", action, resource);`, s) + var p cedar.Policy + if err := p.UnmarshalCedar([]byte(synth)); err != nil { + return fmt.Errorf("group_entity_type %q is not a valid Cedar identifier: %w", s, err) + } + return nil } // NewCedarAuthorizer creates a new Cedar authorizer. @@ -212,10 +253,14 @@ type ConfigOptions struct { // If a second runtime-injected value is needed, bundle both into a // RuntimeContext struct to keep the factory interface stable. func NewCedarAuthorizer(options ConfigOptions, serverName string) (authorizers.Authorizer, error) { + if err := validateGroupEntityType(options.GroupEntityType); err != nil { + return nil, err + } + authorizer := &Authorizer{ policySet: cedar.NewPolicySet(), entities: cedar.EntityMap{}, - entityFactory: NewEntityFactory(), + entityFactory: NewEntityFactory(cedar.EntityType(options.GroupEntityType)), primaryUpstreamProvider: options.PrimaryUpstreamProvider, groupClaimName: options.GroupClaimName, roleClaimName: options.RoleClaimName, @@ -243,6 +288,23 @@ func NewCedarAuthorizer(options ConfigOptions, serverName string) (authorizers.A if err := json.Unmarshal([]byte(options.EntitiesJSON), &authorizer.entities); err != nil { return nil, fmt.Errorf("failed to parse entities JSON: %w", err) } + + // Warn once if entities_json contains stale THVGroup entities while + // GroupEntityType is configured to a different type. Cedar's `in` operator + // compares entity UIDs by type name, so the pre-loaded THVGroup entities will + // never match the synthesised parents and any policy referencing them will + // silently deny every request. + if options.GroupEntityType != "" && options.GroupEntityType != string(EntityTypeTHVGroup) { + for uid := range authorizer.entities { + if uid.Type == EntityTypeTHVGroup { + slog.Warn("Cedar entities_json contains THVGroup entities but GroupEntityType is set to a different value; "+ + "synthesised group parents will not match these pre-loaded entities and policies that reference them will silently deny", + "configured_group_entity_type", options.GroupEntityType, + "stale_entity_uid", uid.String()) + break + } + } + } } return authorizer, nil @@ -325,9 +387,10 @@ func (a *Authorizer) GetEntityFactory() *EntityFactory { // - resource: The object being accessed (e.g., "Tool::weather") // - context: Additional information about the request // -// Note: group-based Cedar policies (e.g. "principal in THVGroup::\"eng\"") require -// that THVGroup parent entities are included in the entity map. See #4768 for the -// group parent wiring that will set these up via CreatePrincipalEntity. +// Note: group-based Cedar policies (e.g. "principal in THVGroup::\"eng\"" with the +// default group entity type — see ConfigOptions.GroupEntityType) require that +// group parent entities are included in the entity map. See #4768 for the group +// parent wiring that will set these up via CreatePrincipalEntity. // - entities: Optional Cedar entity map with attributes func (a *Authorizer) IsAuthorized( principal, action, resource string, @@ -430,6 +493,30 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro } parsedClaims, err := parseUpstreamJWTClaims(upstreamToken) if err != nil { + // Distinguish "not JWT-shaped" (opaque OAuth 2.0 access token — + // Google's ya29.*, GitHub's gho_*, etc.) from "JWT-shaped but + // malformed/tampered" (a JWT with three segments that fails to + // parse). Only fall back for the former; preserve the deny for + // the latter so a tampered upstream JWT cannot bypass policy. + // + // The embedded auth server already mirrors the upstream OIDC + // sub/email/name claims into its issued AS token (see + // pkg/authserver/server/session/session.go). For opaque-token + // providers, falling back to identity.Claims preserves identity + // for policies referencing standard OIDC claims; policies that + // reference upstream-only claims (groups, hd, custom namespaced + // claims) will see those attributes as absent and must be + // authored defensively (`has(claim_groups) && ...`). + if !looksLikeJWT(upstreamToken) { + // The Warn shares claimKeyLog with logClaimKeys below so a busy + // Google/GitHub deployment does not emit one line per tool call. + a.claimKeyLog.Do(func() { + slog.Warn("upstream token is not a JWT; falling back to request-token claims for Cedar evaluation", + "provider", a.primaryUpstreamProvider) + }) + a.logClaimKeys("token-fallback", jwt.MapClaims(identity.Claims)) + return jwt.MapClaims(identity.Claims), nil + } return nil, fmt.Errorf("failed to parse upstream token for provider %q: %w", a.primaryUpstreamProvider, err) } @@ -442,6 +529,13 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro return claims, nil } +// looksLikeJWT returns true when the token has the three-segment shape of a +// JOSE-compact-serialized JWT (`header.payload.signature`). It does not +// validate the contents; the parser handles that. +func looksLikeJWT(tokenStr string) bool { + return strings.Count(tokenStr, ".") == 2 +} + // logClaimKeys emits a rate-limited DEBUG log listing the JWT claim keys // available for Cedar policy evaluation. func (a *Authorizer) logClaimKeys(source string, claims jwt.MapClaims) { @@ -764,8 +858,9 @@ func (a *Authorizer) AuthorizeWithJWTClaims( } // Extract groups from the group claim (or well-known defaults) and the - // role claim, merge, and dedup. Both claim sources map to Cedar THVGroup - // entities. Extraction runs BEFORE preprocessClaims so the raw claim + // role claim, merge, and dedup. Both claim sources map to the configured + // group entity type (default "THVGroup"). Extraction runs BEFORE + // preprocessClaims so the raw claim // values are available. // The identity pointer is not mutated here because Identity MUST NOT be // modified after it is placed in the request context (concurrent reads). diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 65ef464273..5312c2a2cd 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -4,8 +4,10 @@ package cedar import ( + "bytes" "context" "encoding/json" + "log/slog" "testing" cedar "github.com/cedar-policy/cedar-go" @@ -1310,7 +1312,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { errContains: string(upstreamtoken.StatusRefreshFailed), }, { - name: "upstream_token_opaque_not_parseable", + name: "upstream_token_opaque_falls_back_to_request_claims_denied", identity: &auth.Identity{ PrincipalInfo: auth.PrincipalInfo{ Subject: "thv-user", @@ -1320,6 +1322,45 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { providerName: "opaque-token-cannot-be-parsed", }, }, + // Opaque upstream tokens (Google's ya29.*, GitHub's gho_*, etc.) + // trigger the fallback to identity.Claims. Here the request-token + // sub does not match the policy, so authorization is correctly + // denied based on policy evaluation rather than a parse-time error. + wantAuthorize: false, + }, + { + name: "upstream_token_opaque_falls_back_to_request_claims_permitted", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "upstream-user", + Claims: map[string]any{"sub": "upstream-user"}, + }, + UpstreamTokens: map[string]string{ + providerName: "opaque-token-cannot-be-parsed", + }, + }, + // When the upstream token is not a JWT, Cedar evaluates against + // the request-token claims. The embedded auth server already + // mirrors the upstream OIDC sub/email/name into its issued token, + // so a policy referencing claim_sub still matches the user. + wantAuthorize: true, + }, + { + name: "upstream_token_jwt_shaped_but_malformed_still_errors", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "thv-user", + Claims: map[string]any{"sub": "thv-user"}, + }, + UpstreamTokens: map[string]string{ + // Three-segment shape (looks like a JWT) but the segments are + // not valid base64-encoded JSON — i.e. a tampered or + // corrupted JWT. The fallback path MUST NOT trigger here: + // silently degrading a tampered upstream JWT to fallback + // claims would be a security regression. + providerName: "not-base64.not-base64.not-base64", + }, + }, wantErr: true, errContains: "failed to parse upstream token", }, @@ -2850,3 +2891,298 @@ func TestConfigOptionsRoleClaimNameJSON(t *testing.T) { }) } } + +// TestValidateGroupEntityType exercises the private validateGroupEntityType helper +// directly. Each case names an input, states whether it should succeed, and — for +// error cases — a substring that the error message must contain so operators can +// diagnose misconfiguration from a single log line. +// +// Only our package's contract is tested here: +// 1. Empty string short-circuits to nil. +// 2. Inputs containing "::" are rejected with our project-specific error. +// 3. Valid Cedar identifiers pass through (smoke test of the cedar-go delegation path). +// 4. Invalid Cedar identifiers surface the cedar-go rejection wrapped with our message. +// 5. __cedarFoo is accepted — the Cedar spec only reserves the bare "__cedar" token, +// not the entire prefix namespace. This intentional behavioral difference vs older +// hand-rolled validators would be the most surprising case for a future reader. +// +// Exhaustive grammar testing (hyphens, leading digits, whitespace, reserved words, …) +// belongs in cedar-go's own test suite, not here. +func TestValidateGroupEntityType(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input string + wantErr bool + errContains string // substring the error message must contain (ignored when wantErr=false) + }{ + { + // Empty string triggers the short-circuit: our function returns nil immediately + // without consulting cedar-go's parser. + name: "empty string accepted (short-circuit, means use default)", + input: "", + wantErr: false, + }, + { + // Smoke test: a plain valid identifier must pass the cedar-go delegation path. + name: "valid Cedar identifier accepted", + input: "OrgRole", + wantErr: false, + }, + { + // Our project rule: "::" always means a namespaced type which is never a + // valid bare entity-type name. We reject before delegating to cedar-go. + name: "namespaced type rejected with project-specific message", + input: "Foo::Bar", + wantErr: true, + errContains: "::", + }, + { + // Smoke test: an invalid Cedar identifier must produce an error containing + // our wrapper text, proving the cedar-go rejection bubbles up correctly. + // One representative case is sufficient; the grammar details are cedar-go's domain. + name: "invalid Cedar identifier rejected with wrapper message", + input: "Org-Role", + wantErr: true, + errContains: "not a valid Cedar identifier", + }, + { + // The Cedar spec reserves the literal "__cedar" token. "__cedarFoo" (with a + // suffix) is accepted because the reservation does NOT extend to the whole + // prefix namespace. This is intentionally different from older hand-rolled + // validators that rejected the entire "__cedar" prefix — keep this case so a + // future refactor cannot silently regress to the stricter behavior. + name: "__cedarFoo accepted (Cedar spec only reserves bare __cedar)", + input: "__cedarFoo", + wantErr: false, + }, + { + // Sanity check that cedar-go's reserved-word rejection surfaces through our + // wrapper. One reserved word is enough to prove the path works. + name: "reserved word 'in' rejected", + input: "in", + wantErr: true, + errContains: "not a valid Cedar identifier", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateGroupEntityType(tc.input) + + if tc.wantErr { + require.Error(t, err, "expected an error for input %q", tc.input) + assert.Contains(t, err.Error(), tc.errContains, + "error for %q should mention %q", tc.input, tc.errContains) + } else { + require.NoError(t, err, "unexpected error for input %q", tc.input) + } + }) + } +} + +// TestAuthorizeWithJWTClaims_CustomGroupEntityType proves that GroupEntityType +// actually flows through Cedar evaluation, not just through entity construction. +// Case A: GroupEntityType "OrgRole" with policy "principal in OrgRole::..." → Permit. +// Case B: same policy, default GroupEntityType "" (resolves to THVGroup) → Deny, +// because the parent UIDs are typed THVGroup::"engineering" which is not in OrgRole. +// The two cases are adjacent so the contrast is visible to reviewers. +func TestAuthorizeWithJWTClaims_CustomGroupEntityType(t *testing.T) { + t.Parallel() + + // Policy references OrgRole — only a factory configured with GroupEntityType "OrgRole" + // will synthesise parent UIDs that match this policy. + policy := ` + permit( + principal in OrgRole::"engineering", + action == Action::"call_tool", + resource == Tool::"deploy" + ); + ` + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{ + "sub": "user1", + "groups": []interface{}{"engineering"}, + }, + }, + } + + tests := []struct { + name string + groupEntityType string + wantAuthorize bool + }{ + { + // GroupEntityType "OrgRole" makes the factory emit OrgRole::"engineering" + // as the principal's parent UID. Cedar's `in` resolves to true → Permit. + name: "custom_type_OrgRole_permits", + groupEntityType: "OrgRole", + wantAuthorize: true, + }, + { + // Default GroupEntityType "" resolves to THVGroup. The factory emits + // THVGroup::"engineering" instead of OrgRole::"engineering". Cedar's `in` + // for OrgRole::"engineering" evaluates to false → Deny by default. + name: "default_type_THVGroup_denies", + groupEntityType: "", + wantAuthorize: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + GroupClaimName: "groups", + GroupEntityType: tt.groupEntityType, + }, "") + require.NoError(t, err) + + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "deploy", + nil, + ) + require.NoError(t, err) + assert.Equal(t, tt.wantAuthorize, authorized, + "GroupEntityType=%q: expected allow=%v", tt.groupEntityType, tt.wantAuthorize) + }) + } +} + +// TestNewCedarAuthorizerGroupEntityTypeValidation is a thin wiring proof +// that NewCedarAuthorizer actually invokes validateGroupEntityType. The +// exhaustive rejection coverage lives in TestValidateGroupEntityType — this +// test only confirms one valid input passes through and one invalid input +// produces the validator's error at the constructor boundary. +func TestNewCedarAuthorizerGroupEntityTypeValidation(t *testing.T) { + t.Parallel() + + validPolicy := []string{`permit(principal, action, resource);`} + + testCases := []struct { + name string + groupEntityType string + wantErr bool + errContains string + }{ + {name: "empty string succeeds", groupEntityType: "", wantErr: false}, + {name: "namespaced type fails", groupEntityType: "Foo::Bar", wantErr: true, errContains: "::"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := NewCedarAuthorizer(ConfigOptions{ + Policies: validPolicy, + GroupEntityType: tc.groupEntityType, + }, "") + + if tc.wantErr { + require.Error(t, err, "expected construction error for GroupEntityType=%q", tc.groupEntityType) + assert.Contains(t, err.Error(), tc.errContains, + "validator error must bubble up unchanged to the constructor boundary") + } else { + require.NoError(t, err, "unexpected error for GroupEntityType=%q", tc.groupEntityType) + } + }) + } +} + +// captureSlogWarn redirects slog's default logger to a bytes.Buffer for the +// duration of f, then restores the original default. Returns the captured +// output. This helper exists because slog.SetDefault is a process-global +// side effect — tests that use it must NOT run in parallel. +func captureSlogWarn(t *testing.T, f func()) string { + t.Helper() + + var buf bytes.Buffer + handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + orig := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(orig) }) + + f() + + return buf.String() +} + +// TestStaleTHVGroupWarning verifies that NewCedarAuthorizer emits a WARN log +// when entities_json contains entities of type "THVGroup" while GroupEntityType +// is configured to a different value. The mismatch causes Cedar's `in` operator +// to evaluate to false for those entities — a silent deny that is hard to debug +// without this diagnostic. +// +// Subtests use slog.SetDefault (process-global), so they must NOT run in +// parallel with other tests. The parent is still parallel-safe because it does +// not touch global state itself. +// +//nolint:paralleltest,tparallel // Subtests redirect slog.Default, which is process-global state +func TestStaleTHVGroupWarning(t *testing.T) { + t.Parallel() + + const thvGroupEntity = `[{"uid":{"type":"THVGroup","id":"engineering"},"attrs":{},"parents":[]}]` + validPolicy := []string{`permit(principal, action, resource);`} + + tests := []struct { + name string + groupEntityType string + entitiesJSON string + wantWarn bool + wantContains []string // when wantWarn=true, log must contain each of these + }{ + { + name: "warns when stale THVGroup present and GroupEntityType differs", + groupEntityType: "OrgRole", + entitiesJSON: thvGroupEntity, + wantWarn: true, + wantContains: []string{"GroupEntityType", "OrgRole", "THVGroup"}, + }, + { + // Most common path: GroupEntityType is empty (uses THVGroup default), so no + // conflict is possible. One negative is sufficient to prove the guard works. + name: "no warning when GroupEntityType is empty (uses THVGroup default)", + groupEntityType: "", + entitiesJSON: thvGroupEntity, + wantWarn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Cannot be parallel: subtests redirect slog.Default. + output := captureSlogWarn(t, func() { + _, err := NewCedarAuthorizer(ConfigOptions{ + Policies: validPolicy, + EntitiesJSON: tt.entitiesJSON, + GroupEntityType: tt.groupEntityType, + }, "") + require.NoError(t, err) + }) + + if tt.wantWarn { + require.NotEmpty(t, output, "expected a warn log") + for _, want := range tt.wantContains { + assert.Contains(t, output, want, + "warn log must mention %q", want) + } + } else { + assert.Empty(t, output, "no warning expected") + } + }) + } +} diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index 2439b5d807..2ec563f54b 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -8,17 +8,27 @@ import ( cedar "github.com/cedar-policy/cedar-go" ) -// EntityTypeTHVGroup is the Cedar entity type representing group membership. -// Principals are added as children of THVGroup entities so that Cedar's `in` -// operator can evaluate group-based policies (e.g. `principal in THVGroup::"engineering"`). +// EntityTypeTHVGroup is the default Cedar entity type representing group membership. +// It is used when ConfigOptions.GroupEntityType is empty. Principals are added as +// children of group entities so that Cedar's `in` operator can evaluate +// group-based policies (e.g. `principal in THVGroup::"engineering"`). const EntityTypeTHVGroup cedar.EntityType = "THVGroup" // EntityFactory creates Cedar entities for authorization. -type EntityFactory struct{} +type EntityFactory struct { + // groupEntityType is the Cedar entity type used for Client parent UIDs + // synthesised from JWT group/role claims. Resolved at construction — + // defaults to EntityTypeTHVGroup when the caller passes an empty string. + groupEntityType cedar.EntityType +} -// NewEntityFactory creates a new entity factory. -func NewEntityFactory() *EntityFactory { - return &EntityFactory{} +// NewEntityFactory creates a new entity factory. An empty groupEntityType +// falls back to EntityTypeTHVGroup so existing callers stay backward compatible. +func NewEntityFactory(groupEntityType cedar.EntityType) *EntityFactory { + if groupEntityType == "" { + groupEntityType = EntityTypeTHVGroup + } + return &EntityFactory{groupEntityType: groupEntityType} } // CreatePrincipalEntity creates a principal entity with the given ID, attributes, @@ -102,10 +112,11 @@ func (*EntityFactory) CreateResourceEntity( } // CreateEntitiesForRequest creates entities for a specific authorization request. -// Groups are converted to THVGroup parent UIDs on the principal entity so that -// Cedar's `in` operator works for group-based policies. Unlike the pre-refactor -// code, no separate THVGroup entities are inserted into the entity map — those -// must come from entities_json to preserve the role hierarchy. +// Groups are converted to parent UIDs (using the configured group entity type, +// default "THVGroup") on the principal entity so that Cedar's `in` operator works +// for group-based policies. Unlike the pre-refactor code, no separate group +// entities are inserted into the entity map — those must come from entities_json +// to preserve the role hierarchy. // // When serverName is non-empty, resource entities include an MCP parent UID so // that server-scoped Cedar policies (e.g. `resource in MCP::"github"`) evaluate @@ -138,14 +149,15 @@ func (f *EntityFactory) CreateEntitiesForRequest( entities := make(cedar.EntityMap) // Build parent UIDs from groups so the principal's Parents set contains - // THVGroup references (needed for Cedar's `in` operator). Unlike the - // pre-refactor code, we do NOT insert separate THVGroup entities into the - // entity map — those come from entities_json and must not be overwritten - // (see merge-order hazard described in the RFC). #4768 will restructure - // this further for full role hierarchy support. + // references using the configured group entity type (default "THVGroup"), + // needed for Cedar's `in` operator. Unlike the pre-refactor code, we do + // NOT insert separate group entities into the entity map — those come from + // entities_json and must not be overwritten (see merge-order hazard + // described in the RFC). #4768 will restructure this further for full role + // hierarchy support. parentUIDs := make([]cedar.EntityUID, 0, len(groups)) for _, g := range groups { - parentUIDs = append(parentUIDs, cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String(g))) + parentUIDs = append(parentUIDs, cedar.NewEntityUID(f.groupEntityType, cedar.String(g))) } principalUID, principalEntity := f.CreatePrincipalEntity(principalType, principalID, claimsMap, parentUIDs...) diff --git a/pkg/authz/authorizers/cedar/entity_test.go b/pkg/authz/authorizers/cedar/entity_test.go index d67927b380..857f23338a 100644 --- a/pkg/authz/authorizers/cedar/entity_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -16,7 +16,7 @@ import ( func TestCreatePrincipalEntity_Parents(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String("engineering")) roleUID := cedar.NewEntityUID("THVRole", cedar.String("admin")) @@ -83,7 +83,7 @@ func TestCreatePrincipalEntity_Parents(t *testing.T) { func TestCreatePrincipalEntity_NoGroupEntities(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") // Pass a THVGroup parent UID — the function must NOT return extra entities. groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String("engineering")) @@ -100,7 +100,7 @@ func TestCreatePrincipalEntity_NoGroupEntities(t *testing.T) { func TestCreateResourceEntity_Parents(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") mcpUID := cedar.NewEntityUID("MCP", cedar.String("server-a")) orgUID := cedar.NewEntityUID("Org", cedar.String("stacklok")) @@ -169,7 +169,7 @@ func TestCreateResourceEntity_Parents(t *testing.T) { func TestCreateResourceEntity_NamePreservation(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") tests := []struct { name string @@ -231,7 +231,7 @@ func TestCreateResourceEntity_NamePreservation(t *testing.T) { func TestCreateEntitiesForRequest_GroupsAsParents(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") tests := []struct { name string @@ -297,6 +297,45 @@ func TestCreateEntitiesForRequest_GroupsAsParents(t *testing.T) { } } +// TestCreateEntitiesForRequest_CustomGroupEntityType verifies that a factory +// built with a custom groupEntityType produces parent UIDs with that type rather +// than the default "THVGroup", and that no entity of the custom type is inserted +// into the entity map (mirroring the THVGroup invariant). +func TestCreateEntitiesForRequest_CustomGroupEntityType(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory(cedar.EntityType("OrgRole")) + + entities, err := factory.CreateEntitiesForRequest( + "Client::user1", + "Action::call_tool", + "Tool::weather", + map[string]interface{}{"sub": "user1"}, + map[string]interface{}{"name": "weather"}, + []string{"engineering"}, + "", + ) + require.NoError(t, err) + require.NotNil(t, entities) + + // Entity map must contain only principal + action + resource — no OrgRole entries. + assert.Len(t, entities, 3) + for uid := range entities { + assert.NotEqual(t, cedar.EntityType("OrgRole"), uid.Type, + "OrgRole entity should not be inserted into the entity map") + } + + // Principal's Parents set must contain OrgRole::"engineering". + principalUID := cedar.NewEntityUID("Client", cedar.String("user1")) + principalEntity, found := entities[principalUID] + require.True(t, found, "principal entity not found in map") + require.Equal(t, 1, principalEntity.Parents.Len()) + + wantParent := cedar.NewEntityUID(cedar.EntityType("OrgRole"), cedar.String("engineering")) + assert.True(t, principalEntity.Parents.Contains(wantParent), + "expected OrgRole::\"engineering\" in principal.Parents") +} + // TestCreateCedarEntities tests the createCedarEntities function. func TestCreateCedarEntities(t *testing.T) { t.Parallel() @@ -385,7 +424,7 @@ func TestCreateCedarEntities(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Create entity factory - factory := NewEntityFactory() + factory := NewEntityFactory("") // Create Cedar entities (no groups for these test cases) entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "") @@ -471,7 +510,7 @@ func TestCreateCedarEntities(t *testing.T) { func TestCreateEntitiesForRequest_MCPParent(t *testing.T) { t.Parallel() - factory := NewEntityFactory() + factory := NewEntityFactory("") tests := []struct { name string diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index 96ea1438ea..79ca874820 100644 --- a/pkg/authz/integration_test.go +++ b/pkg/authz/integration_test.go @@ -1070,3 +1070,120 @@ func TestIntegrationUpstreamProviderGroupAuth(t *testing.T) { }) } } + +// TestIntegrationPrimaryUpstreamProviderClaimAttributeAccess verifies the +// behavioral effect of the VirtualMCPServer operator converter fix in +// stacklok/toolhive#4997: when PrimaryUpstreamProvider is populated, Cedar +// reads scalar JWT claim attributes (e.g. `email`) from the upstream IDP token +// rather than the ToolHive-issued AS token. +// +// Prior to the fix the operator left PrimaryUpstreamProvider empty, so Cedar +// evaluated policies against the AS-issued token's claims — which do not carry +// upstream-provider attributes such as `email`. Policies referencing those +// attributes (via `has` or `==`) failed with Cedar's +// "does not have the attribute" error. This test pins the two branches of +// cedar.Authorizer.resolveClaims (see core.go:421) against the same identity +// and policy set, demonstrating that only the upstream-provider branch admits +// the reproducer policy from #4997. +func TestIntegrationPrimaryUpstreamProviderClaimAttributeAccess(t *testing.T) { + t.Parallel() + + const providerName = "okta" + + // AS-issued token claims: realistic ToolHive-AS fields, deliberately + // without `email`. This is what Cedar sees when PrimaryUpstreamProvider + // is empty (the pre-fix behavior). + directClaims := jwt.MapClaims{ + "sub": "thv-as|alice", + "aud": "toolhive-vmcp", + "iss": "https://thv-as.example.com/", + "tsid": "sess-abc123", + } + + // Upstream IDP token: carries the `email` claim that the policy inspects. + upstreamToken := makeUnsignedJWT(t, jwt.MapClaims{ + "sub": "okta|alice", + "email": "alice@example.com", + }) + + // Using has-attribute rather than equality so failure uniquely signals + // "the claim source Cedar read from lacked this attribute", which is + // exactly the #4997 regression surface. + policies := []string{ + `permit(principal, action == Action::"call_tool", resource) when { principal has claim_email };`, + } + + tests := []struct { + name string + primaryUpstreamProvider string + expectAllowed bool + }{ + { + // With the #4997 fix: converter populates PrimaryUpstreamProvider, + // Cedar reads the upstream token which has `email`, policy permits. + name: "upstream_provider_set_reads_upstream_claim_and_permits", + primaryUpstreamProvider: providerName, + expectAllowed: true, + }, + { + // Pre-fix behavior: PrimaryUpstreamProvider empty, Cedar falls + // back to direct claims which lack `email`, policy denies with + // Cedar's "does not have the attribute claim_email" error. + name: "upstream_provider_empty_falls_back_to_direct_claims_and_denies", + primaryUpstreamProvider: "", + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: policies, + EntitiesJSON: "[]", + PrimaryUpstreamProvider: tt.primaryUpstreamProvider, + }, "") + require.NoError(t, err) + + params, err := json.Marshal(map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}) + require.NoError(t, err) + callReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), string(mcp.MethodToolsCall), json.RawMessage(params)) + require.NoError(t, err) + reqJSON, err := jsonrpc2.EncodeMessage(callReq) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/messages", bytes.NewBuffer(reqJSON)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: directClaims["sub"].(string), + Claims: directClaims, + }, + UpstreamTokens: map[string]string{providerName: upstreamToken}, + } + httpReq = httpReq.WithContext(auth.WithIdentity(httpReq.Context(), identity)) + + rr := httptest.NewRecorder() + var handlerCalled bool + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result": "ok"}`)) + }) + + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler, nil)) + middleware.ServeHTTP(rr, httpReq) + + if tt.expectAllowed { + assert.Equal(t, http.StatusOK, rr.Code) + assert.True(t, handlerCalled) + } else { + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.False(t, handlerCalled) + } + }) + } +} diff --git a/pkg/cache/validating_cache.go b/pkg/cache/validating_cache.go index c3f6342f79..1f85460f81 100644 --- a/pkg/cache/validating_cache.go +++ b/pkg/cache/validating_cache.go @@ -192,7 +192,7 @@ func sameEntry[V any](a, b V) bool { ra := reflect.ValueOf(any(a)) if ra.IsValid() { switch ra.Kind() { //nolint:exhaustive - case reflect.Ptr, reflect.UnsafePointer: + case reflect.Pointer, reflect.UnsafePointer: rb := reflect.ValueOf(any(b)) return rb.IsValid() && ra.Pointer() == rb.Pointer() } diff --git a/pkg/client/config.go b/pkg/client/config.go index d9781913c1..21f00804be 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -26,11 +26,23 @@ import ( // defaultURLFieldName is the default URL field name used when no specific mapping exists const defaultURLFieldName = "url" -// ClientApp is an enum of supported AI clients (IDEs, editors, and coding tools). +const httpTransportLabel = "http" +const skillsDirName = "skills" + +// ClientApp is an enum of MCP-capable AI clients (IDEs, editors, and coding tools). +// Only clients that support MCP registration appear here; LLM-gateway-only +// tools (e.g. Xcode) are represented by the separate LLMClientApp type so +// that code generators (swag) do not include them in the MCP API enum. // //nolint:revive // ClientApp is intentionally named for clarity across packages type ClientApp string +// LLMClientApp identifies tools that support the LLM gateway but do not +// support MCP client registration (e.g. GitHub Copilot for Xcode). +// Keeping this type separate from ClientApp prevents swag from including +// these tools in the MCP API ClientApp enum. +type LLMClientApp string + const ( // RooCode represents the Roo Code extension for VS Code. RooCode ClientApp = "roo-code" @@ -86,6 +98,16 @@ const ( KimiCli ClientApp = "kimi-cli" // Factory represents the Factory.ai Droid CLI. Factory ClientApp = "factory" + // CopilotCli represents the GitHub Copilot CLI. + CopilotCli ClientApp = "copilot-cli" +) + +const ( + // Xcode represents GitHub Copilot for Xcode. + // Xcode does not support MCP; it is an LLM-gateway-only tool. + // It is declared as LLMClientApp (not ClientApp) so that code generators + // such as swag do not include "xcode" in the MCP API ClientApp enum. + Xcode LLMClientApp = "xcode" ) // Extension is extension of the client config file. @@ -134,10 +156,62 @@ const ( PlatformWindows Platform = "windows" ) +// LLMGatewayKeySpec describes a single JSON key to patch when configuring +// (or reverting) LLM gateway access for a tool. JSONPointer is an RFC 6901 +// path (e.g. "/apiKeyHelper" or "/env/ANTHROPIC_BASE_URL"). Dots in flat +// top-level key names (e.g. "/cursor.general.openAIBaseURL") are treated as +// literals by hujson.Patch. +// +// Exactly one of ValueField or Literal must be set: +// - ValueField names which ApplyConfig field to write. Valid values: +// "GatewayURL", "AnthropicBaseURL", "ProxyBaseURL", "ProxyOrigin", +// "TokenHelperCommand", "PlaceholderAPIKey", "NodeTLSRejectUnauthorized". +// An unrecognised ValueField is a programming error and causes +// ConfigureLLMGateway to return an error. +// - Literal is written verbatim into the settings key (e.g. a fixed auth +// type string). Use Literal instead of ValueField for constant values so +// that typos in ValueField are caught as errors rather than silently +// written as unexpected strings. +// +// ClearWhenEmpty: when true and the resolved value is empty, the key is +// removed from the settings file rather than skipped. Use for conditional +// keys like NODE_TLS_REJECT_UNAUTHORIZED that must be cleaned up when the +// flag is cleared. Ignored when Literal is set (literals are never empty). +type LLMGatewayKeySpec struct { + JSONPointer string // RFC 6901 path + // ValueField: "GatewayURL" | "AnthropicBaseURL" | "ProxyBaseURL" | "ProxyOrigin" | + // "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized" + ValueField string + Literal string // constant value written verbatim; mutually exclusive with ValueField + ClearWhenEmpty bool // remove the key when the resolved value is empty (ignored for Literal) +} + +// LLMEnvFileKeySpec describes a single KEY=value entry to write to a dotenv +// file when configuring (or reverting) LLM gateway access for a tool. +// +// Exactly one of ValueField or Literal must be set. ValueField semantics are +// identical to LLMGatewayKeySpec.ValueField. +type LLMEnvFileKeySpec struct { + // Name is the environment variable name (e.g. "GEMINI_API_KEY"). + Name string + // ValueField names which ApplyConfig field to resolve. Valid values are the + // same as LLMGatewayKeySpec.ValueField. Mutually exclusive with Literal. + ValueField string + // Literal is written verbatim as the variable value. Mutually exclusive with ValueField. + Literal string +} + // clientAppConfig represents a configuration path for a supported MCP client. type clientAppConfig struct { - ClientType ClientApp - Description string + ClientType ClientApp + Description string + // Deprecated marks a client integration whose upstream tool is no longer + // maintained. Deprecated clients still function but are flagged in the CLI + // client list and trigger a warning when registered or removed. + Deprecated bool + // DeprecationMessage is the full warning text shown to users (on stderr) + // when they touch a deprecated client. Only set when Deprecated is true. + DeprecationMessage string RelPath []string SettingsFile string PlatformPrefix map[Platform][]string @@ -162,6 +236,39 @@ type clientAppConfig struct { // (e.g., XDG ~/.config/ on Linux/macOS). // If nil or missing an entry for the current OS, no prefix is added. SkillsPlatformPrefix map[Platform][]string + // LLM gateway configuration ───────────────────────────────────────────── + // LLMGatewayMode is "direct" (token-helper) or "proxy" (static key via + // localhost reverse proxy), or "" when the tool has no LLM gateway support. + LLMGatewayMode string + // LLMBinaryName is the executable name looked up via exec.LookPath to + // confirm the tool is actually installed (not just a leftover config + // directory). Leave empty for tools that are not on $PATH (e.g. macOS + // GUI apps). + LLMBinaryName string + // LLMGatewayOnly marks tools that support LLM gateway but not MCP (e.g. Xcode). + // Entries with this flag are excluded from the MCP client list. + LLMGatewayOnly bool + // LLMSettingsFile is the filename of the settings file to patch for LLM + // gateway (may differ from SettingsFile used for MCP). + LLMSettingsFile string + // LLMSettingsRelPath is the path segments from home dir + platform prefix + // to the directory containing LLMSettingsFile. + LLMSettingsRelPath []string + // LLMSettingsPlatformPrefix maps Platform to path segments prepended before + // LLMSettingsRelPath (same semantics as PlatformPrefix). + LLMSettingsPlatformPrefix map[Platform][]string + // LLMGatewayKeys lists the JSON Pointer paths and value-field mappings to + // apply when setting up (or reverting) LLM gateway access. + LLMGatewayKeys []LLMGatewayKeySpec + // LLMEnvFileRelPath is the path segments from home dir to the directory + // containing the .env file to manage for LLM gateway (e.g. []string{".gemini"}). + // Empty means this client has no .env file to manage. + LLMEnvFileRelPath []string + // LLMEnvFileName is the filename of the .env file (e.g. ".env"). + LLMEnvFileName string + // LLMEnvFileKeys lists the key=value entries to write to the .env file when + // setting up (or reverting) LLM gateway access. + LLMEnvFileKeys []LLMEnvFileKeySpec } // extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix @@ -194,8 +301,13 @@ var ( var supportedClientIntegrations = []clientAppConfig{ { - ClientType: RooCode, - Description: "VS Code Roo Code extension", + ClientType: RooCode, + Description: "VS Code Roo Code extension", + Deprecated: true, + DeprecationMessage: "The Roo Code VS Code extension has been discontinued (last release May 15, 2026)\n" + + "and its repository has been archived. Support for this client will be removed in a\n" + + "future ToolHive release. The Roo Code team recommends migrating to Cline:\n" + + " thv client register cline", SettingsFile: "mcp_settings.json", RelPath: []string{ "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", @@ -214,13 +326,13 @@ var supportedClientIntegrations = []clientAppConfig{ }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".roo", "skills"}, - SkillsProjectPath: []string{".roo", "skills"}, + SkillsGlobalPath: []string{".roo", skillsDirName}, + SkillsProjectPath: []string{".roo", skillsDirName}, }, { ClientType: Cline, @@ -243,13 +355,13 @@ var supportedClientIntegrations = []clientAppConfig{ }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".cline", "skills"}, - SkillsProjectPath: []string{".cline", "skills"}, + SkillsGlobalPath: []string{".cline", skillsDirName}, + SkillsProjectPath: []string{".cline", skillsDirName}, }, { ClientType: VSCodeInsider, @@ -266,19 +378,33 @@ var supportedClientIntegrations = []clientAppConfig{ MCPServersPathPrefix: "/servers", Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".copilot", "skills"}, - SkillsProjectPath: []string{".github", "skills"}, + SkillsGlobalPath: []string{".copilot", skillsDirName}, + SkillsProjectPath: []string{".github", skillsDirName}, + // LLM gateway: patches settings.json (same dir as mcp.json, different file) + LLMGatewayMode: "proxy", + LLMBinaryName: "code-insiders", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{"Code - Insiders", "User"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformLinux: {".config"}, + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/github.copilot.advanced.serverUrl", ValueField: "ProxyBaseURL"}, + {JSONPointer: "/github.copilot.advanced.apiKey", ValueField: "PlaceholderAPIKey"}, + }, }, { ClientType: VSCode, @@ -295,19 +421,33 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".copilot", "skills"}, - SkillsProjectPath: []string{".github", "skills"}, + SkillsGlobalPath: []string{".copilot", skillsDirName}, + SkillsProjectPath: []string{".github", skillsDirName}, + // LLM gateway: patches settings.json (same dir as mcp.json, different file) + LLMGatewayMode: "proxy", + LLMBinaryName: "code", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{"Code", "User"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformLinux: {".config"}, + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/github.copilot.advanced.serverUrl", ValueField: "ProxyBaseURL"}, + {JSONPointer: "/github.copilot.advanced.apiKey", ValueField: "PlaceholderAPIKey"}, + }, }, { ClientType: Cursor, @@ -317,21 +457,35 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".cursor"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, // Adding type field is not explicitly required though, Cursor auto-detects and is able to // connect to both sse and streamable-http types IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".cursor", "skills"}, - SkillsProjectPath: []string{".cursor", "skills"}, + SkillsGlobalPath: []string{".cursor", skillsDirName}, + SkillsProjectPath: []string{".cursor", skillsDirName}, + // LLM gateway: patches the editor settings.json (different from the MCP mcp.json) + LLMGatewayMode: "proxy", + LLMBinaryName: "cursor", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{"Cursor", "User"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformLinux: {".config"}, + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/cursor.general.openAIBaseURL", ValueField: "ProxyBaseURL"}, + {JSONPointer: "/cursor.general.openAIAPIKey", ValueField: "PlaceholderAPIKey"}, + }, }, { ClientType: ClaudeCode, @@ -341,19 +495,31 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".claude", "skills"}, - SkillsProjectPath: []string{".claude", "skills"}, + SkillsGlobalPath: []string{".claude", skillsDirName}, + SkillsProjectPath: []string{".claude", skillsDirName}, + // LLM gateway: patches ~/.claude/settings.json (different from the MCP .claude.json) + LLMGatewayMode: "direct", + LLMBinaryName: "claude", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{".claude"}, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/apiKeyHelper", ValueField: "TokenHelperCommand"}, + {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "AnthropicBaseURL"}, + // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. + // ClearWhenEmpty ensures it is removed when the flag is later cleared. + {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, + }, }, { ClientType: Windsurf, @@ -363,9 +529,9 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".codeium", "windsurf"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ @@ -374,8 +540,8 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeStreamableHTTP: "serverUrl", }, SupportsSkills: true, - SkillsGlobalPath: []string{".codeium", "windsurf", "skills"}, - SkillsProjectPath: []string{".windsurf", "skills"}, + SkillsGlobalPath: []string{".codeium", "windsurf", skillsDirName}, + SkillsProjectPath: []string{".windsurf", skillsDirName}, }, { ClientType: WindsurfJetBrains, @@ -385,9 +551,9 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".codeium"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ @@ -409,19 +575,19 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, }, { ClientType: AmpVSCode, @@ -436,15 +602,15 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -460,15 +626,15 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -484,15 +650,15 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -508,15 +674,15 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -527,15 +693,15 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".lmstudio"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -569,8 +735,8 @@ var supportedClientIntegrations = []clientAppConfig{ "description": "", }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, }, { ClientType: Trae, @@ -585,19 +751,19 @@ var supportedClientIntegrations = []clientAppConfig{ }, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, }, { ClientType: Continue, @@ -613,9 +779,9 @@ var supportedClientIntegrations = []clientAppConfig{ }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, // YAML configuration YAMLStorageType: YAMLStorageTypeArray, @@ -635,13 +801,13 @@ var supportedClientIntegrations = []clientAppConfig{ }, IsTransportTypeFieldSupported: true, // OpenCode requires "type": "remote" for URL-based servers MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{"opencode", "skills"}, - SkillsProjectPath: []string{".opencode", "skills"}, + SkillsGlobalPath: []string{"opencode", skillsDirName}, + SkillsProjectPath: []string{".opencode", skillsDirName}, SkillsPlatformPrefix: map[Platform][]string{ PlatformLinux: {".config"}, PlatformDarwin: {".config"}, @@ -655,19 +821,19 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".kiro", "settings"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".kiro", "skills"}, - SkillsProjectPath: []string{".kiro", "skills"}, + SkillsGlobalPath: []string{".kiro", skillsDirName}, + SkillsProjectPath: []string{".kiro", skillsDirName}, }, { ClientType: Antigravity, @@ -683,8 +849,8 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeStreamableHTTP: "serverUrl", }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, }, { ClientType: Zed, @@ -700,9 +866,9 @@ var supportedClientIntegrations = []clientAppConfig{ Extension: JSON, IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, }, { @@ -718,12 +884,39 @@ var supportedClientIntegrations = []clientAppConfig{ IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ types.TransportTypeStdio: "httpUrl", - types.TransportTypeSSE: "url", + types.TransportTypeSSE: defaultURLFieldName, types.TransportTypeStreamableHTTP: "httpUrl", }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, + // LLM gateway: patches the same settings.json used for MCP. + // Gemini CLI has no dynamic token-command equivalent, so it uses the + // proxy path. GOOGLE_GEMINI_BASE_URL is only honoured when + // security.auth.selectedType is "gemini-api-key" (fixed in + // gemini-cli v0.40.0, PR #25357); OAuth auth ignores the override. + // + // NODE_TLS_REJECT_UNAUTHORIZED is intentionally omitted: in proxy mode + // the tool connects to the local proxy over plain HTTP, so there is no + // TLS handshake on that leg. Setting the env var would globally disable + // TLS verification for all other HTTPS requests the Gemini CLI process + // makes, which is an unacceptable side-effect. + LLMGatewayMode: "proxy", + LLMBinaryName: "gemini", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{".gemini"}, + LLMGatewayKeys: []LLMGatewayKeySpec{ + // Force API-key auth so GOOGLE_GEMINI_BASE_URL is respected. + {JSONPointer: "/security/auth/selectedType", Literal: "gemini-api-key"}, + }, + // Gemini CLI reads GEMINI_API_KEY and GOOGLE_GEMINI_BASE_URL from + // process.env (not from settings.json), so they are injected via .env. + LLMEnvFileRelPath: []string{".gemini"}, + LLMEnvFileName: ".env", + LLMEnvFileKeys: []LLMEnvFileKeySpec{ + {Name: "GEMINI_API_KEY", Literal: llmPlaceholderAPIKey}, + {Name: "GOOGLE_GEMINI_BASE_URL", ValueField: "ProxyOrigin"}, + }, }, { ClientType: VSCodeServer, @@ -735,9 +928,9 @@ var supportedClientIntegrations = []clientAppConfig{ MCPServersPathPrefix: "/servers", Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, }, { @@ -749,20 +942,20 @@ var supportedClientIntegrations = []clientAppConfig{ Extension: TOML, SupportedTransportTypesMap: map[types.TransportType]string{ types.TransportTypeStdio: "streamable-http", - types.TransportTypeSSE: "http", + types.TransportTypeSSE: httpTransportLabel, types.TransportTypeStreamableHTTP: "streamable-http", }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, // TOML configuration: uses array-of-tables format [[mcp_servers]] TOMLStorageType: TOMLStorageTypeArray, SupportsSkills: true, - SkillsGlobalPath: []string{".vibe", "skills"}, - SkillsProjectPath: []string{".vibe", "skills"}, + SkillsGlobalPath: []string{".vibe", skillsDirName}, + SkillsProjectPath: []string{".vibe", skillsDirName}, }, { ClientType: Codex, @@ -774,15 +967,15 @@ var supportedClientIntegrations = []clientAppConfig{ // Codex doesn't support a transport type field - it auto-detects IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, // TOML configuration: uses nested tables format [mcp_servers.servername] TOMLStorageType: TOMLStorageTypeMap, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", "skills"}, - SkillsProjectPath: []string{".agents", "skills"}, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".agents", skillsDirName}, }, { ClientType: KimiCli, @@ -794,13 +987,13 @@ var supportedClientIntegrations = []clientAppConfig{ // Kimi CLI does not use a transport type field in the config file IsTransportTypeFieldSupported: false, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".kimi", "skills"}, - SkillsProjectPath: []string{".kimi", "skills"}, + SkillsGlobalPath: []string{".kimi", skillsDirName}, + SkillsProjectPath: []string{".kimi", skillsDirName}, }, { ClientType: Factory, @@ -810,19 +1003,55 @@ var supportedClientIntegrations = []clientAppConfig{ RelPath: []string{".factory"}, Extension: JSON, SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: "http", + types.TransportTypeStdio: httpTransportLabel, types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: "http", + types.TransportTypeStreamableHTTP: httpTransportLabel, }, IsTransportTypeFieldSupported: true, MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: "url", - types.TransportTypeSSE: "url", - types.TransportTypeStreamableHTTP: "url", + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".factory", "skills"}, - SkillsProjectPath: []string{".factory", "skills"}, + SkillsGlobalPath: []string{".factory", skillsDirName}, + SkillsProjectPath: []string{".factory", skillsDirName}, + }, + { + ClientType: CopilotCli, + Description: "GitHub Copilot CLI", + SettingsFile: "mcp-config.json", + MCPServersPathPrefix: "/mcpServers", + RelPath: []string{".copilot"}, + Extension: JSON, + SupportedTransportTypesMap: map[types.TransportType]string{ + types.TransportTypeStdio: httpTransportLabel, + types.TransportTypeSSE: "sse", + types.TransportTypeStreamableHTTP: httpTransportLabel, + }, + IsTransportTypeFieldSupported: true, + MCPServersUrlLabelMap: map[types.TransportType]string{ + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, + }, + }, + { + // Xcode does not support MCP; it is an LLM-gateway-only entry. + // Cast LLMClientApp → ClientApp for internal config storage; the type + // distinction matters only for swag enum generation (see LLMClientApp). + ClientType: ClientApp(Xcode), + Description: "GitHub Copilot for Xcode", + LLMGatewayOnly: true, + LLMGatewayMode: "proxy", + // Full path is macOS-specific; on Linux/Windows this directory will not + // exist, so DetectedLLMGatewayClients() naturally returns false there. + LLMSettingsFile: "editorSettings.json", + LLMSettingsRelPath: []string{"Library", "Application Support", "GitHub Copilot for Xcode"}, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/openAIBaseURL", ValueField: "ProxyBaseURL"}, + {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, + }, }, } @@ -831,7 +1060,9 @@ var supportedClientIntegrations = []clientAppConfig{ func GetAllClients() []ClientApp { clients := make([]ClientApp, 0, len(supportedClientIntegrations)) for _, config := range supportedClientIntegrations { - clients = append(clients, config.ClientType) + if !config.LLMGatewayOnly { + clients = append(clients, config.ClientType) + } } // Sort alphabetically sort.Slice(clients, func(i, j int) bool { @@ -843,7 +1074,7 @@ func GetAllClients() []ClientApp { // IsValidClient checks if the provided client type is supported. func IsValidClient(clientType string) bool { for _, config := range supportedClientIntegrations { - if string(config.ClientType) == clientType { + if string(config.ClientType) == clientType && !config.LLMGatewayOnly { return true } } @@ -861,19 +1092,39 @@ func GetClientDescription(clientType ClientApp) string { return "" } +// GetClientDeprecation returns the deprecation warning message for a client type +// and whether the client is deprecated. The message is empty when the client is +// not deprecated. +func GetClientDeprecation(clientType ClientApp) (string, bool) { + for _, config := range supportedClientIntegrations { + if config.ClientType == clientType { + return config.DeprecationMessage, config.Deprecated + } + } + return "", false +} + // GetClientListFormatted returns a formatted multi-line string listing all supported clients // with their descriptions, sorted alphabetically. This is suitable for use in CLI help text. func GetClientListFormatted() string { - // Create a sorted copy of the configurations - configs := make([]clientAppConfig, len(supportedClientIntegrations)) - copy(configs, supportedClientIntegrations) + // Create a sorted copy of MCP-capable configurations (exclude LLM-gateway-only entries). + var configs []clientAppConfig + for _, cfg := range supportedClientIntegrations { + if !cfg.LLMGatewayOnly { + configs = append(configs, cfg) + } + } sort.Slice(configs, func(i, j int) bool { return configs[i].ClientType < configs[j].ClientType }) var sb strings.Builder for _, config := range configs { - fmt.Fprintf(&sb, " - %s: %s\n", config.ClientType, config.Description) + description := config.Description + if config.Deprecated { + description += " (deprecated)" + } + fmt.Fprintf(&sb, " - %s: %s\n", config.ClientType, description) } return strings.TrimSuffix(sb.String(), "\n") } @@ -926,15 +1177,6 @@ func (cm *ClientManager) FindClientConfig(clientType ClientApp) (*ConfigFile, er return configFile, nil } -// FindRegisteredClientConfigs finds all registered client configs and creates them if they don't exist. -func FindRegisteredClientConfigs(ctx context.Context) ([]ConfigFile, error) { - manager, err := NewClientManager() - if err != nil { - return nil, err - } - return manager.FindRegisteredClientConfigs(ctx) -} - // FindRegisteredClientConfigs finds all registered client configs using this manager's dependencies func (cm *ClientManager) FindRegisteredClientConfigs(ctx context.Context) ([]ConfigFile, error) { clientStatuses, err := cm.GetClientStatus(ctx) @@ -984,6 +1226,9 @@ func (cm *ClientManager) CreateClientConfig(clientType ClientApp) (*ConfigFile, if clientCfg == nil { return nil, fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType) } + if clientCfg.LLMGatewayOnly { + return nil, fmt.Errorf("%w: %s does not support MCP configuration", ErrUnsupportedClientType, clientType) + } // Build the path to the configuration file path := buildConfigFilePath(clientCfg.SettingsFile, clientCfg.RelPath, clientCfg.PlatformPrefix, []string{cm.homeDir}) diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index aa07e71475..389b10ade9 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -504,7 +504,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpWindsurf: assert.Contains(t, string(content), `"mcpServers":`, "AmpWindsurf config should contain mcpServers key") - case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory: + case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -563,7 +563,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, AmpVSCode, AmpCursor, AmpVSCodeInsider, AmpWindsurf, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, - MistralVibe, Codex, KimiCli, Factory: + MistralVibe, Codex, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } @@ -1292,8 +1292,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 27 supported clients - assert.Len(t, clients, 27, "Expected 27 supported clients") + // Should return all 28 supported clients + assert.Len(t, clients, 28, "Expected 28 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1317,6 +1317,34 @@ func TestGetAllClients(t *testing.T) { for _, expected := range expectedClients { assert.True(t, clientMap[expected], "Expected client %s to be in the list", expected) } + + // LLM-gateway-only tools must not appear in the MCP client list. + assert.NotContains(t, clients, ClientApp(Xcode), + "Xcode is LLM-gateway-only and must not appear in the MCP ClientApp enum") +} + +// TestLLMGatewayOnlyExcludedFromClientListAndValidation verifies that every +// supportedClientIntegrations entry marked LLMGatewayOnly is excluded from +// GetAllClients and rejected by IsValidClient. +func TestLLMGatewayOnlyExcludedFromClientListAndValidation(t *testing.T) { + t.Parallel() + + allClients := GetAllClients() + clientSet := make(map[ClientApp]bool, len(allClients)) + for _, c := range allClients { + clientSet[c] = true + } + + for _, cfg := range supportedClientIntegrations { + if !cfg.LLMGatewayOnly { + continue + } + assert.False(t, clientSet[cfg.ClientType], + "LLM-gateway-only tool %q must not appear in GetAllClients(); "+ + "declare it as LLMClientApp, not ClientApp", cfg.ClientType) + assert.False(t, IsValidClient(string(cfg.ClientType)), + "LLM-gateway-only tool %q must not be accepted by IsValidClient()", cfg.ClientType) + } } func TestIsValidClient(t *testing.T) { @@ -1479,7 +1507,7 @@ func TestGetClientListCSV(t *testing.T) { clientNames[i-1], clientNames[i]) } - // Count the number of clients (should be 25) + // Count the number of clients (should be 28) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 27, "Expected 27 clients in CSV list") + assert.Len(t, clients, 28, "Expected 28 clients in CSV list") } diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 685fb566bc..5dc2cfe2fa 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "runtime" @@ -22,6 +23,7 @@ type ClientManager struct { groupManager groups.Manager clientIntegrations []clientAppConfig configProvider config.Provider + lookPath func(string) (string, error) } // NewClientManager creates a new ClientManager with default dependencies @@ -42,6 +44,7 @@ func NewClientManager() (*ClientManager, error) { groupManager: groupManager, clientIntegrations: supportedClientIntegrations, configProvider: config.NewDefaultProvider(), + lookPath: exec.LookPath, }, nil } @@ -57,6 +60,7 @@ func NewTestClientManager( groupManager: groupManager, clientIntegrations: clientIntegrations, configProvider: configProvider, + lookPath: exec.LookPath, } } @@ -82,7 +86,7 @@ type ClientAppStatus struct { // configuration directory (or settings file when no relative path is defined). func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool { cfg := cm.lookupClientAppConfig(clientType) - if cfg == nil { + if cfg == nil || cfg.LLMGatewayOnly { return false } var pathToCheck string @@ -122,6 +126,9 @@ func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus } for _, cfg := range cm.clientIntegrations { + if cfg.LLMGatewayOnly { + continue + } status := ClientAppStatus{ ClientType: cfg.ClientType, Installed: cm.IsClientInstalled(cfg.ClientType), diff --git a/pkg/client/env_file.go b/pkg/client/env_file.go new file mode 100644 index 0000000000..03799903b7 --- /dev/null +++ b/pkg/client/env_file.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// ConfigureEnvFile writes the client's LLM env-file entries to the nominated +// dotenv file (e.g. ~/.gemini/.env). Existing entries for other keys are +// preserved; entries managed by thv are added or updated. +// +// Returns the absolute path of the written file, or ("", nil) when the client +// has no env-file entries configured. +func (cm *ClientManager) ConfigureEnvFile(clientType ClientApp, cfg llmgateway.ApplyConfig) (string, error) { + appCfg := cm.lookupClientAppConfig(clientType) + if appCfg == nil || len(appCfg.LLMEnvFileKeys) == 0 { + return "", nil + } + + path := cm.buildEnvFilePath(appCfg) + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("creating directory for %s: %w", path, err) + } + + err := fileutils.WithFileLock(path, func() error { + content, err := readOrInitFile(path, nil) + if err != nil { + return err + } + + entries := parseEnvFile(content) + + for _, spec := range appCfg.LLMEnvFileKeys { + val, err := envFileValueForSpec(spec, cfg) + if err != nil { + return err + } + entries = setEnvEntry(entries, spec.Name, val) + } + + return fileutils.AtomicWriteFile(path, marshalEnvFile(entries), 0o600) + }) + if err != nil { + return "", err + } + return path, nil +} + +// RevertEnvFile removes the entries that ConfigureEnvFile wrote for clientType +// from the nominated dotenv file. Other entries in the file are preserved. +// If the file does not exist the call is a no-op. +func (cm *ClientManager) RevertEnvFile(clientType ClientApp, envFilePath string) error { + appCfg := cm.lookupClientAppConfig(clientType) + if appCfg == nil || len(appCfg.LLMEnvFileKeys) == 0 { + return nil + } + if envFilePath == "" { + return nil + } + + if _, err := os.Stat(envFilePath); os.IsNotExist(err) { + return nil + } + + return fileutils.WithFileLock(envFilePath, func() error { + content, err := os.ReadFile(envFilePath) // #nosec G304 -- path is a known tool config file + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading %s: %w", envFilePath, err) + } + + entries := parseEnvFile(content) + for _, spec := range appCfg.LLMEnvFileKeys { + entries = removeEnvEntry(entries, spec.Name) + } + + return fileutils.AtomicWriteFile(envFilePath, marshalEnvFile(entries), 0o600) + }) +} + +// buildEnvFilePath constructs the absolute path to the .env file for the +// given client, using the same home-dir convention as LLM settings paths. +func (cm *ClientManager) buildEnvFilePath(cfg *clientAppConfig) string { + return buildConfigFilePath( + cfg.LLMEnvFileName, + cfg.LLMEnvFileRelPath, + nil, // no platform prefix for env files + []string{cm.homeDir}, + ) +} + +// HasEnvFileSupport reports whether clientType has .env file entries to manage. +func (cm *ClientManager) HasEnvFileSupport(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && len(cfg.LLMEnvFileKeys) > 0 +} + +// envFileValueForSpec resolves the value to write for a single LLMEnvFileKeySpec. +// Exactly one of spec.Literal or spec.ValueField must be set. +func envFileValueForSpec(spec LLMEnvFileKeySpec, cfg llmgateway.ApplyConfig) (string, error) { + if spec.Literal != "" && spec.ValueField != "" { + return "", fmt.Errorf("LLMEnvFileKeySpec for %q has both Literal and ValueField set; exactly one must be set", spec.Name) + } + if spec.Literal == "" && spec.ValueField == "" { + return "", fmt.Errorf("LLMEnvFileKeySpec for %q has neither Literal nor ValueField set; exactly one must be set", spec.Name) + } + if spec.Literal != "" { + return spec.Literal, nil + } + if v, ok := resolveApplyConfigField(spec.ValueField, cfg); ok { + return v, nil + } + return "", fmt.Errorf("unknown ValueField %q in LLMEnvFileKeySpec for %q; use Literal for constant values", + spec.ValueField, spec.Name) +} + +// envEntry is an ordered KEY=value entry in a dotenv file. Comments and blank +// lines are stored verbatim so that round-tripping preserves formatting. +type envEntry struct { + raw string // raw line for comments / blank lines (when key == "") + key string // non-empty for KEY=value lines + value string +} + +// parseEnvFile parses the content of a dotenv file into an ordered list of +// entries. Comments, blank lines, and malformed lines (no "=") are stored as +// raw entries so they survive a round-trip through marshalEnvFile. +func parseEnvFile(content []byte) []envEntry { + var entries []envEntry + // bytes.SplitSeq handles arbitrarily long lines since content is already in + // memory; bufio.Scanner would error on lines longer than its 64 KiB limit. + // Trim trailing newlines first so an empty or newline-only file produces no + // entries, and to avoid a spurious empty token after the final newline. + trimmed := bytes.TrimRight(content, "\r\n") + if len(trimmed) == 0 { + return entries + } + for rawLine := range bytes.SplitSeq(trimmed, []byte("\n")) { + // Strip \r to handle CRLF files. + line := string(bytes.TrimRight(rawLine, "\r")) + if line == "" || strings.HasPrefix(line, "#") { + entries = append(entries, envEntry{raw: line}) + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + // Malformed line — preserve as raw. + entries = append(entries, envEntry{raw: line}) + continue + } + entries = append(entries, envEntry{key: key, value: value}) + } + return entries +} + +// marshalEnvFile serialises entries back to a dotenv-formatted byte slice. +// A trailing newline is always appended. +func marshalEnvFile(entries []envEntry) []byte { + var b bytes.Buffer + for _, e := range entries { + if e.key == "" { + b.WriteString(e.raw) + } else { + b.WriteString(e.key) + b.WriteByte('=') + b.WriteString(e.value) + } + b.WriteByte('\n') + } + return b.Bytes() +} + +// setEnvEntry updates the value for key in entries, or appends a new entry if +// key is not present. Returns the (possibly grown) slice. +func setEnvEntry(entries []envEntry, key, value string) []envEntry { + for i, e := range entries { + if e.key == key { + entries[i].value = value + return entries + } + } + return append(entries, envEntry{key: key, value: value}) +} + +// removeEnvEntry removes the entry with the given key from entries (if any). +func removeEnvEntry(entries []envEntry, key string) []envEntry { + result := entries[:0:len(entries)] + for _, e := range entries { + if e.key != key { + result = append(result, e) + } + } + return result +} diff --git a/pkg/client/env_file_test.go b/pkg/client/env_file_test.go new file mode 100644 index 0000000000..e90960ef0d --- /dev/null +++ b/pkg/client/env_file_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// ── parseEnvFile / marshalEnvFile ───────────────────────────────────────────── + +func TestParseEnvFile_Empty(t *testing.T) { + t.Parallel() + entries := parseEnvFile(nil) + assert.Empty(t, entries) +} + +func TestParseEnvFile_KeyValueLines(t *testing.T) { + t.Parallel() + content := []byte("FOO=bar\nBAZ=qux\n") + entries := parseEnvFile(content) + require.Len(t, entries, 2) + assert.Equal(t, "FOO", entries[0].key) + assert.Equal(t, "bar", entries[0].value) + assert.Equal(t, "BAZ", entries[1].key) + assert.Equal(t, "qux", entries[1].value) +} + +func TestParseEnvFile_CommentsAndBlanks(t *testing.T) { + t.Parallel() + content := []byte("# comment\n\nFOO=bar\n") + entries := parseEnvFile(content) + require.Len(t, entries, 3) + assert.Equal(t, "# comment", entries[0].raw) + assert.Equal(t, "", entries[1].raw) // blank line + assert.Equal(t, "FOO", entries[2].key) +} + +func TestParseEnvFile_MalformedLinePreserved(t *testing.T) { + t.Parallel() + content := []byte("NOT_VALID_LINE\nFOO=bar\n") + entries := parseEnvFile(content) + require.Len(t, entries, 2) + assert.Equal(t, "NOT_VALID_LINE", entries[0].raw) + assert.Equal(t, "FOO", entries[1].key) +} + +func TestParseEnvFile_ValueWithEquals(t *testing.T) { + t.Parallel() + // Values may contain '=' (e.g. base64-encoded tokens). + content := []byte("KEY=abc=xyz\n") + entries := parseEnvFile(content) + require.Len(t, entries, 1) + assert.Equal(t, "KEY", entries[0].key) + assert.Equal(t, "abc=xyz", entries[0].value) +} + +func TestMarshalEnvFile_RoundTrip(t *testing.T) { + t.Parallel() + original := []byte("# header\nFOO=bar\nBAZ=qux\n") + entries := parseEnvFile(original) + got := marshalEnvFile(entries) + assert.Equal(t, string(original), string(got)) +} + +// ── setEnvEntry / removeEnvEntry ────────────────────────────────────────────── + +func TestSetEnvEntry_AddsNew(t *testing.T) { + t.Parallel() + entries := setEnvEntry(nil, "KEY", "value") + require.Len(t, entries, 1) + assert.Equal(t, "KEY", entries[0].key) + assert.Equal(t, "value", entries[0].value) +} + +func TestSetEnvEntry_UpdatesExisting(t *testing.T) { + t.Parallel() + entries := []envEntry{{key: "KEY", value: "old"}} + entries = setEnvEntry(entries, "KEY", "new") + require.Len(t, entries, 1) + assert.Equal(t, "new", entries[0].value) +} + +func TestRemoveEnvEntry_RemovesKey(t *testing.T) { + t.Parallel() + entries := []envEntry{{key: "KEEP", value: "1"}, {key: "REMOVE", value: "2"}} + entries = removeEnvEntry(entries, "REMOVE") + require.Len(t, entries, 1) + assert.Equal(t, "KEEP", entries[0].key) +} + +func TestRemoveEnvEntry_NoopWhenMissing(t *testing.T) { + t.Parallel() + entries := []envEntry{{key: "KEEP", value: "1"}} + entries = removeEnvEntry(entries, "MISSING") + require.Len(t, entries, 1) +} + +// ── ConfigureEnvFile ────────────────────────────────────────────────────────── + +func TestConfigureEnvFile_CreatesFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + + _, err := cm.ConfigureEnvFile(GeminiCli, llmgateway.ApplyConfig{ + ProxyBaseURL: "http://localhost:14000/v1", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, ".gemini", ".env")) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "GEMINI_API_KEY=thv-proxy\n") + assert.Contains(t, content, "GOOGLE_GEMINI_BASE_URL=http://localhost:14000\n") +} + +func TestConfigureEnvFile_UpdatesExistingFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + geminiDir := filepath.Join(dir, ".gemini") + require.NoError(t, os.MkdirAll(geminiDir, 0o700)) + existing := "# user comment\nOTHER_VAR=keep\nGOOGLE_GEMINI_BASE_URL=old\n" + require.NoError(t, os.WriteFile(filepath.Join(geminiDir, ".env"), []byte(existing), 0o600)) + + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + _, err := cm.ConfigureEnvFile(GeminiCli, llmgateway.ApplyConfig{ + ProxyBaseURL: "http://localhost:14000/v1", + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(geminiDir, ".env")) + require.NoError(t, err) + content := string(data) + // Original entries preserved. + assert.Contains(t, content, "# user comment\n") + assert.Contains(t, content, "OTHER_VAR=keep\n") + // thv-managed entries written or updated. + assert.Contains(t, content, "GEMINI_API_KEY=thv-proxy\n") + assert.Contains(t, content, "GOOGLE_GEMINI_BASE_URL=http://localhost:14000\n") + // Old value replaced (not duplicated). + assert.NotContains(t, content, "GOOGLE_GEMINI_BASE_URL=old") +} + +func TestConfigureEnvFile_NoopForClientWithoutEnvKeys(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + + // Claude Code has no LLMEnvFileKeys → returns ("", nil). + path, err := cm.ConfigureEnvFile(ClaudeCode, llmgateway.ApplyConfig{}) + require.NoError(t, err) + assert.Empty(t, path) +} + +// ── RevertEnvFile ───────────────────────────────────────────────────────────── + +func TestRevertEnvFile_RemovesEntries(t *testing.T) { + t.Parallel() + dir := t.TempDir() + geminiDir := filepath.Join(dir, ".gemini") + require.NoError(t, os.MkdirAll(geminiDir, 0o700)) + initial := "OTHER_VAR=keep\nGEMINI_API_KEY=thv-proxy\nGOOGLE_GEMINI_BASE_URL=http://localhost:14000\n" + envPath := filepath.Join(geminiDir, ".env") + require.NoError(t, os.WriteFile(envPath, []byte(initial), 0o600)) + + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + require.NoError(t, cm.RevertEnvFile(GeminiCli, envPath)) + + data, err := os.ReadFile(envPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "OTHER_VAR=keep\n") + assert.NotContains(t, content, "GEMINI_API_KEY") + assert.NotContains(t, content, "GOOGLE_GEMINI_BASE_URL") +} + +func TestRevertEnvFile_NoopWhenFileMissing(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + // File does not exist — must not return an error. + require.NoError(t, cm.RevertEnvFile(GeminiCli, filepath.Join(dir, ".gemini", ".env"))) +} + +func TestRevertEnvFile_NoopWhenPathEmpty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cm := NewTestClientManager(dir, nil, supportedClientIntegrations, nil) + require.NoError(t, cm.RevertEnvFile(GeminiCli, "")) +} + +// ── envFileValueForSpec ─────────────────────────────────────────────────────── + +func TestEnvFileValueForSpec_Literal(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY", Literal: "constant"} + val, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{}) + require.NoError(t, err) + assert.Equal(t, "constant", val) +} + +func TestEnvFileValueForSpec_ProxyOrigin(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY", ValueField: "ProxyOrigin"} + val, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{ProxyBaseURL: "http://localhost:14000/v1"}) + require.NoError(t, err) + assert.Equal(t, "http://localhost:14000", val) +} + +func TestEnvFileValueForSpec_PlaceholderAPIKey(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY", ValueField: "PlaceholderAPIKey"} + val, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{}) + require.NoError(t, err) + assert.Equal(t, llmPlaceholderAPIKey, val) +} + +func TestEnvFileValueForSpec_UnknownFieldReturnsError(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY", ValueField: "NotAField"} + _, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "NotAField") +} + +func TestEnvFileValueForSpec_BothSetReturnsError(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY", Literal: "val", ValueField: "GatewayURL"} + _, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "both Literal and ValueField") +} + +func TestEnvFileValueForSpec_NeitherSetReturnsError(t *testing.T) { + t.Parallel() + spec := LLMEnvFileKeySpec{Name: "KEY"} + _, err := envFileValueForSpec(spec, llmgateway.ApplyConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "neither Literal nor ValueField") +} diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go new file mode 100644 index 0000000000..4d487a97c1 --- /dev/null +++ b/pkg/client/llm_gateway.go @@ -0,0 +1,398 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/tailscale/hujson" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// llmPlaceholderAPIKey is the static API key written into proxy-mode tool +// configurations. The localhost reverse proxy accepts any non-empty value. +const llmPlaceholderAPIKey = "thv-proxy" + +// llmPatchOp is a single RFC 6902 JSON Patch operation, marshaled via +// encoding/json so all string fields are properly escaped. +type llmPatchOp struct { + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value,omitempty"` +} + +// ConfigureLLMGateway patches the tool's LLM-gateway settings file with cfg +// and returns the absolute path of the patched file. +// +// It uses fileutils.WithFileLock so concurrent calls (e.g. two "thv llm setup" +// invocations) are serialised. Comments and trailing commas in JSONC settings +// files are preserved via hujson. Writes are crash-safe via AtomicWriteFile. +func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgateway.ApplyConfig) (string, error) { + appCfg := cm.lookupClientAppConfig(clientType) + if appCfg == nil || appCfg.LLMGatewayMode == "" { + return "", fmt.Errorf("client %q does not support LLM gateway configuration", clientType) + } + + path := cm.buildLLMSettingsPath(appCfg) + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("creating directory for %s: %w", path, err) + } + + err := fileutils.WithFileLock(path, func() error { + content, err := readOrInitFile(path, []byte("{}")) + if err != nil { + return err + } + + // Parse with hujson first so that JSONC (comments, trailing commas) is + // handled correctly for all subsequent operations. + v, err := hujson.Parse(content) + if err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + + if err := applyLLMGatewayKeys(&v, appCfg.LLMGatewayKeys, cfg, path); err != nil { + return err + } + + formatted, err := hujson.Format(v.Pack()) + if err != nil { + return fmt.Errorf("formatting %s: %w", path, err) + } + return fileutils.AtomicWriteFile(path, formatted, 0o600) + }) + if err != nil { + return "", err + } + return path, nil +} + +// applyLLMGatewayKeys writes or removes each key spec into v according to cfg. +// Specs with ClearWhenEmpty=true are removed when their resolved value is empty, +// allowing conditional keys (e.g. NODE_TLS_REJECT_UNAUTHORIZED) to be cleaned +// up when the associated flag is cleared. +func applyLLMGatewayKeys(v *hujson.Value, specs []LLMGatewayKeySpec, cfg llmgateway.ApplyConfig, filePath string) error { + // Resolve all values up front so an unknown ValueField fails fast before + // any file is modified. + values := make([]string, len(specs)) + for i, spec := range specs { + if spec.Literal != "" { + values[i] = spec.Literal + } else { + v, err := llmValueForSpec(spec.ValueField, cfg) + if err != nil { + return err + } + values[i] = v + } + } + + // Ensure ancestors only for specs that will be written (not removed). + for i, spec := range specs { + if spec.ClearWhenEmpty && values[i] == "" { + continue + } + if err := ensureLLMAncestors(v, spec.JSONPointer, filePath); err != nil { + return err + } + } + + // Standardize once for existence checks in the remove path. + standardized, err := hujson.Standardize(v.Pack()) + if err != nil { + return fmt.Errorf("standardizing %s: %w", filePath, err) + } + + for i, spec := range specs { + value := values[i] + if spec.ClearWhenEmpty && value == "" { + if err := removeLLMKey(v, spec.JSONPointer, filePath, standardized); err != nil { + return err + } + continue + } + if err := addLLMKey(v, spec.JSONPointer, value, filePath); err != nil { + return err + } + } + return nil +} + +// removeLLMKey removes the key at ptr from v if it exists. standardized is +// pre-computed hujson.Standardize output used for the existence check. +func removeLLMKey(v *hujson.Value, ptr, filePath string, standardized []byte) error { + if !jsonPointerExists(standardized, ptr) { + return nil + } + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "remove", Path: ptr}}) + if err != nil { + return fmt.Errorf("marshaling remove patch for %s: %w", ptr, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("removing %s from %s: %w", ptr, filePath, err) + } + return nil +} + +// addLLMKey writes value to the key at ptr inside v. +func addLLMKey(v *hujson.Value, ptr, value, filePath string) error { + valueJSON, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling value for %s: %w", ptr, err) + } + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "add", Path: ptr, Value: valueJSON}}) + if err != nil { + return fmt.Errorf("marshaling patch for %s: %w", ptr, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("patching %s in %s: %w", ptr, filePath, err) + } + return nil +} + +// RevertLLMGateway removes the LLM gateway keys from the tool's settings file. +// If the file does not exist the call is a no-op. Comments and trailing commas +// in JSONC settings files are preserved. +func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath string) error { + appCfg := cm.lookupClientAppConfig(clientType) + if appCfg == nil || appCfg.LLMGatewayMode == "" { + return fmt.Errorf("client %q does not support LLM gateway configuration", clientType) + } + + // Guard against a missing file (or deleted parent directory) before trying + // to acquire the lock — WithFileLock creates configPath+".lock", which + // fails when the directory no longer exists. + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil + } + + return fileutils.WithFileLock(configPath, func() error { + content, err := os.ReadFile(configPath) // #nosec G304 -- path is caller-supplied config file + if err != nil { + if os.IsNotExist(err) { + return nil // file removed between stat and lock acquisition + } + return fmt.Errorf("reading %s: %w", configPath, err) + } + if len(content) == 0 { + return nil + } + + v, err := hujson.Parse(content) + if err != nil { + return fmt.Errorf("parsing %s: %w", configPath, err) + } + + // Standardize once for all existence checks below. + standardized, err := hujson.Standardize(v.Pack()) + if err != nil { + return fmt.Errorf("standardizing %s: %w", configPath, err) + } + + for _, spec := range appCfg.LLMGatewayKeys { + // Skip keys that are already absent — avoids brittle error-string matching. + if !jsonPointerExists(standardized, spec.JSONPointer) { + continue + } + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "remove", Path: spec.JSONPointer}}) + if err != nil { + return fmt.Errorf("marshaling patch for %s: %w", spec.JSONPointer, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("reverting %s from %s: %w", spec.JSONPointer, configPath, err) + } + } + + formatted, err := hujson.Format(v.Pack()) + if err != nil { + return fmt.Errorf("formatting %s: %w", configPath, err) + } + return fileutils.AtomicWriteFile(configPath, formatted, 0o600) + }) +} + +// IsLLMGatewaySupported reports whether clientType has LLM gateway support. +func (cm *ClientManager) IsLLMGatewaySupported(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && cfg.LLMGatewayMode != "" +} + +// LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. +func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return "" + } + return cfg.LLMGatewayMode +} + +// DetectedLLMGatewayClients returns the subset of LLM-gateway-capable clients +// that are installed on this machine. A client is considered installed when: +// 1. Its settings directory exists on disk. +// 2. If LLMBinaryName is set, the binary is also present on $PATH. +// +// The binary check prevents false positives from leftover config directories +// (e.g. ~/.claude or ~/.gemini) that remain after a tool is uninstalled. +func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { + var result []ClientApp + for i := range cm.clientIntegrations { + cfg := &cm.clientIntegrations[i] + if cfg.LLMGatewayMode == "" { + continue + } + path := cm.buildLLMSettingsPath(cfg) + if _, err := os.Stat(filepath.Dir(path)); err != nil { + continue + } + if cfg.LLMBinaryName != "" { + if _, err := cm.lookPath(cfg.LLMBinaryName); err != nil { + continue + } + } + result = append(result, cfg.ClientType) + } + return result +} + +// buildLLMSettingsPath resolves the absolute path to the LLM settings file +// for the given client using the same PlatformPrefix logic as MCP config paths. +func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { + return buildConfigFilePath( + cfg.LLMSettingsFile, + cfg.LLMSettingsRelPath, + cfg.LLMSettingsPlatformPrefix, + []string{cm.homeDir}, + ) +} + +// resolveApplyConfigField maps a ValueField name to its runtime value from cfg. +// Returns (value, true) for any recognized field, ("", false) for unknown ones. +// This is the single source of truth for ValueField semantics shared by both +// LLMGatewayKeySpec (settings-file patching) and LLMEnvFileKeySpec (.env injection). +func resolveApplyConfigField(valueField string, cfg llmgateway.ApplyConfig) (string, bool) { + switch valueField { + case "GatewayURL": + return cfg.GatewayURL, true + case "AnthropicBaseURL": + // Use the pre-computed Anthropic base URL when available; fall back to + // GatewayURL so existing configs continue to work without the prefix. + if cfg.AnthropicBaseURL != "" { + return cfg.AnthropicBaseURL, true + } + return cfg.GatewayURL, true + case "ProxyBaseURL": + return cfg.ProxyBaseURL, true + case "ProxyOrigin": + return llmgateway.ProxyOriginOf(cfg.ProxyBaseURL), true + case "TokenHelperCommand": + return cfg.TokenHelperCommand, true + case "PlaceholderAPIKey": + return llmPlaceholderAPIKey, true + case "NodeTLSRejectUnauthorized": + if cfg.TLSSkipVerify { + return "0", true + } + return "", true + default: + return "", false + } +} + +// llmValueForSpec returns the config value for a settings-file key spec. +// An unrecognised ValueField is a programming error and returns an error so +// typos are caught at call time rather than silently written into user config. +// Use LLMGatewayKeySpec.Literal for constant values. +func llmValueForSpec(valueField string, cfg llmgateway.ApplyConfig) (string, error) { + if v, ok := resolveApplyConfigField(valueField, cfg); ok { + return v, nil + } + return "", fmt.Errorf("unknown ValueField %q in LLMGatewayKeySpec; use Literal for constant values", valueField) +} + +// ensureLLMAncestors walks every ancestor of ptr from root inward and creates +// any missing intermediate object. For example, for "/a/b/c" it ensures "/a" +// and then "/a/b" exist, so the final "add" patch for "/a/b/c" never fails +// because a parent is missing. +// +// Existence is checked against standardized JSON (hujson.Standardize strips +// JSONC comments and trailing commas) so that JSONC input never produces a +// false "missing" result that would cause an RFC 6902 "add" to replace an +// existing object. +func ensureLLMAncestors(v *hujson.Value, ptr, filePath string) error { + segments := strings.Split(strings.TrimPrefix(ptr, "/"), "/") + if len(segments) <= 1 { + return nil // top-level key — no ancestors to create + } + // Standardize once for all existence checks in this call. + standardized, err := hujson.Standardize(v.Pack()) + if err != nil { + return fmt.Errorf("standardizing JSON in %s: %w", filePath, err) + } + + ancestor := "" + needsCreate := false + for _, seg := range segments[:len(segments)-1] { + ancestor += "/" + seg + // Once a missing ancestor is found, all deeper paths are also absent + // (we just created an empty object), so skip further existence checks. + if !needsCreate && jsonPointerExists(standardized, ancestor) { + continue + } + needsCreate = true + patchDoc, err := json.Marshal([]llmPatchOp{{Op: "add", Path: ancestor, Value: json.RawMessage("{}")}}) + if err != nil { + return fmt.Errorf("marshaling ancestor patch for %s in %s: %w", ancestor, filePath, err) + } + if err := v.Patch(patchDoc); err != nil { + return fmt.Errorf("creating ancestor object %s in %s: %w", ancestor, filePath, err) + } + } + return nil +} + +// jsonPointerExists reports whether the JSON Pointer path resolves to a value +// in standard (non-JSONC) JSON data. +// data must already be standardized via hujson.Standardize. +func jsonPointerExists(data []byte, pointer string) bool { + var root any + if err := json.Unmarshal(data, &root); err != nil { + return false + } + current := root + for _, seg := range strings.Split(strings.TrimPrefix(pointer, "/"), "/") { + m, ok := current.(map[string]any) + if !ok { + return false + } + current, ok = m[seg] + if !ok { + return false + } + } + return true +} + +// readOrInitFile reads path and returns its content. +// When the file is missing or empty it returns defaultContent instead. +// Returns an error only for real IO failures. +func readOrInitFile(path string, defaultContent []byte) ([]byte, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if os.IsNotExist(err) { + return defaultContent, nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return defaultContent, nil + } + return data, nil +} diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go new file mode 100644 index 0000000000..44ca028fe4 --- /dev/null +++ b/pkg/client/llm_gateway_test.go @@ -0,0 +1,759 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tailscale/hujson" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// jsonPointerGet resolves a JSON Pointer (RFC 6901) in data and returns the +// string value at that path, or ("", false) if the pointer does not exist or +// the value is not a string. data may be JSONC (hujson). +func jsonPointerGet(data []byte, pointer string) (string, bool) { + std, err := hujson.Standardize(data) + if err != nil { + return "", false + } + var root any + if err := json.Unmarshal(std, &root); err != nil { + return "", false + } + current := root + for _, seg := range strings.Split(strings.TrimPrefix(pointer, "/"), "/") { + m, ok := current.(map[string]any) + if !ok { + return "", false + } + current, ok = m[seg] + if !ok { + return "", false + } + } + s, ok := current.(string) + return s, ok +} + +// fakeLLMBinary is the sentinel binary name used in tests that exercise the +// exec.LookPath check inside DetectedLLMGatewayClients. The real lookup is +// replaced by an injected stub, so no actual binary needs to exist. +const fakeLLMBinary = "fake-llm-tool" + +// ── real production configs ─────────────────────────────────────────────────── + +// TestRealClientConfigs_ConfigureAndRevert exercises ConfigureLLMGateway and +// RevertLLMGateway against every entry in supportedClientIntegrations that has +// LLMGatewayMode set. This catches typos in JSON pointer paths, wrong +// ValueField names, or missing intermediate-object creation in the real config +// table — none of which are caught by tests that use fake clientAppConfig +// entries via LLMTestIntegrations. +// +// Values are asserted via exact JSON-pointer lookups rather than raw substring +// checks, so a value landing at a wrong pointer (or as a stray key name) will +// fail the test rather than silently pass. +func TestRealClientConfigs_ConfigureAndRevert(t *testing.T) { + t.Parallel() + + home := t.TempDir() + // Use real supportedClientIntegrations so we exercise the actual paths and keys. + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + + applyCfg := llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + ProxyBaseURL: "http://localhost:14000/v1", + TokenHelperCommand: `"thv" llm token`, + } + + // wantPointers maps RFC 6901 JSON pointer → expected string value after + // Configure. After Revert every pointer must be absent. + cases := []struct { + clientType ClientApp + wantPointers map[string]string + }{ + { + // ~/.claude/settings.json + clientType: ClaudeCode, + wantPointers: map[string]string{ + "/apiKeyHelper": `"thv" llm token`, + "/env/ANTHROPIC_BASE_URL": "https://gw.example.com", + }, + }, + { + // ~/.gemini/settings.json + // NODE_TLS_REJECT_UNAUTHORIZED must NOT appear when TLSSkipVerify is false. + clientType: GeminiCli, + wantPointers: map[string]string{ + "/security/auth/selectedType": "gemini-api-key", + }, + }, + { + // ~/./Cursor/User/settings.json + clientType: Cursor, + wantPointers: map[string]string{ + "/cursor.general.openAIBaseURL": "http://localhost:14000/v1", + "/cursor.general.openAIAPIKey": "thv-proxy", + }, + }, + { + // ~/./Code/User/settings.json + clientType: VSCode, + wantPointers: map[string]string{ + "/github.copilot.advanced.serverUrl": "http://localhost:14000/v1", + "/github.copilot.advanced.apiKey": "thv-proxy", + }, + }, + { + // ~/./Code - Insiders/User/settings.json + clientType: VSCodeInsider, + wantPointers: map[string]string{ + "/github.copilot.advanced.serverUrl": "http://localhost:14000/v1", + "/github.copilot.advanced.apiKey": "thv-proxy", + }, + }, + { + // ~/Library/Application Support/GitHub Copilot for Xcode/editorSettings.json + clientType: ClientApp(Xcode), + wantPointers: map[string]string{ + "/openAIBaseURL": "http://localhost:14000/v1", + "/apiKey": "thv-proxy", + }, + }, + } + + for _, tc := range cases { + t.Run(string(tc.clientType), func(t *testing.T) { + t.Parallel() + + cfg := cm.lookupClientAppConfig(tc.clientType) + require.NotNil(t, cfg, "missing entry in supportedClientIntegrations") + require.NotEmpty(t, cfg.LLMGatewayMode, "no LLMGatewayMode set") + + // Create the settings directory so detection and configure succeed. + settingsPath := cm.buildLLMSettingsPath(cfg) + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0o700)) + + // Configure and verify each pointer resolves to the expected value. + path, err := cm.ConfigureLLMGateway(tc.clientType, applyCfg) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr, want := range tc.wantPointers { + got, ok := jsonPointerGet(data, ptr) + assert.True(t, ok, "pointer %q missing after Configure for %s", ptr, tc.clientType) + assert.Equal(t, want, got, "wrong value at %q after Configure for %s", ptr, tc.clientType) + } + + // Revert and verify every pointer is gone. + require.NoError(t, cm.RevertLLMGateway(tc.clientType, path)) + + data, err = os.ReadFile(path) + require.NoError(t, err) + for ptr := range tc.wantPointers { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q still present after Revert for %s", ptr, tc.clientType) + } + }) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// newLLMManager builds a ClientManager with a single direct-mode LLM entry +// whose settings dir is homeDir/. +func newLLMManager(t *testing.T, clientType ClientApp, mode, dir string, ptrs, vals []string) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: clientType, + Mode: mode, + SettingsDir: []string{dir}, + SettingsFile: "settings.json", + JSONPointers: ptrs, + ValueFields: vals, + }}) + cm := NewTestClientManager(home, nil, cfgs, nil) + return cm, home +} + +// ── multi-level ancestor creation ──────────────────────────────────────────── + +// TestConfigureLLMGateway_DeepNestedKey verifies that a key three levels deep +// (e.g. "/a/b/c") is written correctly even when neither "/a" nor "/a/b" +// exist in the settings file yet. This exercises the ensureLLMAncestors path. +func TestConfigureLLMGateway_DeepNestedKey(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", + []string{"/a/b/c"}, []string{"GatewayURL"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + got, ok := jsonPointerGet(data, "/a/b/c") + assert.True(t, ok, "deep nested key /a/b/c must exist (ancestor objects must be created)") + assert.Equal(t, "https://gw.example.com", got, "deep nested value must match") +} + +// ── IsLLMGatewaySupported / LLMGatewayModeFor ───────────────────────────────── + +func TestIsLLMGatewaySupported(t *testing.T) { + t.Parallel() + cm, _ := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + assert.True(t, cm.IsLLMGatewaySupported(ClaudeCode)) + assert.False(t, cm.IsLLMGatewaySupported(Cursor)) // not in cfgs → unsupported +} + +func TestLLMGatewayModeFor(t *testing.T) { + t.Parallel() + cm, _ := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + assert.Equal(t, "direct", cm.LLMGatewayModeFor(ClaudeCode)) + assert.Equal(t, "", cm.LLMGatewayModeFor(Cursor)) +} + +// ── DetectedLLMGatewayClients ───────────────────────────────────────────────── + +func TestDetectedLLMGatewayClients_DirAbsent(t *testing.T) { + t.Parallel() + cm, _ := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + // settings dir not created → nothing detected + assert.Empty(t, cm.DetectedLLMGatewayClients()) +} + +func TestDetectedLLMGatewayClients_DirPresent(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + detected := cm.DetectedLLMGatewayClients() + require.Len(t, detected, 1) + assert.Equal(t, ClaudeCode, detected[0]) +} + +// ── ConfigureLLMGateway ─────────────────────────────────────────────────────── + +func TestConfigureLLMGateway_CreatesFile(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + }) + require.NoError(t, err) + assert.Equal(t, filepath.Join(claudeDir, "settings.json"), path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + got, ok := jsonPointerGet(data, "/apiKeyHelper") + assert.True(t, ok, "/apiKeyHelper pointer must be present") + assert.Equal(t, `"thv" llm token`, got, "/apiKeyHelper must contain the token helper command") +} + +func TestConfigureLLMGateway_PreservesExistingKeys(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + // pre-populate with an existing key that should survive + settingsPath := filepath.Join(claudeDir, "settings.json") + require.NoError(t, os.WriteFile(settingsPath, []byte(`{"permissions":{"allow":["read"]}}`), 0o600)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + }) + require.NoError(t, err) + + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + assert.Contains(t, string(data), "permissions") // non-string object — checked as raw substring + got, ok := jsonPointerGet(data, "/apiKeyHelper") + assert.True(t, ok, "/apiKeyHelper pointer must be present after configure") + assert.Equal(t, `"thv" llm token`, got) +} + +func TestConfigureLLMGateway_JSONCPreservesExistingParent(t *testing.T) { + t.Parallel() + // JSONC file with an existing "/env" object and a comment. Before the fix, + // gjson could not parse JSONC and would see "/env" as absent, causing an + // "add {}" patch that wiped the existing object. + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", + []string{"/env/ANTHROPIC_BASE_URL"}, []string{"GatewayURL"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + settingsPath := filepath.Join(claudeDir, "settings.json") + // Write JSONC with an existing "env" object containing another key. + require.NoError(t, os.WriteFile(settingsPath, + []byte(`{ // this is JSONC + "env": { "EXISTING_KEY": "keep-me" }, +}`), 0o600)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + }) + require.NoError(t, err) + + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + s := string(data) + // Comment and trailing comma must survive (JSONC round-trip). + assert.Contains(t, s, "// this is JSONC", "JSONC comment must be preserved after configure") + // Pre-existing sibling key inside the parent object must not be wiped. + assert.Contains(t, s, "EXISTING_KEY", "existing key inside parent object must be preserved") + assert.Contains(t, s, "keep-me", "existing value inside parent object must be preserved") + assert.Contains(t, s, "ANTHROPIC_BASE_URL", "new key must be added") + assert.Contains(t, s, "https://gw.example.com", "gateway URL must be written") +} + +func TestConfigureLLMGateway_UnsupportedClient(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, nil, nil) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support LLM gateway") +} + +func TestConfigureLLMGateway_Idempotent(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + cfg := llmgateway.ApplyConfig{TokenHelperCommand: `"thv" llm token`} + _, err := cm.ConfigureLLMGateway(ClaudeCode, cfg) + require.NoError(t, err) + _, err = cm.ConfigureLLMGateway(ClaudeCode, cfg) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + require.NoError(t, err) + // key should appear exactly once + assert.Equal(t, 1, countSubstring(string(data), "apiKeyHelper")) +} + +// ── RevertLLMGateway ────────────────────────────────────────────────────────── + +func TestRevertLLMGateway_RemovesKey(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + settingsPath := filepath.Join(claudeDir, "settings.json") + require.NoError(t, os.WriteFile(settingsPath, + []byte(`{"apiKeyHelper":"thv llm token","permissions":{"allow":["read"]}}`), 0o600)) + + require.NoError(t, cm.RevertLLMGateway(ClaudeCode, settingsPath)) + + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + _, ok := jsonPointerGet(data, "/apiKeyHelper") + assert.False(t, ok, "/apiKeyHelper must be removed after revert") + assert.Contains(t, string(data), "permissions") // non-string object — checked as raw substring +} + +func TestRevertLLMGateway_MissingFile(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + // File does not exist → no-op, no error + missing := filepath.Join(home, ".claude", "settings.json") + assert.NoError(t, cm.RevertLLMGateway(ClaudeCode, missing)) +} + +func TestRevertLLMGateway_MissingDir(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + // Neither the dir nor the file exist → no-op, no error + missing := filepath.Join(home, ".no-such-dir", "settings.json") + assert.NoError(t, cm.RevertLLMGateway(ClaudeCode, missing)) +} + +func TestRevertLLMGateway_EmptyFile(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, ClaudeCode, "direct", ".claude", []string{"/apiKeyHelper"}, []string{"TokenHelperCommand"}) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + settingsPath := filepath.Join(claudeDir, "settings.json") + require.NoError(t, os.WriteFile(settingsPath, []byte{}, 0o600)) + + assert.NoError(t, cm.RevertLLMGateway(ClaudeCode, settingsPath)) +} + +func TestRevertLLMGateway_UnsupportedClient(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, nil, nil) + + err := cm.RevertLLMGateway(ClaudeCode, "/some/path") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support LLM gateway") +} + +// ── proxy-mode (nested key) ─────────────────────────────────────────────────── + +func TestConfigureLLMGateway_ProxyMode(t *testing.T) { + t.Parallel() + cm, home := newLLMManager(t, Cursor, "proxy", ".cursor-test", []string{"/github.copilot.advanced.serverUrl", "/github.copilot.advanced.apiKey"}, + []string{"ProxyBaseURL", "PlaceholderAPIKey"}) + + dir := filepath.Join(home, ".cursor-test") + require.NoError(t, os.MkdirAll(dir, 0o700)) + + path, err := cm.ConfigureLLMGateway(Cursor, llmgateway.ApplyConfig{ + ProxyBaseURL: "http://localhost:14000/v1", + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + serverURL, okURL := jsonPointerGet(data, "/github.copilot.advanced.serverUrl") + assert.True(t, okURL, "/github.copilot.advanced.serverUrl pointer must be present") + assert.Equal(t, "http://localhost:14000/v1", serverURL) + apiKey, okKey := jsonPointerGet(data, "/github.copilot.advanced.apiKey") + assert.True(t, okKey, "/github.copilot.advanced.apiKey pointer must be present") + assert.Equal(t, "thv-proxy", apiKey) +} + +// ── DetectedLLMGatewayClients ───────────────────────────────────────────────── + +// TestDetectedLLMGatewayClients_DirOnly verifies that a client without a +// BinaryName set is detected based solely on the settings directory existing. +func TestDetectedLLMGatewayClients_DirOnly(t *testing.T) { + t.Parallel() + home := t.TempDir() + + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: ClaudeCode, + Mode: "direct", + SettingsDir: []string{".claude"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/apiKeyHelper"}, + ValueFields: []string{"TokenHelperCommand"}, + }}) + // LLMBinaryName is intentionally left empty — dir check only. + cm := NewTestClientManager(home, nil, cfgs, nil) + + // Directory absent → not detected. + require.Empty(t, cm.DetectedLLMGatewayClients()) + + // Create the settings directory → now detected. + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + detected := cm.DetectedLLMGatewayClients() + require.Len(t, detected, 1) + assert.Equal(t, ClaudeCode, detected[0]) +} + +// TestDetectedLLMGatewayClients_BinaryAndDirExist verifies that a client is +// detected when both the settings directory and the binary are present. +func TestDetectedLLMGatewayClients_BinaryAndDirExist(t *testing.T) { + t.Parallel() + home := t.TempDir() + + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: GeminiCli, + Mode: "direct", + SettingsDir: []string{".gemini"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/baseUrl"}, + ValueFields: []string{"GatewayURL"}, + }}) + cfgs[0].LLMBinaryName = fakeLLMBinary + cm := NewTestClientManager(home, nil, cfgs, nil) + // Inject a lookPath that reports the fake binary as found. + cm.lookPath = func(name string) (string, error) { return "/usr/local/bin/" + name, nil } + + require.NoError(t, os.MkdirAll(filepath.Join(home, ".gemini"), 0o700)) + + detected := cm.DetectedLLMGatewayClients() + require.Len(t, detected, 1) + assert.Equal(t, GeminiCli, detected[0]) +} + +// TestDetectedLLMGatewayClients_DirExistsButBinaryAbsent verifies that a +// client is NOT detected when the settings directory exists but the binary is +// absent from $PATH — the false-positive case the fix addresses. +func TestDetectedLLMGatewayClients_DirExistsButBinaryAbsent(t *testing.T) { + t.Parallel() + home := t.TempDir() + + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: ClaudeCode, + Mode: "direct", + SettingsDir: []string{".claude"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/apiKeyHelper"}, + ValueFields: []string{"TokenHelperCommand"}, + }}) + cfgs[0].LLMBinaryName = fakeLLMBinary + cm := NewTestClientManager(home, nil, cfgs, nil) + // Inject a lookPath that always reports the binary as missing. + cm.lookPath = func(_ string) (string, error) { return "", os.ErrNotExist } + + // Create the settings directory to simulate a leftover install. + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + // Should NOT be detected because the binary is not on $PATH. + assert.Empty(t, cm.DetectedLLMGatewayClients()) +} + +// TestDetectedLLMGatewayClients_NeitherDirNorBinary verifies that a client is +// not detected when neither the directory nor the binary are present. +func TestDetectedLLMGatewayClients_NeitherDirNorBinary(t *testing.T) { + t.Parallel() + home := t.TempDir() + + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: ClaudeCode, + Mode: "direct", + SettingsDir: []string{".claude"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/apiKeyHelper"}, + ValueFields: []string{"TokenHelperCommand"}, + }}) + cfgs[0].LLMBinaryName = fakeLLMBinary + cm := NewTestClientManager(home, nil, cfgs, nil) + cm.lookPath = func(_ string) (string, error) { return "", os.ErrNotExist } + + assert.Empty(t, cm.DetectedLLMGatewayClients()) +} + +// TestRealClientConfigs_LLMBinaryNames asserts the expected binary name for +// every LLM-gateway-capable entry in supportedClientIntegrations. This is a +// regression guard: a silent typo (e.g. "code" instead of "code-insiders") +// causes detection to fail on machines that only have the Insiders build. +func TestRealClientConfigs_LLMBinaryNames(t *testing.T) { + t.Parallel() + + want := map[ClientApp]string{ + VSCodeInsider: "code-insiders", + VSCode: "code", + Cursor: "cursor", + ClaudeCode: "claude", + GeminiCli: "gemini", + // Tools without a binary check (dir-only detection) are omitted. + } + + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + + for clientType, wantBinary := range want { + t.Run(string(clientType), func(t *testing.T) { + t.Parallel() + cfg := cm.lookupClientAppConfig(clientType) + require.NotNil(t, cfg, "missing entry in supportedClientIntegrations for %s", clientType) + assert.Equal(t, wantBinary, cfg.LLMBinaryName, + "wrong LLMBinaryName for %s: detection will fail on machines that only have the expected binary", clientType) + }) + } +} + +// ── TLSSkipVerify / NodeTLSRejectUnauthorized / ClearWhenEmpty ─────────────── + +func newTLSTestManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cfgs := LLMTestIntegrations([]LLMTestEntry{{ + ClientType: ClaudeCode, + Mode: "direct", + SettingsDir: []string{".claude"}, + SettingsFile: "settings.json", + JSONPointers: []string{"/apiKeyHelper", "/env/NODE_TLS_REJECT_UNAUTHORIZED"}, + ValueFields: []string{"TokenHelperCommand", "NodeTLSRejectUnauthorized"}, + ClearWhenEmpty: []bool{false, true}, + }}) + return NewTestClientManager(home, nil, cfgs, nil), home +} + +func TestConfigureLLMGateway_TLSSkipVerify_WritesNodeEnv(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: true, + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + require.NoError(t, err) + val, ok := jsonPointerGet(data, "/env/NODE_TLS_REJECT_UNAUTHORIZED") + assert.True(t, ok, "/env/NODE_TLS_REJECT_UNAUTHORIZED must be present when TLSSkipVerify=true") + assert.Equal(t, "0", val) +} + +func TestConfigureLLMGateway_TLSSkipVerify_NotSet_DoesNotWriteNodeEnv(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: false, + }) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + require.NoError(t, err) + _, ok := jsonPointerGet(data, "/env/NODE_TLS_REJECT_UNAUTHORIZED") + assert.False(t, ok, "/env/NODE_TLS_REJECT_UNAUTHORIZED must not be written when TLSSkipVerify=false") +} + +func TestConfigureLLMGateway_TLSSkipVerify_ClearRemovesKey(t *testing.T) { + t.Parallel() + cm, home := newTLSTestManager(t) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + // First run: set tls-skip-verify + _, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: true, + }) + require.NoError(t, err) + + settingsPath := filepath.Join(claudeDir, "settings.json") + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + _, present := jsonPointerGet(data, "/env/NODE_TLS_REJECT_UNAUTHORIZED") + require.True(t, present, "/env/NODE_TLS_REJECT_UNAUTHORIZED must be present after first configure") + + // Second run: clear tls-skip-verify + _, err = cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: false, + }) + require.NoError(t, err) + + data, err = os.ReadFile(settingsPath) + require.NoError(t, err) + _, present = jsonPointerGet(data, "/env/NODE_TLS_REJECT_UNAUTHORIZED") + assert.False(t, present, "/env/NODE_TLS_REJECT_UNAUTHORIZED must be removed when TLSSkipVerify is cleared") +} + +// TestRealClientConfigs_GeminiCLI_NeverWritesTLSKey verifies that +// NODE_TLS_REJECT_UNAUTHORIZED is never written for Gemini CLI regardless of +// TLSSkipVerify. In proxy mode the tool connects to localhost over plain HTTP, +// so setting the env var would only globally suppress TLS for other HTTPS +// requests — an unacceptable side-effect. The key spec is intentionally absent. +func TestRealClientConfigs_GeminiCLI_NeverWritesTLSKey(t *testing.T) { + t.Parallel() + + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".gemini"), 0o700)) + + for _, skipVerify := range []bool{false, true} { + path, err := cm.ConfigureLLMGateway(GeminiCli, llmgateway.ApplyConfig{ + ProxyBaseURL: "http://localhost:14000/v1", + TLSSkipVerify: skipVerify, + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + _, ok := jsonPointerGet(data, "/env/NODE_TLS_REJECT_UNAUTHORIZED") + assert.False(t, ok, "TLS key must never be written for Gemini CLI (TLSSkipVerify=%v)", skipVerify) + } +} + +// ── llmValueForSpec unit tests ──────────────────────────────────────────────── + +func TestLLMValueForSpec(t *testing.T) { + t.Parallel() + + cfg := llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + ProxyBaseURL: "http://localhost:14000/v1", + TokenHelperCommand: `"thv" llm token`, + TLSSkipVerify: false, + } + + cases := []struct { + name string + valueField string + cfg llmgateway.ApplyConfig + want string + wantErr bool + }{ + // Known ValueField names resolve correctly + {name: "GatewayURL", valueField: "GatewayURL", cfg: cfg, want: "https://gw.example.com"}, + {name: "ProxyBaseURL", valueField: "ProxyBaseURL", cfg: cfg, want: "http://localhost:14000/v1"}, + {name: "TokenHelperCommand", valueField: "TokenHelperCommand", cfg: cfg, want: `"thv" llm token`}, + {name: "PlaceholderAPIKey", valueField: "PlaceholderAPIKey", cfg: cfg, want: "thv-proxy"}, + // NodeTLSRejectUnauthorized: "0" when set, "" when clear + {name: "NodeTLSRejectUnauthorized/skip=false", valueField: "NodeTLSRejectUnauthorized", cfg: cfg, want: ""}, + {name: "NodeTLSRejectUnauthorized/skip=true", valueField: "NodeTLSRejectUnauthorized", cfg: llmgateway.ApplyConfig{TLSSkipVerify: true}, want: "0"}, + // ProxyOrigin strips path, query, and fragment from ProxyBaseURL + {name: "ProxyOrigin/strips_path", valueField: "ProxyOrigin", cfg: cfg, want: "http://localhost:14000"}, + {name: "ProxyOrigin/long_path", valueField: "ProxyOrigin", cfg: llmgateway.ApplyConfig{ProxyBaseURL: "http://localhost:9000/v1beta/openai"}, want: "http://localhost:9000"}, + {name: "ProxyOrigin/strips_query_and_fragment", valueField: "ProxyOrigin", cfg: llmgateway.ApplyConfig{ProxyBaseURL: "http://host:8080/path?q=1#frag"}, want: "http://host:8080"}, + // ForceQuery: trailing "?" with no key must not leak into the origin. + {name: "ProxyOrigin/force_query", valueField: "ProxyOrigin", cfg: llmgateway.ApplyConfig{ProxyBaseURL: "http://host:8080/path?"}, want: "http://host:8080"}, + // ProxyOrigin falls back to the raw value when URL parsing fails + {name: "ProxyOrigin/invalid_url_fallback", valueField: "ProxyOrigin", cfg: llmgateway.ApplyConfig{ProxyBaseURL: "::invalid"}, want: "::invalid"}, + // Unknown ValueField names are programming errors and must return an error + {name: "unknown_ValueField/typo", valueField: "GatwayURL", cfg: cfg, wantErr: true}, + {name: "unknown_ValueField/arbitrary", valueField: "gemini-api-key", cfg: cfg, wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := llmValueForSpec(tc.valueField, tc.cfg) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// countSubstring counts non-overlapping occurrences of substr in s. +func countSubstring(s, substr string) int { + count := 0 + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + count++ + i += len(substr) - 1 + } + } + return count +} diff --git a/pkg/client/test_support.go b/pkg/client/test_support.go new file mode 100644 index 0000000000..30894dcde5 --- /dev/null +++ b/pkg/client/test_support.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +// LLMTestEntry describes a minimal LLM-gateway-capable client integration for +// use in tests. No platform prefix is applied, so settings files resolve as +// homeDir/SettingsDir.../SettingsFile on all platforms. +type LLMTestEntry struct { + ClientType ClientApp + Mode string // "direct" or "proxy" + SettingsDir []string // path segments from homeDir to the settings directory + SettingsFile string // settings filename + JSONPointers []string // RFC 6901 JSON Pointer paths to patch + ValueFields []string // value-field names parallel to JSONPointers + ClearWhenEmpty []bool // ClearWhenEmpty flags parallel to JSONPointers (optional) +} + +// LLMTestIntegrations converts []LLMTestEntry into an internal []clientAppConfig +// slice suitable for passing as the third argument to NewTestClientManager. +// Because clientAppConfig is unexported, callers assign the result via := +// (type inferred) and pass it directly to NewTestClientManager. +func LLMTestIntegrations(entries []LLMTestEntry) []clientAppConfig { + cfgs := make([]clientAppConfig, len(entries)) + for i, e := range entries { + keys := make([]LLMGatewayKeySpec, len(e.JSONPointers)) + for j, ptr := range e.JSONPointers { + vf := "" + if j < len(e.ValueFields) { + vf = e.ValueFields[j] + } + cwe := false + if j < len(e.ClearWhenEmpty) { + cwe = e.ClearWhenEmpty[j] + } + keys[j] = LLMGatewayKeySpec{JSONPointer: ptr, ValueField: vf, ClearWhenEmpty: cwe} + } + cfgs[i] = clientAppConfig{ + ClientType: e.ClientType, + LLMGatewayMode: e.Mode, + LLMSettingsFile: e.SettingsFile, + LLMSettingsRelPath: e.SettingsDir, + LLMGatewayKeys: keys, + } + } + return cfgs +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 01222d8e77..7151912793 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -19,7 +19,9 @@ import ( "github.com/stacklok/toolhive-core/env" "github.com/stacklok/toolhive/pkg/container/templates" + "github.com/stacklok/toolhive/pkg/llm" "github.com/stacklok/toolhive/pkg/lockfile" + "github.com/stacklok/toolhive/pkg/oidc" "github.com/stacklok/toolhive/pkg/secrets" ) @@ -47,6 +49,7 @@ type Config struct { BuildAuthFiles map[string]string `yaml:"build_auth_files,omitempty"` RuntimeConfigs map[string]*templates.RuntimeConfig `yaml:"runtime_configs,omitempty"` RegistryAuth RegistryAuth `yaml:"registry_auth,omitempty"` + LLM llm.Config `yaml:"llm,omitempty"` } // RegistryAuthTypeOAuth is the auth type for OAuth/OIDC authentication. @@ -63,17 +66,10 @@ type RegistryAuth struct { // RegistryOAuthConfig holds OAuth/OIDC configuration for registry authentication. // PKCE (S256) is always enforced per OAuth 2.1 requirements for public clients. -type RegistryOAuthConfig struct { - Issuer string `yaml:"issuer"` - ClientID string `yaml:"client_id"` - Scopes []string `yaml:"scopes,omitempty"` - Audience string `yaml:"audience,omitempty"` - CallbackPort int `yaml:"callback_port,omitempty"` - - // Cached token references for session restoration across CLI invocations. - CachedRefreshTokenRef string `yaml:"cached_refresh_token_ref,omitempty"` - CachedTokenExpiry time.Time `yaml:"cached_token_expiry,omitempty"` -} +// +// This is a type alias for oidc.ClientConfig so that registry and LLM gateway +// authentication always share the same field set and validation logic. +type RegistryOAuthConfig = oidc.ClientConfig // Secrets contains the settings for secrets management. type Secrets struct { @@ -194,8 +190,11 @@ func createNewConfigWithDefaults() Config { } } -// applyBackwardCompatibility applies backward compatibility fixes to existing configs -func applyBackwardCompatibility(config *Config) error { +// applyBackwardCompatibility applies backward compatibility fixes to existing configs. +// Any migration that needs to be persisted is written back to configPath, the same +// path the config was loaded from, so that path-based loads (e.g. PathProvider) stay +// isolated. An empty configPath falls back to the default path via saveToPath. +func applyBackwardCompatibility(config *Config, configPath string) error { // Hack - if the secrets provider type is set to the old `basic` type, // just change it to `encrypted`. if config.Secrets.ProviderType == "basic" { @@ -206,7 +205,7 @@ func applyBackwardCompatibility(config *Config) error { _ = os.Remove(oldPath) } config.Secrets.ProviderType = string(secrets.EncryptedType) - err = config.save() + err = config.saveToPath(configPath) if err != nil { return fmt.Errorf("error updating config: %w", err) } @@ -216,7 +215,7 @@ func applyBackwardCompatibility(config *Config) error { // consider it as setup completed (for existing users) if config.Secrets.ProviderType != "" && !config.Secrets.SetupCompleted { config.Secrets.SetupCompleted = true - err := config.save() + err := config.saveToPath(configPath) if err != nil { return fmt.Errorf("error updating config for backward compatibility: %w", err) } @@ -295,8 +294,9 @@ func LoadOrCreateConfigFromPath(configPath string) (*Config, error) { return nil, fmt.Errorf("failed to parse config file yaml: %w", err) } - // Apply backward compatibility fixes - err = applyBackwardCompatibility(&config) + // Apply backward compatibility fixes, persisting any migration back to the + // same path the config was loaded from. + err = applyBackwardCompatibility(&config, configPath) if err != nil { return nil, fmt.Errorf("failed to apply backward compatibility fixes: %w", err) } @@ -305,11 +305,6 @@ func LoadOrCreateConfigFromPath(configPath string) (*Config, error) { return &config, nil } -// Save serializes the config struct and writes it to disk. -func (c *Config) save() error { - return c.saveToPath("") -} - // saveToPath serializes the config struct and writes it to a specific path. // If configPath is empty, it uses the default path. func (c *Config) saveToPath(configPath string) error { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 56f076772e..4bc30074f4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -282,6 +282,46 @@ func TestUpdateConfigAtPath_CallbackError(t *testing.T) { "config should not be written to disk when the callback returns an error") } +// TestLoadFromPath_BackwardCompatMigrationStaysOnPath guards the test-isolation +// regression from issue #894: a path-based load that triggers a backward-compat +// migration must persist the migration back to the same path, never to the +// default (real) config path. See applyBackwardCompatibility. +// +//nolint:paralleltest // swaps the package-level getConfigPath; must not run in parallel +func TestLoadFromPath_BackwardCompatMigrationStaysOnPath(t *testing.T) { + // Not parallel: this test swaps the package-level getConfigPath sentinel. + + // Point the default path generator at a sentinel that must never be written. + sentinelPath := filepath.Join(t.TempDir(), "should-never-exist", "config.yaml") + originalGetConfigPath := getConfigPath + getConfigPath = func() (string, error) { return sentinelPath, nil } + t.Cleanup(func() { getConfigPath = originalGetConfigPath }) + + // A config that triggers the "provider set but setup_completed false" migration. + _, configPath := SetupTestConfig(t, &Config{ + Secrets: Secrets{ + ProviderType: string(secrets.EncryptedType), + SetupCompleted: false, + }, + }) + + config, err := LoadOrCreateConfigWithPath(configPath) + require.NoError(t, err) + assert.True(t, config.Secrets.SetupCompleted, + "backward-compat migration should mark setup as completed") + + // The migration must be persisted to the path we loaded from. + reloaded, err := LoadOrCreateConfigWithPath(configPath) + require.NoError(t, err) + assert.True(t, reloaded.Secrets.SetupCompleted, + "migration should be persisted back to the loaded path") + + // The default/real config path must never have been touched. + _, statErr := os.Stat(sentinelPath) + assert.ErrorIs(t, statErr, os.ErrNotExist, + "backward-compat migration must not write to the default config path") +} + func TestSecrets_GetProviderType_EnvironmentVariable(t *testing.T) { t.Parallel() diff --git a/pkg/config/interface_test.go b/pkg/config/interface_test.go index dfbd8868a8..5a6b5e20b0 100644 --- a/pkg/config/interface_test.go +++ b/pkg/config/interface_test.go @@ -291,7 +291,7 @@ func TestProviderRegistryOperations(t *testing.T) { // Test SetRegistryFile (must be a JSON file with valid registry structure) registryFilePath := filepath.Join(tempDir, "registry.json") - validRegistryJSON := `{"servers": {"test-server": {"command": ["test"], "args": []}}}` + validRegistryJSON := `{"data": {"servers": [{"name": "test-server"}]}}` err = os.WriteFile(registryFilePath, []byte(validRegistryJSON), 0600) require.NoError(t, err) err = pathProvider.SetRegistryFile(registryFilePath) @@ -333,7 +333,7 @@ func TestProviderRegistryOperations(t *testing.T) { // Test SetRegistryFile with valid structure (should succeed) validFilePath := filepath.Join(tempDir, "path_registry.json") - validRegistryJSON := `{"servers": {"test-server": {"command": ["test"], "args": []}}}` + validRegistryJSON := `{"data": {"servers": [{"name": "test-server"}]}}` err = os.WriteFile(validFilePath, []byte(validRegistryJSON), 0600) require.NoError(t, err) err = provider.SetRegistryFile(validFilePath) diff --git a/pkg/config/registry.go b/pkg/config/registry.go index 607793ab0d..cb0f1cb2cc 100644 --- a/pkg/config/registry.go +++ b/pkg/config/registry.go @@ -20,6 +20,7 @@ import ( registrytypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/registry/legacyhint" ) const ( @@ -169,29 +170,27 @@ func isValidRegistryJSON(client *http.Client, url string) error { return fmt.Errorf("%w: failed to read response body: %v", ErrRegistryValidationFailed, err) } - // Try upstream format first - if isUpstreamRegistryFormat(data) { - var upstream registrytypes.UpstreamRegistry - if err := json.Unmarshal(data, &upstream); err != nil { - return fmt.Errorf("%w: invalid upstream JSON format: %v", ErrRegistryValidationFailed, err) - } - if len(upstream.Data.Servers) > 0 || len(upstream.Data.Groups) > 0 { - return nil - } - return fmt.Errorf("%w: upstream registry contains no servers", ErrRegistryValidationFailed) + if err := parseAndValidateUpstreamRegistry(data); err != nil { + return fmt.Errorf("%w: %v", ErrRegistryValidationFailed, err) } + return nil +} - // Fall back to legacy format - registry := ®istrytypes.Registry{} - if err := json.Unmarshal(data, registry); err != nil { - return fmt.Errorf("%w: invalid JSON format: %v", ErrRegistryValidationFailed, err) +// parseAndValidateUpstreamRegistry parses data as an UpstreamRegistry and +// verifies that it contains at least one server or skill. If the data looks +// like the legacy ToolHive registry format, returns an error containing the +// migration hint message. +func parseAndValidateUpstreamRegistry(data []byte) error { + if legacyhint.Looks(data) { + return errors.New(legacyhint.MigrationMessage) } - - // Verify registry contains at least one server (in top-level or groups) - if !registryHasServers(registry) { - return fmt.Errorf("%w: registry contains no servers", ErrRegistryValidationFailed) + var upstream registrytypes.UpstreamRegistry + if err := json.Unmarshal(data, &upstream); err != nil { + return fmt.Errorf("invalid upstream registry format: %w", err) + } + if len(upstream.Data.Servers) == 0 && len(upstream.Data.Skills) == 0 { + return errors.New("upstream registry contains no servers or skills") } - return nil } @@ -334,42 +333,8 @@ func setRegistryFile(provider Provider, registryPath string) error { return nil } -// isUpstreamRegistryFormat returns true if the JSON data appears to be in the -// upstream MCP registry format. The key discriminator is the "data" wrapper -// object — only the upstream format wraps servers inside it. -// NOTE: keep in sync with isUpstreamFormat in pkg/registry/upstream_parser.go -// (duplicated to avoid a circular import). -func isUpstreamRegistryFormat(data []byte) bool { - var probe struct { - Data json.RawMessage `json:"data"` - } - if err := json.Unmarshal(data, &probe); err != nil { - return false - } - // The "data" wrapper object is unique to the upstream format. - return len(probe.Data) > 0 && probe.Data[0] == '{' -} - -// registryHasServers checks if a registry contains at least one server -// (either in top-level servers/remote_servers or within groups) -func registryHasServers(registry *registrytypes.Registry) bool { - // Check top-level servers - if len(registry.Servers) > 0 || len(registry.RemoteServers) > 0 { - return true - } - - // Check servers within groups - for _, group := range registry.Groups { - if group != nil && (len(group.Servers) > 0 || len(group.RemoteServers) > 0) { - return true - } - } - - return false -} - -// validateRegistryFileStructure checks if a file contains valid ToolHive registry structure -// by parsing it into the actual Registry type. Accepts both upstream and legacy formats. +// validateRegistryFileStructure checks if a file contains a valid upstream MCP +// registry structure by parsing it into the UpstreamRegistry type. func validateRegistryFileStructure(path string) error { // Read file content // #nosec G304: File path is user-provided but validated by caller @@ -378,30 +343,7 @@ func validateRegistryFileStructure(path string) error { return fmt.Errorf("failed to read file: %w", err) } - // Try upstream format first - if isUpstreamRegistryFormat(data) { - var upstream registrytypes.UpstreamRegistry - if err := json.Unmarshal(data, &upstream); err != nil { - return fmt.Errorf("invalid upstream registry format: %w", err) - } - if len(upstream.Data.Servers) > 0 || len(upstream.Data.Groups) > 0 { - return nil - } - return fmt.Errorf("upstream registry contains no servers or groups") - } - - // Fall back to legacy format - registry := ®istrytypes.Registry{} - if err := json.Unmarshal(data, registry); err != nil { - return fmt.Errorf("invalid registry format: %w", err) - } - - // Verify registry contains at least one server (in top-level or groups) - if !registryHasServers(registry) { - return fmt.Errorf("registry contains no servers (expected at least one server in 'servers', 'remote_servers', or 'groups')") - } - - return nil + return parseAndValidateUpstreamRegistry(data) } // setRegistryAPI validates and sets an MCP Registry API URL using the provided provider diff --git a/pkg/config/registry_test.go b/pkg/config/registry_test.go index d12924c04a..d9b4476a18 100644 --- a/pkg/config/registry_test.go +++ b/pkg/config/registry_test.go @@ -54,18 +54,18 @@ func TestDetectRegistryType(t *testing.T) { //nolint:tparallel,paralleltest // C return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/": - // Return valid ToolHive registry JSON w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ + "$schema": "https://example.com/schema.json", "version": "1.0.0", - "servers": map[string]interface{}{ - "test-server": map[string]interface{}{ - "image": "test:latest", + "meta": map[string]interface{}{"last_updated": "2025-01-01T00:00:00Z"}, + "data": map[string]interface{}{ + "servers": []interface{}{ + map[string]interface{}{"name": "io.example.test-server"}, }, }, }) default: - // Return 404 for API endpoint and any other path w.WriteHeader(http.StatusNotFound) } })) @@ -115,31 +115,6 @@ func TestDetectRegistryType(t *testing.T) { //nolint:tparallel,paralleltest // C })) }, }, - { - name: "URL with remote_servers field (valid registry JSON)", - allowPrivateIPs: true, - expectedType: RegistryTypeURL, - setupMockServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/": - // Return valid ToolHive registry JSON with remote_servers - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "remote_servers": map[string]interface{}{ - "remote-server": map[string]interface{}{ - "url": "https://remote.example.com", - }, - }, - }) - default: - // Return 404 for API endpoint and any other path - w.WriteHeader(http.StatusNotFound) - } - })) - }, - }, } for _, tt := range tests { @@ -177,72 +152,33 @@ func TestDetectRegistryType(t *testing.T) { //nolint:tparallel,paralleltest // C func TestIsValidRegistryJSON(t *testing.T) { t.Parallel() - tests := []struct { - name string - setupServer func() *httptest.Server - expectedError bool - }{ - { - name: "valid registry JSON with servers field", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "servers": map[string]interface{}{ - "test": map[string]interface{}{"image": "test:latest"}, - }, - }) - })) - }, - expectedError: false, - }, - { - name: "valid registry JSON with remote_servers field", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "remote_servers": map[string]interface{}{ - "remote": map[string]interface{}{"url": "https://example.com"}, - }, - }) - })) + upstreamWithServer := map[string]interface{}{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": map[string]interface{}{"last_updated": "2025-01-01T00:00:00Z"}, + "data": map[string]interface{}{ + "servers": []interface{}{ + map[string]interface{}{"name": "io.example.test"}, }, - expectedError: false, }, + } + + tests := []struct { + name string + setupServer func() *httptest.Server + expectedError bool + expectErrMessage string + }{ { - name: "valid registry JSON with both servers and remote_servers", + name: "valid upstream registry with servers", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "servers": map[string]interface{}{ - "test": map[string]interface{}{"image": "test:latest"}, - }, - "remote_servers": map[string]interface{}{ - "remote": map[string]interface{}{"url": "https://example.com"}, - }, - }) + json.NewEncoder(w).Encode(upstreamWithServer) })) }, expectedError: false, }, - { - name: "invalid JSON - missing registry fields", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - // Missing servers and remote_servers - }) - })) - }, - expectedError: true, - }, { name: "invalid JSON - not JSON at all", setupServer: func() *httptest.Server { @@ -263,67 +199,56 @@ func TestIsValidRegistryJSON(t *testing.T) { expectedError: true, }, { - name: "invalid structure - servers as string instead of map", + name: "upstream registry with empty servers list", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - // This would pass weak validation but fails strong type validation json.NewEncoder(w).Encode(map[string]interface{}{ + "$schema": "https://example.com/schema.json", "version": "1.0.0", - "servers": "not-a-map", + "meta": map[string]interface{}{}, + "data": map[string]interface{}{"servers": []interface{}{}}, }) })) }, expectedError: true, }, { - name: "invalid structure - remote_servers as array instead of map", + name: "valid upstream registry with skills", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - // This would pass weak validation but fails strong type validation json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "remote_servers": []string{"wrong", "type"}, - }) - })) - }, - expectedError: true, - }, - { - name: "empty registry - has fields but no servers", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "version": "1.0.0", - "servers": map[string]interface{}{}, - "remote_servers": map[string]interface{}{}, + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": map[string]interface{}{}, + "data": map[string]interface{}{ + "servers": []interface{}{}, + "skills": []map[string]interface{}{ + { + "name": "test-skill", + "description": "Test skill", + }, + }, + }, }) })) }, - expectedError: true, + expectedError: false, }, { - name: "valid registry with groups only", + name: "legacy registry returns migration hint", setupServer: func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + _, _ = w.Write([]byte(`{ "version": "1.0.0", - "groups": []map[string]interface{}{ - { - "name": "test-group", - "description": "Test group", - "servers": map[string]interface{}{ - "grouped-server": map[string]interface{}{"image": "test:latest"}, - }, - }, - }, - }) + "servers": {"test": {"image": "test:latest"}} + }`)) })) }, - expectedError: false, + expectedError: true, + expectErrMessage: "thv registry convert", }, } @@ -339,10 +264,13 @@ func TestIsValidRegistryJSON(t *testing.T) { err := isValidRegistryJSON(client, server.URL) if tt.expectedError { - assert.Error(t, err, "isValidRegistryJSON should return an error") - } else { - assert.NoError(t, err, "isValidRegistryJSON should not return an error") + require.Error(t, err, "isValidRegistryJSON should return an error") + if tt.expectErrMessage != "" { + assert.Contains(t, err.Error(), tt.expectErrMessage) + } + return } + assert.NoError(t, err, "isValidRegistryJSON should not return an error") }) } } @@ -351,9 +279,10 @@ func TestValidateRegistryFileStructure_UpstreamFormat(t *testing.T) { t.Parallel() tests := []struct { - name string - content string - expectError bool + name string + content string + expectError bool + errMsgContains string }{ { name: "valid upstream format with servers", @@ -381,15 +310,26 @@ func TestValidateRegistryFileStructure_UpstreamFormat(t *testing.T) { "meta": {"last_updated": "2025-01-01T00:00:00Z"}, "data": {"servers": []} }`, - expectError: true, + expectError: true, + errMsgContains: "no servers or skills", }, { - name: "legacy format still works", + name: "legacy format with top-level servers returns migration hint", content: `{ "version": "1.0.0", "servers": {"test": {"image": "test:latest"}} }`, - expectError: false, + expectError: true, + errMsgContains: "thv registry convert", + }, + { + name: "legacy format with top-level remote_servers returns migration hint", + content: `{ + "version": "1.0.0", + "remote_servers": {"test": {"url": "https://example.com"}} + }`, + expectError: true, + errMsgContains: "thv registry convert", }, } @@ -402,7 +342,10 @@ func TestValidateRegistryFileStructure_UpstreamFormat(t *testing.T) { err := validateRegistryFileStructure(path) if tt.expectError { - assert.Error(t, err) + require.Error(t, err) + if tt.errMsgContains != "" { + assert.Contains(t, err.Error(), tt.errMsgContains) + } } else { assert.NoError(t, err) } @@ -484,13 +427,16 @@ func TestProbeRegistryURL(t *testing.T) { //nolint:tparallel,paralleltest // Can case "/": w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ + "$schema": "https://example.com/schema.json", "version": "1.0.0", - "servers": map[string]interface{}{ - "test-server": map[string]interface{}{"image": "test:latest"}, + "meta": map[string]interface{}{}, + "data": map[string]interface{}{ + "servers": []interface{}{ + map[string]interface{}{"name": "io.example.test-server"}, + }, }, }) default: - // Return 404 for API endpoint and any other path w.WriteHeader(http.StatusNotFound) } })) diff --git a/pkg/container/docker/client.go b/pkg/container/docker/client.go index ea5ef8fe88..e85142682e 100644 --- a/pkg/container/docker/client.go +++ b/pkg/container/docker/client.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log/slog" + "net/netip" "os" "path/filepath" rt "runtime" @@ -21,14 +22,11 @@ import ( "time" "github.com/containerd/errdefs" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/api/types/mount" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" - "github.com/docker/docker/pkg/stdcopy" - "github.com/docker/go-connections/nat" - v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" "github.com/stacklok/toolhive-core/permissions" "github.com/stacklok/toolhive/pkg/container/docker/sdk" @@ -62,20 +60,31 @@ const ( // dockerAPI defines the minimal Docker client surface we need for unit-testing // ListWorkloads/GetWorkloadInfo through an adapter without requiring a live daemon. +// The signatures mirror the methods on *mobyclient.Client so the real client can +// be assigned directly to Client.api. type dockerAPI interface { - ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) - ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) - ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error - ContainerCreate( + ContainerList(ctx context.Context, options mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) + ContainerInspect( ctx context.Context, - config *container.Config, - hostConfig *container.HostConfig, - networkingConfig *network.NetworkingConfig, - platform *v1.Platform, - containerName string, - ) (container.CreateResponse, error) - ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error - ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error + containerID string, + options mobyclient.ContainerInspectOptions, + ) (mobyclient.ContainerInspectResult, error) + ContainerStop( + ctx context.Context, + containerID string, + options mobyclient.ContainerStopOptions, + ) (mobyclient.ContainerStopResult, error) + ContainerCreate(ctx context.Context, options mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) + ContainerStart( + ctx context.Context, + containerID string, + options mobyclient.ContainerStartOptions, + ) (mobyclient.ContainerStartResult, error) + ContainerRemove( + ctx context.Context, + containerID string, + options mobyclient.ContainerRemoveOptions, + ) (mobyclient.ContainerRemoveResult, error) } // deployOps defines the internal operations used by DeployWorkload. @@ -129,7 +138,7 @@ type deployOps interface { type Client struct { runtimeType runtime.Type socketPath string - client *client.Client + client *mobyclient.Client api dockerAPI imageManager images.ImageManager ops deployOps @@ -337,17 +346,17 @@ func (c *Client) DeployWorkload( // ListWorkloads lists workloads func (c *Client) ListWorkloads(ctx context.Context) ([]runtime.ContainerInfo, error) { // Create filter for toolhive containers - filterArgs := filters.NewArgs() - filterArgs.Add("label", "toolhive=true") + filterArgs := mobyclient.Filters{}.Add("label", "toolhive=true") // List containers - containers, err := c.api.ContainerList(ctx, container.ListOptions{ + listResult, err := c.api.ContainerList(ctx, mobyclient.ContainerListOptions{ All: true, Filters: filterArgs, }) if err != nil { return nil, NewContainerError(err, "", fmt.Sprintf("failed to list containers: %v", err)) } + containers := listResult.Items // Convert to our ContainerInfo format result := make([]runtime.ContainerInfo, 0, len(containers)) @@ -381,7 +390,7 @@ func (c *Client) ListWorkloads(ctx context.Context) ([]runtime.ContainerInfo, er Name: name, Image: c.Image, Status: c.Status, - State: dockerToDomainStatus(c.State), + State: dockerToDomainStatus(string(c.State)), Created: created, Labels: c.Labels, Ports: ports, @@ -411,7 +420,7 @@ func (c *Client) StopWorkload(ctx context.Context, workloadName string) error { // Use a reasonable timeout timeoutSeconds := 30 - err = c.api.ContainerStop(ctx, workloadName, container.StopOptions{Timeout: &timeoutSeconds}) + _, err = c.api.ContainerStop(ctx, workloadName, mobyclient.ContainerStopOptions{Timeout: &timeoutSeconds}) if err != nil { return NewContainerError(err, workloadName, fmt.Sprintf("failed to stop workload: %v", err)) } @@ -497,7 +506,7 @@ func (c *Client) GetWorkloadLogs(ctx context.Context, workloadName string, follo tail = fmt.Sprintf("%d", lines) } - options := container.LogsOptions{ + options := mobyclient.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, @@ -577,9 +586,9 @@ func (c *Client) GetWorkloadInfo(ctx context.Context, workloadName string) (runt } ports = append(ports, runtime.PortMapping{ - ContainerPort: containerPort.Int(), + ContainerPort: int(containerPort.Num()), HostPort: hostPort, - Protocol: containerPort.Proto(), + Protocol: string(containerPort.Proto()), }) } } @@ -599,8 +608,8 @@ func (c *Client) GetWorkloadInfo(ctx context.Context, workloadName string) (runt return runtime.ContainerInfo{ Name: strings.TrimPrefix(info.Name, "/"), Image: info.Config.Image, - Status: info.State.Status, - State: dockerToDomainStatus(info.State.Status), + Status: string(info.State.Status), + State: dockerToDomainStatus(string(info.State.Status)), Created: created, StartedAt: startedAt, Labels: info.Config.Labels, @@ -620,7 +629,7 @@ func (c *Client) AttachToWorkload(ctx context.Context, workloadName string) (io. } // Attach to workload - resp, err := c.client.ContainerAttach(ctx, workloadName, container.AttachOptions{ + resp, err := c.client.ContainerAttach(ctx, workloadName, mobyclient.ContainerAttachOptions{ Stream: true, Stdin: true, Stdout: true, @@ -655,7 +664,7 @@ func (c *Client) AttachToWorkload(ctx context.Context, workloadName string) (io. // This is used to verify that the runtime is operational and can manage workloads. func (c *Client) IsRunning(ctx context.Context) error { // Try to ping the Docker server - _, err := c.client.Ping(ctx) + _, err := c.client.Ping(ctx, mobyclient.PingOptions{}) if err != nil { return fmt.Errorf("failed to ping Docker server: %w", err) } @@ -865,18 +874,16 @@ func (c *Client) getPermissionConfigFromProfile( // Uses container runtime's name filter for efficiency but then verifies exact match to prevent partial matching func (c *Client) findExistingContainer(ctx context.Context, name string) (string, error) { // Use name filter to narrow results, then verify exact match - containers, err := c.api.ContainerList(ctx, container.ListOptions{ - All: true, // Include stopped containers - Filters: filters.NewArgs( - filters.Arg("name", name), - ), + listResult, err := c.api.ContainerList(ctx, mobyclient.ContainerListOptions{ + All: true, // Include stopped containers + Filters: mobyclient.Filters{}.Add("name", name), }) if err != nil { return "", NewContainerError(err, "", fmt.Sprintf("failed to list containers: %v", err)) } // Verify exact name match from the filtered results (name filter does partial matching) - for _, cont := range containers { + for _, cont := range listResult.Items { for _, containerName := range cont.Names { // Container names in the API have a leading slash if containerName == "/"+name || containerName == name { @@ -1108,10 +1115,11 @@ func (c *Client) handleExistingContainer( desiredHostConfig *container.HostConfig, ) (bool, error) { // Get container info - info, err := c.api.ContainerInspect(ctx, containerID) + inspectResult, err := c.api.ContainerInspect(ctx, containerID, mobyclient.ContainerInspectOptions{}) if err != nil { return false, NewContainerError(err, containerID, fmt.Sprintf("failed to inspect container: %v", err)) } + info := inspectResult.Container // Compare configurations if compareContainerConfig(&info, desiredConfig, desiredHostConfig) { @@ -1120,7 +1128,7 @@ func (c *Client) handleExistingContainer( // Check if the container is running if !info.State.Running { // Container exists but is not running, start it - err = c.api.ContainerStart(ctx, containerID, container.StartOptions{}) + _, err = c.api.ContainerStart(ctx, containerID, mobyclient.ContainerStartOptions{}) if err != nil { return false, NewContainerError(err, containerID, fmt.Sprintf("failed to start existing container: %v", err)) } @@ -1149,21 +1157,21 @@ func (c *Client) createNetwork( ) error { // Check if the network already exists // Use name filter for efficiency but verify exact match to avoid partial matching - networks, err := c.client.NetworkList(ctx, network.ListOptions{ - Filters: filters.NewArgs(filters.Arg("name", name)), + networks, err := c.client.NetworkList(ctx, mobyclient.NetworkListOptions{ + Filters: mobyclient.Filters{}.Add("name", name), }) if err != nil { return fmt.Errorf("failed to list networks: %w", err) } // Verify exact name match from filtered results - for _, n := range networks { + for _, n := range networks.Items { if n.Name == name { // Network already exists return nil } } - networkCreate := network.CreateOptions{ + networkCreate := mobyclient.NetworkCreateOptions{ Driver: "bridge", Internal: internal, Labels: labels, @@ -1183,14 +1191,14 @@ func (c *Client) createNetwork( // the daemon directly is more accurate than hardcoding platform-specific IPs. // Falls back to dockerDefaultBridgeGatewayIP on any error. func (c *Client) getDockerBridgeGatewayIP(ctx context.Context) string { - nr, err := c.client.NetworkInspect(ctx, "bridge", network.InspectOptions{}) + nr, err := c.client.NetworkInspect(ctx, "bridge", mobyclient.NetworkInspectOptions{}) if err != nil { slog.Debug("failed to inspect bridge network, using default gateway IP", "error", err) return dockerDefaultBridgeGatewayIP } - for _, cfg := range nr.IPAM.Config { - if cfg.Gateway != "" { - return cfg.Gateway + for _, cfg := range nr.Network.IPAM.Config { + if cfg.Gateway.IsValid() { + return cfg.Gateway.String() } } slog.Debug("bridge network has no gateway in IPAM config, using default gateway IP") @@ -1200,8 +1208,8 @@ func (c *Client) getDockerBridgeGatewayIP(ctx context.Context) string { // DeleteNetwork deletes a network by name. func (c *Client) deleteNetwork(ctx context.Context, name string) error { // find the network by name using filter for efficiency but verify exact match - networks, err := c.client.NetworkList(ctx, network.ListOptions{ - Filters: filters.NewArgs(filters.Arg("name", name)), + networks, err := c.client.NetworkList(ctx, mobyclient.NetworkListOptions{ + Filters: mobyclient.Filters{}.Add("name", name), }) if err != nil { return err @@ -1209,7 +1217,7 @@ func (c *Client) deleteNetwork(ctx context.Context, name string) error { // Verify exact name match from filtered results var networkToRemove *network.Summary - for _, n := range networks { + for _, n := range networks.Items { if n.Name == name { networkToRemove = &n break @@ -1222,7 +1230,7 @@ func (c *Client) deleteNetwork(ctx context.Context, name string) error { return nil } - if err := c.client.NetworkRemove(ctx, networkToRemove.ID); err != nil { + if _, err := c.client.NetworkRemove(ctx, networkToRemove.ID, mobyclient.NetworkRemoveOptions{}); err != nil { return fmt.Errorf("failed to remove network %s: %w", name, err) } return nil @@ -1230,7 +1238,7 @@ func (c *Client) deleteNetwork(ctx context.Context, name string) error { // removeContainer removes a container by ID, without removing any associated networks or proxy containers. func (c *Client) removeContainer(ctx context.Context, containerID string) error { - err := c.api.ContainerRemove(ctx, containerID, container.RemoveOptions{ + _, err := c.api.ContainerRemove(ctx, containerID, mobyclient.ContainerRemoveOptions{ Force: true, }) if err != nil { @@ -1276,7 +1284,7 @@ func (c *Client) removeProxyContainers( continue } - err = c.client.ContainerRemove(ctx, containerId, container.RemoveOptions{ + _, err = c.client.ContainerRemove(ctx, containerId, mobyclient.ContainerRemoveOptions{ Force: true, }) if err != nil { @@ -1322,13 +1330,13 @@ func setupExposedPorts(config *container.Config, exposedPorts map[string]struct{ return nil } - config.ExposedPorts = nat.PortSet{} + config.ExposedPorts = network.PortSet{} for port := range exposedPorts { - natPort, err := nat.NewPort("tcp", strings.Split(port, "/")[0]) + networkPort, err := network.ParsePort(strings.Split(port, "/")[0]) if err != nil { return fmt.Errorf("failed to parse port: %w", err) } - config.ExposedPorts[natPort] = struct{}{} + config.ExposedPorts[networkPort] = struct{}{} } return nil @@ -1340,26 +1348,40 @@ func setupPortBindings(hostConfig *container.HostConfig, portBindings map[string return nil } - hostConfig.PortBindings = nat.PortMap{} + hostConfig.PortBindings = network.PortMap{} for port, bindings := range portBindings { - natPort, err := nat.NewPort("tcp", strings.Split(port, "/")[0]) + networkPort, err := network.ParsePort(strings.Split(port, "/")[0]) if err != nil { return fmt.Errorf("failed to parse port: %w", err) } - natBindings := make([]nat.PortBinding, len(bindings)) + networkBindings := make([]network.PortBinding, len(bindings)) for i, binding := range bindings { - natBindings[i] = nat.PortBinding{ - HostIP: binding.HostIP, + hostIP, err := parseHostIP(binding.HostIP) + if err != nil { + return fmt.Errorf("failed to parse host IP %q: %w", binding.HostIP, err) + } + networkBindings[i] = network.PortBinding{ + HostIP: hostIP, HostPort: binding.HostPort, } } - hostConfig.PortBindings[natPort] = natBindings + hostConfig.PortBindings[networkPort] = networkBindings } return nil } +// parseHostIP converts a host IP string into a netip.Addr. An empty string +// (meaning "all interfaces") maps to the zero netip.Addr, preserving the +// previous behavior where an empty HostIP string was passed through verbatim. +func parseHostIP(hostIP string) (netip.Addr, error) { + if hostIP == "" { + return netip.Addr{}, nil + } + return netip.ParseAddr(hostIP) +} + func (c *Client) createContainer( ctx context.Context, containerName string, @@ -1392,14 +1414,13 @@ func (c *Client) createContainer( } // Create the container - resp, err := c.api.ContainerCreate( - ctx, - config, - hostConfig, - networkConfig, - nil, - containerName, - ) + resp, err := c.api.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{ + Config: config, + HostConfig: hostConfig, + NetworkingConfig: networkConfig, + Platform: nil, + Name: containerName, + }) if err != nil { return "", NewContainerError(err, "", fmt.Sprintf("failed to create container: %v", err)) } @@ -1409,7 +1430,7 @@ func (c *Client) createContainer( } // Start the container - err = c.api.ContainerStart(ctx, resp.ID, container.StartOptions{}) + _, err = c.api.ContainerStart(ctx, resp.ID, mobyclient.ContainerStartOptions{}) if err != nil { return "", NewContainerError(err, resp.ID, fmt.Sprintf("failed to start container: %v", err)) } @@ -1465,16 +1486,21 @@ func (c *Client) createDnsContainer(ctx context.Context, dnsContainerName string return "", "", fmt.Errorf("failed to create dns container: %w", err) } - dnsContainerResponse, err := c.client.ContainerInspect(ctx, dnsContainerId) + dnsInspectResult, err := c.client.ContainerInspect(ctx, dnsContainerId, mobyclient.ContainerInspectOptions{}) if err != nil { return "", "", fmt.Errorf("failed to inspect DNS container: %w", err) } - dnsNetworkSettings, ok := dnsContainerResponse.NetworkSettings.Networks[networkName] + dnsNetworkSettings, ok := dnsInspectResult.Container.NetworkSettings.Networks[networkName] if !ok { return "", "", fmt.Errorf("network %s not found in container's network settings", networkName) } - dnsContainerIP := dnsNetworkSettings.IPAddress + // EndpointSettings.IPAddress is a netip.Addr; render it back to the string + // form the callers expect, treating the zero value as an empty address. + dnsContainerIP := "" + if dnsNetworkSettings.IPAddress.IsValid() { + dnsContainerIP = dnsNetworkSettings.IPAddress.String() + } return dnsContainerId, dnsContainerIP, nil } @@ -1520,7 +1546,11 @@ func (c *Client) createMcpContainer( }, } if additionalDNS != "" { - hostConfig.DNS = []string{additionalDNS} + dnsAddr, err := netip.ParseAddr(additionalDNS) + if err != nil { + return NewContainerError(err, "", fmt.Sprintf("invalid additional DNS address %q: %v", additionalDNS, err)) + } + hostConfig.DNS = []netip.Addr{dnsAddr} } // Configure ports if options are provided @@ -1699,16 +1729,16 @@ func (c *Client) stopProxyContainer(ctx context.Context, containerName string, t if containerId == "" { return } - if err := c.api.ContainerStop(ctx, containerId, container.StopOptions{Timeout: &timeoutSeconds}); err != nil { + if _, err := c.api.ContainerStop(ctx, containerId, mobyclient.ContainerStopOptions{Timeout: &timeoutSeconds}); err != nil { slog.Debug("failed to stop internal container", "name", containerName, "error", err) } } func (c *Client) deleteNetworks(ctx context.Context, containerName string) error { // Delete networks if there are no containers using them. - toolHiveContainers, err := c.client.ContainerList(ctx, container.ListOptions{ + toolHiveContainers, err := c.client.ContainerList(ctx, mobyclient.ContainerListOptions{ All: true, - Filters: filters.NewArgs(filters.Arg("label", "toolhive=true")), + Filters: mobyclient.Filters{}.Add("label", "toolhive=true"), }) if err != nil { return fmt.Errorf("failed to list containers: %w", err) @@ -1721,7 +1751,7 @@ func (c *Client) deleteNetworks(ctx context.Context, containerName string) error slog.Warn("failed to delete network", "name", networkName, "error", err) } - if len(toolHiveContainers) == 0 { + if len(toolHiveContainers.Items) == 0 { // remove external network if err := c.deleteNetwork(ctx, "toolhive-external"); err != nil { // just log the error and continue @@ -1750,17 +1780,18 @@ func dockerToDomainStatus(status string) runtime.WorkloadStatus { // findContainerByLabel finds a container by the base name label. // Returns the container ID if found, empty string otherwise. func (c *Client) findContainerByLabel(ctx context.Context, workloadName string) (string, error) { - filterArgs := filters.NewArgs() - filterArgs.Add("label", "toolhive=true") - filterArgs.Add("label", fmt.Sprintf("toolhive-basename=%s", workloadName)) + filterArgs := mobyclient.Filters{}. + Add("label", "toolhive=true"). + Add("label", fmt.Sprintf("toolhive-basename=%s", workloadName)) - containers, err := c.api.ContainerList(ctx, container.ListOptions{ + listResult, err := c.api.ContainerList(ctx, mobyclient.ContainerListOptions{ All: true, Filters: filterArgs, }) if err != nil { return "", NewContainerError(err, "", fmt.Sprintf("failed to list containers: %v", err)) } + containers := listResult.Items if len(containers) == 0 { return "", nil @@ -1786,17 +1817,18 @@ func (c *Client) findContainerByLabel(ctx context.Context, workloadName string) // Returns the container ID if found, empty string otherwise. // Uses container runtime's name filter for efficiency but then verifies exact match to prevent partial matching func (c *Client) findContainerByExactName(ctx context.Context, workloadName string) (string, error) { - filterArgs := filters.NewArgs() - filterArgs.Add("label", "toolhive=true") - filterArgs.Add("name", workloadName) // Use name filter for efficiency + filterArgs := mobyclient.Filters{}. + Add("label", "toolhive=true"). + Add("name", workloadName) // Use name filter for efficiency - containers, err := c.api.ContainerList(ctx, container.ListOptions{ + listResult, err := c.api.ContainerList(ctx, mobyclient.ContainerListOptions{ All: true, Filters: filterArgs, }) if err != nil { return "", NewContainerError(err, "", fmt.Sprintf("failed to list containers: %v", err)) } + containers := listResult.Items if len(containers) == 0 { return "", nil @@ -1826,7 +1858,8 @@ func (c *Client) inspectContainerByName(ctx context.Context, workloadName string return empty, err } if containerID != "" { - return c.api.ContainerInspect(ctx, containerID) + result, err := c.api.ContainerInspect(ctx, containerID, mobyclient.ContainerInspectOptions{}) + return result.Container, err } // Fall back to exact name matching for backward compatibility @@ -1838,5 +1871,6 @@ func (c *Client) inspectContainerByName(ctx context.Context, workloadName string return empty, NewContainerError(runtime.ErrWorkloadNotFound, workloadName, "no containers found") } - return c.api.ContainerInspect(ctx, containerID) + result, err := c.api.ContainerInspect(ctx, containerID, mobyclient.ContainerInspectOptions{}) + return result.Container, err } diff --git a/pkg/container/docker/client_config_test.go b/pkg/container/docker/client_config_test.go index 3de902040d..629780caa8 100644 --- a/pkg/container/docker/client_config_test.go +++ b/pkg/container/docker/client_config_test.go @@ -4,11 +4,12 @@ package docker import ( + "net/netip" "testing" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" - "github.com/docker/go-connections/nat" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,9 +29,9 @@ func TestSetupExposedPorts_SetsPorts(t *testing.T) { require.NoError(t, err) require.NotNil(t, cfg.ExposedPorts) - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) - p9090, err := nat.NewPort("tcp", "9090") + p9090, err := network.ParsePort("9090") require.NoError(t, err) assert.Contains(t, cfg.ExposedPorts, p8080) @@ -63,14 +64,14 @@ func TestSetupPortBindings_SetsBindings(t *testing.T) { require.NoError(t, err) require.NotNil(t, hostCfg.PortBindings) - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) got, ok := hostCfg.PortBindings[p8080] require.True(t, ok) require.Len(t, got, 2) - assert.Equal(t, nat.PortBinding{HostIP: "127.0.0.1", HostPort: "8081"}, got[0]) - assert.Equal(t, nat.PortBinding{HostIP: "", HostPort: "8082"}, got[1]) + assert.Equal(t, network.PortBinding{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "8081"}, got[0]) + assert.Equal(t, network.PortBinding{HostIP: netip.Addr{}, HostPort: "8082"}, got[1]) } func TestConvertMounts_BindMounts(t *testing.T) { @@ -123,15 +124,13 @@ func TestCompareHostConfig_EqualAndMismatch(t *testing.T) { t.Parallel() existing := &container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{ - NetworkMode: "bridge", - CapAdd: []string{"CAP_A"}, - CapDrop: []string{"ALL"}, - SecurityOpt: []string{"seccomp:unconfined"}, - Privileged: false, - RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, - }, + HostConfig: &container.HostConfig{ + NetworkMode: "bridge", + CapAdd: []string{"CAP_A"}, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"seccomp:unconfined"}, + Privileged: false, + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, }, } @@ -164,18 +163,16 @@ func TestComparePortConfig_EqualAndMismatch(t *testing.T) { })) // Build existing to match desired - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) existing := &container.InspectResponse{ Config: &container.Config{ - ExposedPorts: nat.PortSet{p8080: {}}, + ExposedPorts: network.PortSet{p8080: {}}, }, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{ - PortBindings: nat.PortMap{ - p8080: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "18080"}}, - }, + HostConfig: &container.HostConfig{ + PortBindings: network.PortMap{ + p8080: []network.PortBinding{{HostIP: netip.MustParseAddr("0.0.0.0"), HostPort: "18080"}}, }, }, } @@ -183,7 +180,7 @@ func TestComparePortConfig_EqualAndMismatch(t *testing.T) { assert.True(t, comparePortConfig(existing, desiredCfg, desiredHost)) // Mismatch: different host port - existing.HostConfig.PortBindings[p8080] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "18081"}} + existing.HostConfig.PortBindings[p8080] = []network.PortBinding{{HostIP: netip.MustParseAddr("0.0.0.0"), HostPort: "18081"}} assert.False(t, comparePortConfig(existing, desiredCfg, desiredHost)) } @@ -222,7 +219,7 @@ func TestCompareContainerConfig_AllMatch(t *testing.T) { })) // Existing configuration (must be a superset for env vars) - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) existing := &container.InspectResponse{ @@ -236,23 +233,21 @@ func TestCompareContainerConfig_AllMatch(t *testing.T) { AttachStderr: true, OpenStdin: true, Tty: false, - ExposedPorts: nat.PortSet{p8080: {}}, + ExposedPorts: network.PortSet{p8080: {}}, }, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{ - NetworkMode: "bridge", - CapAdd: []string{"NET_BIND_SERVICE"}, - CapDrop: []string{"ALL"}, - SecurityOpt: []string{"seccomp:unconfined"}, - Privileged: false, - RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, - Mounts: []mount.Mount{ - {Type: mount.TypeBind, Source: "/src1", Target: "/dst1", ReadOnly: true}, - {Type: mount.TypeBind, Source: "/src2", Target: "/dst2", ReadOnly: false}, - }, - PortBindings: nat.PortMap{ - p8080: []nat.PortBinding{{HostIP: "", HostPort: "18080"}}, - }, + HostConfig: &container.HostConfig{ + NetworkMode: "bridge", + CapAdd: []string{"NET_BIND_SERVICE"}, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"seccomp:unconfined"}, + Privileged: false, + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + Mounts: []mount.Mount{ + {Type: mount.TypeBind, Source: "/src1", Target: "/dst1", ReadOnly: true}, + {Type: mount.TypeBind, Source: "/src2", Target: "/dst2", ReadOnly: false}, + }, + PortBindings: network.PortMap{ + p8080: []network.PortBinding{{HostIP: netip.Addr{}, HostPort: "18080"}}, }, }, } diff --git a/pkg/container/docker/client_create_test.go b/pkg/container/docker/client_create_test.go index fad0e680b9..3d7515b43c 100644 --- a/pkg/container/docker/client_create_test.go +++ b/pkg/container/docker/client_create_test.go @@ -6,11 +6,12 @@ package docker import ( "context" "fmt" + "net/netip" "testing" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/go-connections/nat" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +39,7 @@ func TestCreateMcpContainer_Isolated_WiresConfigAndNetworks(t *testing.T) { assert.Equal(t, "app", name) return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, id string, _ container.StartOptions) error { + startFunc: func(_ context.Context, id string, _ mobyclient.ContainerStartOptions) error { startCalled = true assert.Equal(t, "cid-new", id) return nil @@ -98,7 +99,7 @@ func TestCreateMcpContainer_Isolated_WiresConfigAndNetworks(t *testing.T) { assert.Equal(t, labels, gotConfig.Labels) // Exposed ports set - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) require.Contains(t, gotConfig.ExposedPorts, p8080) @@ -109,12 +110,12 @@ func TestCreateMcpContainer_Isolated_WiresConfigAndNetworks(t *testing.T) { assert.Equal(t, []string{"ALL"}, []string(gotHost.CapDrop)) assert.Equal(t, []string{"seccomp:unconfined"}, gotHost.SecurityOpt) assert.Equal(t, false, gotHost.Privileged) - assert.Equal(t, []string{"1.2.3.4"}, gotHost.DNS) + assert.Equal(t, []netip.Addr{netip.MustParseAddr("1.2.3.4")}, gotHost.DNS) // Port bindings wired require.Contains(t, gotHost.PortBindings, p8080) require.Len(t, gotHost.PortBindings[p8080], 1) - assert.Equal(t, "127.0.0.1", gotHost.PortBindings[p8080][0].HostIP) + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), gotHost.PortBindings[p8080][0].HostIP) assert.Equal(t, "18080", gotHost.PortBindings[p8080][0].HostPort) // Networking config points to internal network when isolated @@ -134,7 +135,7 @@ func TestCreateMcpContainer_NonIsolated_UsesExternalNetwork(t *testing.T) { gotNet = netCfg return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { return nil }, } @@ -171,7 +172,7 @@ func TestCreateContainer_CreateAndStart_New(t *testing.T) { var started bool api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { // No existing container return []container.Summary{}, nil }, @@ -181,7 +182,7 @@ func TestCreateContainer_CreateAndStart_New(t *testing.T) { gotNet = netCfg return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, id string, _ container.StartOptions) error { + startFunc: func(_ context.Context, id string, _ mobyclient.ContainerStartOptions) error { started = true assert.Equal(t, "cid-new", id) return nil @@ -218,7 +219,7 @@ func TestCreateContainer_ReuseExisting_WhenConfigMatchesAndStartIfStopped(t *tes api := &fakeDockerAPI{ // findExistingContainer will call ContainerList filtering by name - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ {ID: "cid-reuse", Names: []string{"/reuse"}}, }, nil @@ -227,18 +228,16 @@ func TestCreateContainer_ReuseExisting_WhenConfigMatchesAndStartIfStopped(t *tes require.Equal(t, "cid-reuse", id) // Existing matches desired (all zero-values) and is not running return container.InspectResponse{ - Config: &container.Config{}, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{}, - State: &container.State{Status: "exited", Running: false}, - }, + Config: &container.Config{}, + HostConfig: &container.HostConfig{}, + State: &container.State{Status: "exited", Running: false}, }, nil }, createFunc: func(_ context.Context, _ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { createCalled = true return container.CreateResponse{ID: "cid-should-not"}, nil }, - startFunc: func(_ context.Context, id string, _ container.StartOptions) error { + startFunc: func(_ context.Context, id string, _ mobyclient.ContainerStartOptions) error { startCalled = true assert.Equal(t, "cid-reuse", id) return nil @@ -266,7 +265,7 @@ func TestCreateContainer_Mismatch_RemovesAndRecreates(t *testing.T) { hcfg := &container.HostConfig{} api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ {ID: "cid-old", Names: []string{"/app"}}, }, nil @@ -275,14 +274,12 @@ func TestCreateContainer_Mismatch_RemovesAndRecreates(t *testing.T) { require.Equal(t, "cid-old", id) // Existing image different -> mismatch path return container.InspectResponse{ - Config: &container.Config{Image: "different"}, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{}, - State: &container.State{Status: "running", Running: true}, - }, + Config: &container.Config{Image: "different"}, + HostConfig: &container.HostConfig{}, + State: &container.State{Status: "running", Running: true}, }, nil }, - removeFunc: func(_ context.Context, id string, _ container.RemoveOptions) error { + removeFunc: func(_ context.Context, id string, _ mobyclient.ContainerRemoveOptions) error { removedID = id return nil }, @@ -290,7 +287,7 @@ func TestCreateContainer_Mismatch_RemovesAndRecreates(t *testing.T) { created = true return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, id string, _ container.StartOptions) error { + startFunc: func(_ context.Context, id string, _ mobyclient.ContainerStartOptions) error { started = true assert.Equal(t, "cid-new", id) return nil @@ -316,7 +313,7 @@ func TestCreateMcpContainer_InvalidExposedPort_ReturnsError(t *testing.T) { t.Fatalf("ContainerCreate should not be called when exposed ports are invalid") return container.CreateResponse{}, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { t.Fatalf("ContainerStart should not be called when exposed ports are invalid") return nil }, @@ -356,7 +353,7 @@ func TestCreateMcpContainer_InvalidPortBinding_ReturnsError(t *testing.T) { t.Fatalf("ContainerCreate should not be called when port bindings are invalid") return container.CreateResponse{}, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { t.Fatalf("ContainerStart should not be called when port bindings are invalid") return nil }, @@ -400,7 +397,7 @@ func TestCreateMcpContainer_NoAdditionalDNS_DNSNotSet(t *testing.T) { gotHost = host return container.CreateResponse{ID: "cid-dns"}, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { return nil }, } @@ -432,7 +429,7 @@ func TestCreateContainer_ListError_Propagates(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return nil, fmt.Errorf("list fail") }, } @@ -448,7 +445,7 @@ func TestCreateContainer_InspectError_Propagates(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ {ID: "cid1", Names: []string{"/x"}}, }, nil @@ -469,21 +466,19 @@ func TestCreateContainer_StartExistingError_Wrapped(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ {ID: "cid-exist", Names: []string{"/svc"}}, }, nil }, inspectFunc: func(_ context.Context, _ string) (container.InspectResponse, error) { return container.InspectResponse{ - Config: &container.Config{}, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{}, - State: &container.State{Status: "exited", Running: false}, - }, + Config: &container.Config{}, + HostConfig: &container.HostConfig{}, + State: &container.State{Status: "exited", Running: false}, }, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { return fmt.Errorf("start fail") }, } @@ -500,7 +495,7 @@ func TestCreateContainer_CreateError_Wrapped(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{}, nil }, createFunc: func(_ context.Context, _ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { @@ -520,13 +515,13 @@ func TestCreateContainer_StartError_Wrapped(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{}, nil }, createFunc: func(_ context.Context, _ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, _ string, _ container.StartOptions) error { + startFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStartOptions) error { return fmt.Errorf("start fail") }, } @@ -543,7 +538,7 @@ func TestCreateContainer_RemoveError_Propagates(t *testing.T) { ctx := t.Context() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ {ID: "cid-old", Names: []string{"/svc"}}, }, nil @@ -551,14 +546,12 @@ func TestCreateContainer_RemoveError_Propagates(t *testing.T) { inspectFunc: func(_ context.Context, _ string) (container.InspectResponse, error) { // Mismatch to force recreation return container.InspectResponse{ - Config: &container.Config{Image: "different"}, - ContainerJSONBase: &container.ContainerJSONBase{ - HostConfig: &container.HostConfig{}, - State: &container.State{Status: "running", Running: true}, - }, + Config: &container.Config{Image: "different"}, + HostConfig: &container.HostConfig{}, + State: &container.State{Status: "running", Running: true}, }, nil }, - removeFunc: func(_ context.Context, id string, _ container.RemoveOptions) error { + removeFunc: func(_ context.Context, id string, _ mobyclient.ContainerRemoveOptions) error { return fmt.Errorf("remove fail: %s", id) }, } diff --git a/pkg/container/docker/client_deploy_test.go b/pkg/container/docker/client_deploy_test.go index 271ab4645d..592bf51d7e 100644 --- a/pkg/container/docker/client_deploy_test.go +++ b/pkg/container/docker/client_deploy_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - "github.com/docker/docker/api/types/network" + "github.com/moby/moby/api/types/network" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/container/docker/client_info_test.go b/pkg/container/docker/client_info_test.go index 2a52503528..8f5eb331f5 100644 --- a/pkg/container/docker/client_info_test.go +++ b/pkg/container/docker/client_info_test.go @@ -5,11 +5,13 @@ package docker import ( "context" + "net/netip" "testing" "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +26,7 @@ func TestGetWorkloadInfo_MapsInspectResponseToDomain(t *testing.T) { call := 0 api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { call++ if call == 1 { // First call: find by base name label -> return empty to force fallback @@ -42,20 +44,18 @@ func TestGetWorkloadInfo_MapsInspectResponseToDomain(t *testing.T) { }, inspectFunc: func(_ context.Context, id string) (container.InspectResponse, error) { require.Equal(t, "cid-123", id) - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) ns := &container.NetworkSettings{} - ns.Ports = nat.PortMap{ - p8080: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "18080"}}, + ns.Ports = network.PortMap{ + p8080: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "18080"}}, } return container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - Name: "/mcp", - Created: createdStr, - State: &container.State{Status: "running", Running: true}, - }, + Name: "/mcp", + Created: createdStr, + State: &container.State{Status: "running", Running: true}, Config: &container.Config{ Image: "ghcr.io/example/mcp:latest", Labels: map[string]string{"toolhive": "true", "k": "v"}, @@ -86,7 +86,7 @@ func TestIsWorkloadRunning_TrueWhenDockerReportsRunning(t *testing.T) { call := 0 api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { call++ if call == 1 { // First call: base name lookup -> not found @@ -106,13 +106,11 @@ func TestIsWorkloadRunning_TrueWhenDockerReportsRunning(t *testing.T) { require.Equal(t, "cid-xyz", id) ns := &container.NetworkSettings{} - ns.Ports = nat.PortMap{} + ns.Ports = network.PortMap{} return container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - Name: "/server", - State: &container.State{Status: "running", Running: true}, - }, + Name: "/server", + State: &container.State{Status: "running", Running: true}, Config: &container.Config{ Image: "img", }, @@ -134,7 +132,7 @@ func TestGetWorkloadInfo_PortParseAndCreatedFallback(t *testing.T) { call := 0 api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { call++ if call == 1 { // First attempt (label-based) -> none found @@ -154,19 +152,17 @@ func TestGetWorkloadInfo_PortParseAndCreatedFallback(t *testing.T) { require.Equal(t, "cid-badfields", id) // Non-numeric host port; GetWorkloadInfo should log a warning and fall back to 0 - p8080, err := nat.NewPort("tcp", "8080") + p8080, err := network.ParsePort("8080") require.NoError(t, err) ns := &container.NetworkSettings{} - ns.Ports = nat.PortMap{ - p8080: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "abc"}}, + ns.Ports = network.PortMap{ + p8080: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "abc"}}, } return container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - Name: "/svc-bad", - State: &container.State{Status: "exited", Running: false}, - Created: "not-a-time", // invalid RFC3339 -> Created should be zero time - }, + Name: "/svc-bad", + State: &container.State{Status: "exited", Running: false}, + Created: "not-a-time", // invalid RFC3339 -> Created should be zero time Config: &container.Config{Image: "img", Labels: map[string]string{"toolhive": "true"}}, NetworkSettings: ns, }, nil diff --git a/pkg/container/docker/client_list_test.go b/pkg/container/docker/client_list_test.go index ecc5da869e..1cff90e9cb 100644 --- a/pkg/container/docker/client_list_test.go +++ b/pkg/container/docker/client_list_test.go @@ -8,7 +8,8 @@ import ( "testing" "time" - "github.com/docker/docker/api/types/container" + "github.com/moby/moby/api/types/container" + mobyclient "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,7 +22,7 @@ func TestListWorkloads_FiltersAuxiliaryAndMapsFields(t *testing.T) { created := time.Now().Add(-1 * time.Hour).Unix() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { return []container.Summary{ { ID: "aux1", @@ -30,7 +31,7 @@ func TestListWorkloads_FiltersAuxiliaryAndMapsFields(t *testing.T) { State: "running", Names: []string{"/aux-name"}, Labels: map[string]string{ToolhiveAuxiliaryWorkloadLabel: LabelValueTrue, "toolhive": "true"}, - Ports: []container.Port{{PrivatePort: 3128, PublicPort: 0, Type: "tcp"}}, + Ports: []container.PortSummary{{PrivatePort: 3128, PublicPort: 0, Type: "tcp"}}, Created: created, }, { @@ -40,7 +41,7 @@ func TestListWorkloads_FiltersAuxiliaryAndMapsFields(t *testing.T) { State: "running", Names: []string{"/mcp-name"}, Labels: map[string]string{"toolhive": "true", "custom": "x"}, - Ports: []container.Port{{PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}}, + Ports: []container.PortSummary{{PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}}, Created: created, }, }, nil diff --git a/pkg/container/docker/client_partial_match_test.go b/pkg/container/docker/client_partial_match_test.go index e3473a4987..2c9c26b453 100644 --- a/pkg/container/docker/client_partial_match_test.go +++ b/pkg/container/docker/client_partial_match_test.go @@ -7,7 +7,8 @@ import ( "context" "testing" - "github.com/docker/docker/api/types/container" + "github.com/moby/moby/api/types/container" + mobyclient "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -94,9 +95,9 @@ func TestFindExistingContainer_RejectsPartialMatches(t *testing.T) { t.Parallel() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, opts container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, opts mobyclient.ContainerListOptions) ([]container.Summary, error) { // Verify that the name filter is being used - nameFilters := opts.Filters.Get("name") + nameFilters := filterValues(opts.Filters, "name") if len(nameFilters) > 0 { assert.Equal(t, tt.searchName, nameFilters[0], "Expected name filter to be set correctly") } @@ -214,10 +215,10 @@ func TestFindContainerByExactName_RejectsPartialMatches(t *testing.T) { t.Parallel() api := &fakeDockerAPI{ - listFunc: func(_ context.Context, opts container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, opts mobyclient.ContainerListOptions) ([]container.Summary, error) { // Verify that both toolhive label filter and name filter are being used - toolhiveFilter := opts.Filters.Get("label") - nameFilter := opts.Filters.Get("name") + toolhiveFilter := filterValues(opts.Filters, "label") + nameFilter := filterValues(opts.Filters, "name") assert.Contains(t, toolhiveFilter, "toolhive=true", "Expected toolhive label filter") assert.Contains(t, nameFilter, tt.searchName, "Expected name filter to be set") @@ -258,7 +259,7 @@ func TestPartialMatchingPrevention_IntegrationScenarios(t *testing.T) { } api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { // Simulate that container runtime returned all containers due to partial matching return mockContainers, nil }, @@ -281,3 +282,14 @@ func TestPartialMatchingPrevention_IntegrationScenarios(t *testing.T) { require.NoError(t, err) assert.Empty(t, containerID, "Should not find anything for non-existent exact name") } + +// filterValues returns the set of values associated with a filter term in the +// redesigned client.Filters (map[string]map[string]bool), giving tests the same +// view the legacy filters.Args.Get helper used to provide. +func filterValues(f mobyclient.Filters, term string) []string { + values := make([]string, 0, len(f[term])) + for value := range f[term] { + values = append(values, value) + } + return values +} diff --git a/pkg/container/docker/client_stop_test.go b/pkg/container/docker/client_stop_test.go index 8e58780a33..41a42dd46b 100644 --- a/pkg/container/docker/client_stop_test.go +++ b/pkg/container/docker/client_stop_test.go @@ -8,8 +8,9 @@ import ( "testing" "github.com/containerd/errdefs" - "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,7 +21,7 @@ func TestStopWorkload_NotRunning_ReturnsNil(t *testing.T) { // Arrange: find by exact name and inspect -> not running call := 0 api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { call++ if call == 1 { // First call: base-name label lookup -> none found @@ -40,19 +41,17 @@ func TestStopWorkload_NotRunning_ReturnsNil(t *testing.T) { require.Equal(t, "cid-not-running", id) // Not running ns := &container.NetworkSettings{} - ns.Ports = nat.PortMap{} + ns.Ports = network.PortMap{} return container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - Name: "/svc", - State: &container.State{Status: "exited", Running: false}, - }, + Name: "/svc", + State: &container.State{Status: "exited", Running: false}, Config: &container.Config{Image: "img", Labels: map[string]string{"toolhive": "true"}}, NetworkSettings: ns, ImageManifestDescriptor: nil, }, nil }, // stopFunc should not be called - stopFunc: func(_ context.Context, _ string, _ container.StopOptions) error { + stopFunc: func(_ context.Context, _ string, _ mobyclient.ContainerStopOptions) error { t.Fatalf("ContainerStop should not be called for not-running container") return nil }, @@ -72,7 +71,7 @@ func TestStopWorkload_Running_CallsContainerStop(t *testing.T) { called := false call := 0 api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { call++ if call == 1 { return []container.Summary{}, nil @@ -89,17 +88,15 @@ func TestStopWorkload_Running_CallsContainerStop(t *testing.T) { inspectFunc: func(_ context.Context, id string) (container.InspectResponse, error) { require.Equal(t, "cid-running", id) ns := &container.NetworkSettings{} - ns.Ports = nat.PortMap{} + ns.Ports = network.PortMap{} return container.InspectResponse{ - ContainerJSONBase: &container.ContainerJSONBase{ - Name: "/app", - State: &container.State{Status: "running", Running: true}, - }, + Name: "/app", + State: &container.State{Status: "running", Running: true}, Config: &container.Config{Image: "img", Labels: map[string]string{"toolhive": "true"}}, NetworkSettings: ns, }, nil }, - stopFunc: func(_ context.Context, id string, _ container.StopOptions) error { + stopFunc: func(_ context.Context, id string, _ mobyclient.ContainerStopOptions) error { // The implementation stops by workloadName (not ID), verify that assert.Equal(t, "app", id) called = true @@ -118,7 +115,7 @@ func TestStopWorkload_NotFound_ReturnsNil(t *testing.T) { // Simulate a case where a container appears in listing, but inspect returns NotFound api := &fakeDockerAPI{ - listFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + listFunc: func(_ context.Context, _ mobyclient.ContainerListOptions) ([]container.Summary, error) { // Exact name lookup will find a candidate return []container.Summary{ { diff --git a/pkg/container/docker/mocks_test.go b/pkg/container/docker/mocks_test.go index 798c7c2b09..9ca117be99 100644 --- a/pkg/container/docker/mocks_test.go +++ b/pkg/container/docker/mocks_test.go @@ -7,64 +7,91 @@ import ( "context" "fmt" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" v1 "github.com/opencontainers/image-spec/specs-go/v1" ) // fakeDockerAPI provides a minimal test double for dockerAPI used by Client. -// Centralized here for reuse across tests. +// Centralized here for reuse across tests. The hook signatures mirror the +// redesigned moby/moby client API that Client.api targets. type fakeDockerAPI struct { - listFunc func(ctx context.Context, options container.ListOptions) ([]container.Summary, error) + listFunc func(ctx context.Context, options mobyclient.ContainerListOptions) ([]container.Summary, error) inspectFunc func(ctx context.Context, id string) (container.InspectResponse, error) - stopFunc func(ctx context.Context, containerID string, options container.StopOptions) error + stopFunc func(ctx context.Context, containerID string, options mobyclient.ContainerStopOptions) error // additional hooks to satisfy extended dockerAPI for create/start/remove createFunc func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) - startFunc func(ctx context.Context, containerID string, options container.StartOptions) error - removeFunc func(ctx context.Context, containerID string, options container.RemoveOptions) error + startFunc func(ctx context.Context, containerID string, options mobyclient.ContainerStartOptions) error + removeFunc func(ctx context.Context, containerID string, options mobyclient.ContainerRemoveOptions) error } -func (f *fakeDockerAPI) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) { +func (f *fakeDockerAPI) ContainerList( + ctx context.Context, + options mobyclient.ContainerListOptions, +) (mobyclient.ContainerListResult, error) { if f.listFunc != nil { - return f.listFunc(ctx, options) + items, err := f.listFunc(ctx, options) + return mobyclient.ContainerListResult{Items: items}, err } - return nil, nil + return mobyclient.ContainerListResult{}, nil } -func (f *fakeDockerAPI) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) { +func (f *fakeDockerAPI) ContainerInspect( + ctx context.Context, + id string, + _ mobyclient.ContainerInspectOptions, +) (mobyclient.ContainerInspectResult, error) { if f.inspectFunc != nil { - return f.inspectFunc(ctx, id) + resp, err := f.inspectFunc(ctx, id) + return mobyclient.ContainerInspectResult{Container: resp}, err } - return container.InspectResponse{}, nil + return mobyclient.ContainerInspectResult{}, nil } -func (f *fakeDockerAPI) ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error { +func (f *fakeDockerAPI) ContainerStop( + ctx context.Context, + containerID string, + options mobyclient.ContainerStopOptions, +) (mobyclient.ContainerStopResult, error) { if f.stopFunc != nil { - return f.stopFunc(ctx, containerID, options) + return mobyclient.ContainerStopResult{}, f.stopFunc(ctx, containerID, options) } - return nil + return mobyclient.ContainerStopResult{}, nil } -func (f *fakeDockerAPI) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) { +func (f *fakeDockerAPI) ContainerCreate( + ctx context.Context, + options mobyclient.ContainerCreateOptions, +) (mobyclient.ContainerCreateResult, error) { if f.createFunc != nil { - return f.createFunc(ctx, config, hostConfig, networkingConfig, platform, containerName) + resp, err := f.createFunc(ctx, options.Config, options.HostConfig, options.NetworkingConfig, options.Platform, options.Name) + return mobyclient.ContainerCreateResult{ID: resp.ID, Warnings: resp.Warnings}, err } - return container.CreateResponse{}, nil + return mobyclient.ContainerCreateResult{}, nil } -func (f *fakeDockerAPI) ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error { +func (f *fakeDockerAPI) ContainerStart( + ctx context.Context, + containerID string, + options mobyclient.ContainerStartOptions, +) (mobyclient.ContainerStartResult, error) { if f.startFunc != nil { - return f.startFunc(ctx, containerID, options) + return mobyclient.ContainerStartResult{}, f.startFunc(ctx, containerID, options) } - return nil + return mobyclient.ContainerStartResult{}, nil } -func (f *fakeDockerAPI) ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error { +func (f *fakeDockerAPI) ContainerRemove( + ctx context.Context, + containerID string, + options mobyclient.ContainerRemoveOptions, +) (mobyclient.ContainerRemoveResult, error) { if f.removeFunc != nil { - return f.removeFunc(ctx, containerID, options) + return mobyclient.ContainerRemoveResult{}, f.removeFunc(ctx, containerID, options) } - return nil + return mobyclient.ContainerRemoveResult{}, nil } // fakeImageManager provides a minimal test double for ImageManager diff --git a/pkg/container/docker/sdk/client_unix.go b/pkg/container/docker/sdk/client_unix.go index a0b22cd6fa..4a9cb56b0f 100644 --- a/pkg/container/docker/sdk/client_unix.go +++ b/pkg/container/docker/sdk/client_unix.go @@ -14,7 +14,7 @@ import ( "os" "path/filepath" - "github.com/docker/docker/client" + mobyclient "github.com/moby/moby/client" "github.com/stacklok/toolhive/pkg/container/runtime" ) @@ -23,7 +23,7 @@ import ( var ErrRuntimeNotFound = fmt.Errorf("container runtime not found") // newPlatformClient creates a Docker client using Unix sockets -func newPlatformClient(socketPath string) (*http.Client, []client.Opt) { +func newPlatformClient(socketPath string) (*http.Client, []mobyclient.Opt) { // Create a custom HTTP client that uses the Unix socket httpClient := &http.Client{ Transport: &http.Transport{ @@ -33,224 +33,211 @@ func newPlatformClient(socketPath string) (*http.Client, []client.Opt) { }, } - // Create Docker client options - opts := []client.Opt{ - client.WithAPIVersionNegotiation(), - client.WithHTTPClient(httpClient), - client.WithHost("unix://" + socketPath), + // Create Docker client options. API-version negotiation is enabled by + // default in the new client, so it no longer needs to be requested + // explicitly. + opts := []mobyclient.Opt{ + mobyclient.WithHTTPClient(httpClient), + mobyclient.WithHost("unix://" + socketPath), } return httpClient, opts } -// findPlatformContainerSocket finds a container socket path on Unix systems -func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) { - // First check for custom socket paths via environment variables +// findPlatformContainerSocket finds container socket paths on Unix systems. +// Returns every candidate path that exists on disk, in priority order, so the +// caller can fall back to the next candidate when an earlier one is found but +// cannot be connected to (e.g. /var/run/docker.sock present but EACCES, while +// Docker Desktop's per-user socket would have worked). +func findPlatformContainerSocket(rt runtime.Type) ([]string, runtime.Type, error) { + // First check for custom socket paths via environment variables. The + // override is explicit, so we don't add other fallbacks alongside it. if customSocketPath := os.Getenv(PodmanSocketEnv); customSocketPath != "" { //nolint:gosec // G706: socket path from trusted environment variable slog.Debug("using Podman socket from env", "path", customSocketPath) - // validate the socket path if _, err := os.Stat(customSocketPath); err != nil { //nolint:gosec // G703: socket path from trusted environment variable - return "", runtime.TypePodman, fmt.Errorf("invalid Podman socket path: %w", err) + return nil, runtime.TypePodman, fmt.Errorf("invalid Podman socket path: %w", err) } - return customSocketPath, runtime.TypePodman, nil + return []string{customSocketPath}, runtime.TypePodman, nil } if customSocketPath := os.Getenv(DockerSocketEnv); customSocketPath != "" { //nolint:gosec // G706: socket path from trusted environment variable slog.Debug("using Docker socket from env", "path", customSocketPath) - // validate the socket path if _, err := os.Stat(customSocketPath); err != nil { //nolint:gosec // G703: socket path from trusted environment variable - return "", runtime.TypeDocker, fmt.Errorf("invalid Docker socket path: %w", err) + return nil, runtime.TypeDocker, fmt.Errorf("invalid Docker socket path: %w", err) } - return customSocketPath, runtime.TypeDocker, nil + return []string{customSocketPath}, runtime.TypeDocker, nil } if customSocketPath := os.Getenv(ColimaSocketEnv); customSocketPath != "" { //nolint:gosec // G706: socket path from trusted environment variable slog.Debug("using Colima socket from env", "path", customSocketPath) - // validate the socket path if _, err := os.Stat(customSocketPath); err != nil { //nolint:gosec // G703: socket path from trusted environment variable - return "", runtime.TypeDocker, fmt.Errorf("invalid Colima socket path: %w", err) + return nil, runtime.TypeDocker, fmt.Errorf("invalid Colima socket path: %w", err) } - return customSocketPath, runtime.TypeDocker, nil + return []string{customSocketPath}, runtime.TypeDocker, nil } if rt == runtime.TypePodman { - socketPath, err := findPodmanSocket() - if err == nil { - return socketPath, runtime.TypePodman, nil + if paths := findPodmanSocket(); len(paths) > 0 { + return paths, runtime.TypePodman, nil } } if rt == runtime.TypeDocker { - socketPath, err := findDockerSocket() - if err == nil { - return socketPath, runtime.TypeDocker, nil + if paths := findDockerSocket(); len(paths) > 0 { + return paths, runtime.TypeDocker, nil } } if rt == runtime.TypeColima { - socketPath, err := findColimaSocket() - if err == nil { - return socketPath, runtime.TypeColima, nil + if paths := findColimaSocket(); len(paths) > 0 { + return paths, runtime.TypeColima, nil } } - return "", "", ErrRuntimeNotFound + return nil, "", ErrRuntimeNotFound } -// findPodmanSocket attempts to locate a Podman socket -func findPodmanSocket() (string, error) { - // Check standard Podman location - _, err := os.Stat(PodmanSocketPath) - if err == nil { +// findPodmanSocket attempts to locate Podman sockets. Returns every candidate +// path that exists on disk in priority order; an empty slice means no Podman +// socket was found. +func findPodmanSocket() []string { + var paths []string + + if _, err := os.Stat(PodmanSocketPath); err == nil { slog.Debug("found Podman socket", "path", PodmanSocketPath) - return PodmanSocketPath, nil + paths = append(paths, PodmanSocketPath) + } else { + slog.Debug("failed to check Podman socket", "path", PodmanSocketPath, "error", err) } - slog.Debug("failed to check Podman socket", "path", PodmanSocketPath, "error", err) - - // Check XDG_RUNTIME_DIR location for Podman if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" { xdgSocketPath := filepath.Join(xdgRuntimeDir, PodmanXDGRuntimeSocketPath) - _, err := os.Stat(xdgSocketPath) //nolint:gosec // G703: path from trusted env + constant - - if err == nil { + if _, err := os.Stat(xdgSocketPath); err == nil { //nolint:gosec // G703: path from trusted env + constant //nolint:gosec // G706: socket path derived from XDG_RUNTIME_DIR env var slog.Debug("found Podman socket", "path", xdgSocketPath) - return xdgSocketPath, nil + paths = append(paths, xdgSocketPath) + } else { + //nolint:gosec // G706: socket path derived from XDG_RUNTIME_DIR env var + slog.Debug("failed to check Podman socket", "path", xdgSocketPath, "error", err) } - - //nolint:gosec // G706: socket path derived from XDG_RUNTIME_DIR env var - slog.Debug("failed to check Podman socket", "path", xdgSocketPath, "error", err) } - // Check user-specific location for Podman if home := os.Getenv("HOME"); home != "" { userSocketPath := filepath.Join(home, ".local/share/containers/podman/machine/podman.sock") - _, err := os.Stat(userSocketPath) //nolint:gosec // G703: path from trusted env + constant - - if err == nil { + if _, err := os.Stat(userSocketPath); err == nil { //nolint:gosec // G703: path from trusted env + constant //nolint:gosec // G706: socket path derived from HOME env var slog.Debug("found Podman socket", "path", userSocketPath) - return userSocketPath, nil + paths = append(paths, userSocketPath) + } else { + //nolint:gosec // G706: socket path derived from HOME env var + slog.Debug("failed to check Podman socket", "path", userSocketPath, "error", err) } - - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("failed to check Podman socket", "path", userSocketPath, "error", err) } - // Check TMPDIR for Podman Machine API sockets (macOS) + // Check TMPDIR for Podman Machine API sockets (macOS). // The socket path follows the pattern: $TMPDIR/podman/-api.sock if tmpDir := os.Getenv("TMPDIR"); tmpDir != "" { podmanTmpDir := filepath.Join(tmpDir, "podman") if _, err := os.Stat(podmanTmpDir); err == nil { //nolint:gosec // G703: path from trusted env - // Look for any -api.sock files (there may be multiple machines) matches, err := filepath.Glob(filepath.Join(podmanTmpDir, "*-api.sock")) if err == nil && len(matches) > 0 { - // Use the first available API socket - socketPath := matches[0] - //nolint:gosec // G706: socket path discovered from TMPDIR - slog.Debug("found Podman machine API socket", "path", socketPath) - return socketPath, nil + for _, socketPath := range matches { + //nolint:gosec // G706: socket path discovered from TMPDIR + slog.Debug("found Podman machine API socket", "path", socketPath) + paths = append(paths, socketPath) + } + } else { + //nolint:gosec // G706: directory path from TMPDIR env var + slog.Debug("no Podman machine API sockets found", "dir", podmanTmpDir) } - //nolint:gosec // G706: directory path from TMPDIR env var - slog.Debug("no Podman machine API sockets found", "dir", podmanTmpDir) } else { //nolint:gosec // G706: directory path from TMPDIR env var slog.Debug("podman temp directory not found", "dir", podmanTmpDir, "error", err) } } - return "", fmt.Errorf("podman socket not found in standard locations") + return paths } -// findDockerSocket attempts to locate a Docker socket -func findDockerSocket() (string, error) { - // Try Docker socket as fallback - _, err := os.Stat(DockerSocketPath) - - if err == nil { - slog.Debug("found Docker socket", "path", DockerSocketPath) - return DockerSocketPath, nil - } - - slog.Debug("failed to check Docker socket", "path", DockerSocketPath, "error", err) - - // Try Docker Desktop socket path on macOS - if home := os.Getenv("HOME"); home != "" { - dockerDesktopPath := filepath.Join(home, DockerDesktopMacSocketPath) - _, err := os.Stat(dockerDesktopPath) // #nosec G703 -- path is built from HOME + constant socket path - - if err == nil { - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("found Docker Desktop socket", "path", dockerDesktopPath) - return dockerDesktopPath, nil - } - - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("failed to check Docker Desktop socket", "path", dockerDesktopPath, "error", err) - } - - // Try Rancher Desktop socket path on macOS - if home := os.Getenv("HOME"); home != "" { - rancherDesktopPath := filepath.Join(home, RancherDesktopMacSocketPath) - _, err := os.Stat(rancherDesktopPath) // #nosec G703 -- path is built from HOME + constant socket path - - if err == nil { +// systemDockerSocketPath is the system-wide Docker socket path probed by +// findDockerSocket. It defaults to DockerSocketPath and is package-private so +// tests can redirect the system check to a sandbox path. +var systemDockerSocketPath = DockerSocketPath + +// findDockerSocket attempts to locate Docker sockets. Returns every candidate +// path that exists on disk in priority order; an empty slice means no Docker +// socket was found. +// +// Returning every candidate lets callers fall back to a per-user socket like +// ~/.docker/desktop/docker.sock when the system socket /var/run/docker.sock +// exists but is not connectable (e.g. the running user is not in the docker +// group). Stat-OK on a path does not imply the daemon will accept a connect. +func findDockerSocket() []string { + var paths []string + + if _, err := os.Stat(systemDockerSocketPath); err == nil { + slog.Debug("found Docker socket", "path", systemDockerSocketPath) + paths = append(paths, systemDockerSocketPath) + } else { + slog.Debug("failed to check Docker socket", "path", systemDockerSocketPath, "error", err) + } + + home := os.Getenv("HOME") + if home == "" { + return paths + } + + userCandidates := []struct { + label string + relative string + }{ + {"Docker Desktop socket", DockerDesktopMacSocketPath}, + {"Docker Desktop socket", DockerDesktopLinuxSocketPath}, + {"Rancher Desktop socket", RancherDesktopMacSocketPath}, + {"OrbStack socket", OrbStackMacSocketPath}, + } + for _, c := range userCandidates { + path := filepath.Join(home, c.relative) + if _, err := os.Stat(path); err == nil { // #nosec G703 -- path is built from HOME + constant socket path //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("found Rancher Desktop socket", "path", rancherDesktopPath) - return rancherDesktopPath, nil - } - - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("failed to check Rancher Desktop socket", "path", rancherDesktopPath, "error", err) - } - - // Try OrbStack socket path on macOS - if home := os.Getenv("HOME"); home != "" { - orbStackPath := filepath.Join(home, OrbStackMacSocketPath) - _, err := os.Stat(orbStackPath) // #nosec G703 -- path is built from HOME + constant socket path - - if err == nil { + slog.Debug("found "+c.label, "path", path) + paths = append(paths, path) + } else { //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("found OrbStack socket", "path", orbStackPath) - return orbStackPath, nil + slog.Debug("failed to check "+c.label, "path", path, "error", err) } - - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("failed to check OrbStack socket", "path", orbStackPath, "error", err) } - return "", fmt.Errorf("docker socket not found in standard locations") + return paths } -// findColimaSocket attempts to locate a Colima socket -func findColimaSocket() (string, error) { - // Check standard Colima location - _, err := os.Stat(ColimaDesktopMacSocketPath) - if err == nil { +// findColimaSocket attempts to locate Colima sockets. Returns every candidate +// path that exists on disk in priority order; an empty slice means no Colima +// socket was found. +func findColimaSocket() []string { + var paths []string + + if _, err := os.Stat(ColimaDesktopMacSocketPath); err == nil { slog.Debug("found Colima socket", "path", ColimaDesktopMacSocketPath) - return ColimaDesktopMacSocketPath, nil + paths = append(paths, ColimaDesktopMacSocketPath) + } else { + slog.Debug("failed to check Colima socket", "path", ColimaDesktopMacSocketPath, "error", err) } - slog.Debug("failed to check Colima socket", "path", ColimaDesktopMacSocketPath, "error", err) - - // Check user-specific location for Colima if home := os.Getenv("HOME"); home != "" { userSocketPath := filepath.Join(home, ColimaDesktopMacSocketPath) - _, err := os.Stat(userSocketPath) // #nosec G703 -- path is built from HOME + constant socket path - - if err == nil { + if _, err := os.Stat(userSocketPath); err == nil { // #nosec G703 -- path is built from HOME + constant socket path //nolint:gosec // G706: socket path derived from HOME env var slog.Debug("found Colima socket", "path", userSocketPath) - return userSocketPath, nil + paths = append(paths, userSocketPath) + } else { + //nolint:gosec // G706: socket path derived from HOME env var + slog.Debug("failed to check Colima socket", "path", userSocketPath, "error", err) } - - //nolint:gosec // G706: socket path derived from HOME env var - slog.Debug("failed to check Colima socket", "path", userSocketPath, "error", err) } - return "", fmt.Errorf("colima socket not found in standard locations") + return paths } diff --git a/pkg/container/docker/sdk/client_unix_test.go b/pkg/container/docker/sdk/client_unix_test.go new file mode 100644 index 0000000000..3531a68cf7 --- /dev/null +++ b/pkg/container/docker/sdk/client_unix_test.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package sdk + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/container/runtime" +) + +// clearSocketEnv removes any inherited TOOLHIVE_*_SOCKET overrides so the +// helpers fall through to filesystem discovery during the test. +func clearSocketEnv(t *testing.T) { + t.Helper() + t.Setenv(PodmanSocketEnv, "") + t.Setenv(DockerSocketEnv, "") + t.Setenv(ColimaSocketEnv, "") +} + +// redirectSystemDockerSocket points the system-socket probe at a path that +// definitely does not exist, so user-level fallbacks are exercised regardless +// of whether the host has a real /var/run/docker.sock. +func redirectSystemDockerSocket(t *testing.T) { + t.Helper() + orig := systemDockerSocketPath + systemDockerSocketPath = filepath.Join(t.TempDir(), "no-such-docker.sock") + t.Cleanup(func() { systemDockerSocketPath = orig }) +} + +func TestFindDockerSocket_DockerDesktopOnLinux(t *testing.T) { + clearSocketEnv(t) + redirectSystemDockerSocket(t) + + home := t.TempDir() + t.Setenv("HOME", home) + + socketDir := filepath.Join(home, filepath.Dir(DockerDesktopLinuxSocketPath)) + require.NoError(t, os.MkdirAll(socketDir, 0o755)) + socketPath := filepath.Join(home, DockerDesktopLinuxSocketPath) + require.NoError(t, os.WriteFile(socketPath, nil, 0o600)) + + got := findDockerSocket() + assert.Equal(t, []string{socketPath}, got) +} + +// TestFindDockerSocket_ReturnsAllCandidates exercises the multi-candidate path +// that motivates the slice return type: a system Docker socket AND a +// Docker Desktop on Linux user socket both exist at once (common when Docker +// Desktop installs a host-side wrapper at /var/run/docker.sock alongside its +// per-user socket). Both must be returned, system-first, so NewDockerClient +// can fall back from one to the other on connect failure. +func TestFindDockerSocket_ReturnsAllCandidates(t *testing.T) { + clearSocketEnv(t) + + sysDir := t.TempDir() + sysSocket := filepath.Join(sysDir, "docker.sock") + require.NoError(t, os.WriteFile(sysSocket, nil, 0o600)) + orig := systemDockerSocketPath + systemDockerSocketPath = sysSocket + t.Cleanup(func() { systemDockerSocketPath = orig }) + + home := t.TempDir() + t.Setenv("HOME", home) + socketDir := filepath.Join(home, filepath.Dir(DockerDesktopLinuxSocketPath)) + require.NoError(t, os.MkdirAll(socketDir, 0o755)) + userSocket := filepath.Join(home, DockerDesktopLinuxSocketPath) + require.NoError(t, os.WriteFile(userSocket, nil, 0o600)) + + got := findDockerSocket() + require.Len(t, got, 2, "expected system socket + user socket to both be returned") + assert.Equal(t, sysSocket, got[0], "system socket must be probed first") + assert.Equal(t, userSocket, got[1], "Docker Desktop on Linux socket must be the next candidate") +} + +func TestFindPlatformContainerSocket_DockerEnvOverrideWins(t *testing.T) { + clearSocketEnv(t) + + tmp := t.TempDir() + envSocket := filepath.Join(tmp, "docker-from-env.sock") + require.NoError(t, os.WriteFile(envSocket, nil, 0o600)) + t.Setenv(DockerSocketEnv, envSocket) + + // Even with a Docker Desktop on Linux socket present at $HOME, the env + // var must take precedence and short-circuit candidate collection. + home := t.TempDir() + t.Setenv("HOME", home) + socketDir := filepath.Join(home, filepath.Dir(DockerDesktopLinuxSocketPath)) + require.NoError(t, os.MkdirAll(socketDir, 0o755)) + homeSocket := filepath.Join(home, DockerDesktopLinuxSocketPath) + require.NoError(t, os.WriteFile(homeSocket, nil, 0o600)) + + paths, rt, err := findPlatformContainerSocket(runtime.TypeDocker) + require.NoError(t, err) + assert.Equal(t, []string{envSocket}, paths) + assert.Equal(t, runtime.TypeDocker, rt) +} + +func TestFindPlatformContainerSocket_NotFound(t *testing.T) { + clearSocketEnv(t) + redirectSystemDockerSocket(t) + + // Empty HOME with no sockets created — every discovery path should miss. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("TMPDIR", "") + + _, _, err := findPlatformContainerSocket(runtime.TypeDocker) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRuntimeNotFound), "expected ErrRuntimeNotFound, got %v", err) +} + +func TestDockerPermissionHint(t *testing.T) { + t.Parallel() + + t.Run("nil error returns empty hint", func(t *testing.T) { + t.Parallel() + assert.Empty(t, dockerPermissionHint(nil)) + }) + + t.Run("non-permission error returns empty hint", func(t *testing.T) { + t.Parallel() + assert.Empty(t, dockerPermissionHint(errors.New("connection refused"))) + }) + + t.Run("fs.ErrPermission triggers hint", func(t *testing.T) { + t.Parallel() + wrapped := fmt.Errorf("failed to ping Docker server: %w", fs.ErrPermission) + hint := dockerPermissionHint(wrapped) + assert.Contains(t, hint, "docker' group") + assert.Contains(t, hint, "usermod -aG docker") + }) + + t.Run("rendered 'permission denied' substring triggers hint", func(t *testing.T) { + t.Parallel() + // The Docker SDK wraps the underlying *os.SyscallError in its own + // formatted message, which we can't reach via errors.Is. Make sure + // the substring fallback still surfaces the hint in that shape. + err := errors.New("Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock") + hint := dockerPermissionHint(err) + assert.NotEmpty(t, hint) + }) +} diff --git a/pkg/container/docker/sdk/client_windows.go b/pkg/container/docker/sdk/client_windows.go index f5a382ed8e..ca8b9692fc 100644 --- a/pkg/container/docker/sdk/client_windows.go +++ b/pkg/container/docker/sdk/client_windows.go @@ -15,7 +15,7 @@ import ( "time" "github.com/Microsoft/go-winio" - "github.com/docker/docker/client" + mobyclient "github.com/moby/moby/client" "github.com/stacklok/toolhive/pkg/container/runtime" ) @@ -35,7 +35,7 @@ const ( const pipeConnectionTimeout = 2 * time.Second // newPlatformClient creates a Docker client using Windows named pipes -func newPlatformClient(pipePath string) (*http.Client, []client.Opt) { +func newPlatformClient(pipePath string) (*http.Client, []mobyclient.Opt) { // Create a custom HTTP client that uses Windows named pipes httpClient := &http.Client{ Transport: &http.Transport{ @@ -48,18 +48,21 @@ func newPlatformClient(pipePath string) (*http.Client, []client.Opt) { }, } - // Create Docker client options - opts := []client.Opt{ - client.WithAPIVersionNegotiation(), - client.WithHTTPClient(httpClient), - client.WithHost("npipe://" + pipePath), + // Create Docker client options. API-version negotiation is enabled by + // default in the new client, so it no longer needs to be requested + // explicitly. + opts := []mobyclient.Opt{ + mobyclient.WithHTTPClient(httpClient), + mobyclient.WithHost("npipe://" + pipePath), } return httpClient, opts } -// findPlatformContainerSocket finds a container socket path on Windows -func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) { +// findPlatformContainerSocket finds container pipe paths on Windows. Returns +// a slice for parity with the Unix implementation, but Windows only ever has +// one candidate per runtime type (the well-known named pipe). +func findPlatformContainerSocket(rt runtime.Type) ([]string, runtime.Type, error) { // First check for custom socket paths via environment variables if customPipePath := os.Getenv(PodmanSocketEnv); customPipePath != "" { //nolint:gosec // G706: pipe path from trusted environment variable @@ -69,10 +72,10 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) defer cancel() conn, err := winio.DialPipeContext(ctx, customPipePath) if err != nil { - return "", runtime.TypePodman, fmt.Errorf("invalid Podman pipe path: %w", err) + return nil, runtime.TypePodman, fmt.Errorf("invalid Podman pipe path: %w", err) } conn.Close() - return customPipePath, runtime.TypePodman, nil + return []string{customPipePath}, runtime.TypePodman, nil } if customPipePath := os.Getenv(DockerSocketEnv); customPipePath != "" { @@ -83,10 +86,10 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) defer cancel() conn, err := winio.DialPipeContext(ctx, customPipePath) if err != nil { - return "", runtime.TypeDocker, fmt.Errorf("invalid Docker pipe path: %w", err) + return nil, runtime.TypeDocker, fmt.Errorf("invalid Docker pipe path: %w", err) } conn.Close() - return customPipePath, runtime.TypeDocker, nil + return []string{customPipePath}, runtime.TypeDocker, nil } if rt == runtime.TypePodman { @@ -97,7 +100,7 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) if err == nil { slog.Debug("found Podman pipe", "path", PodmanDesktopWindowsPipePath) conn.Close() - return PodmanDesktopWindowsPipePath, runtime.TypePodman, nil + return []string{PodmanDesktopWindowsPipePath}, runtime.TypePodman, nil } slog.Debug("failed to connect to Podman pipe", "path", PodmanDesktopWindowsPipePath, "error", err) } @@ -110,10 +113,10 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) if err == nil { slog.Debug("found Docker pipe", "path", DockerDesktopWindowsPipePath) conn.Close() - return DockerDesktopWindowsPipePath, runtime.TypeDocker, nil + return []string{DockerDesktopWindowsPipePath}, runtime.TypeDocker, nil } slog.Debug("failed to connect to Docker pipe", "path", DockerDesktopWindowsPipePath, "error", err) } - return "", "", ErrRuntimeNotFound + return nil, "", ErrRuntimeNotFound } diff --git a/pkg/container/docker/sdk/factory.go b/pkg/container/docker/sdk/factory.go index dccb19828b..8d19c01dc2 100644 --- a/pkg/container/docker/sdk/factory.go +++ b/pkg/container/docker/sdk/factory.go @@ -6,10 +6,13 @@ package sdk import ( "context" + "errors" "fmt" + "io/fs" "log/slog" + "strings" - "github.com/docker/docker/client" + mobyclient "github.com/moby/moby/client" "github.com/stacklok/toolhive/pkg/container/runtime" ) @@ -39,6 +42,10 @@ const ( DockerSocketPath = "/var/run/docker.sock" // DockerDesktopMacSocketPath is the Docker Desktop socket path on macOS DockerDesktopMacSocketPath = ".docker/run/docker.sock" + // DockerDesktopLinuxSocketPath is the Docker Desktop socket path on Linux + // (relative to $HOME). Docker Desktop on Linux registers a "desktop-linux" + // Docker context that points to this socket. + DockerDesktopLinuxSocketPath = ".docker/desktop/docker.sock" // RancherDesktopMacSocketPath is the Docker socket path for Rancher Desktop on macOS RancherDesktopMacSocketPath = ".rd/docker.sock" // OrbStackMacSocketPath is the Docker socket path for OrbStack on macOS @@ -49,15 +56,21 @@ const ( var supportedSocketPaths = []runtime.Type{runtime.TypePodman, runtime.TypeDocker, runtime.TypeColima} -// NewDockerClient creates a new container client -func NewDockerClient(ctx context.Context) (*client.Client, string, runtime.Type, error) { +// NewDockerClient creates a new container client. +// +// For each runtime (Podman, Docker, Colima) it asks the platform helper for +// every candidate socket that exists on disk and then tries to connect to each +// in turn. This matters in mixed setups — e.g. /var/run/docker.sock is present +// but the running user is not in the docker group, while Docker Desktop's +// per-user socket at ~/.docker/desktop/docker.sock would work. Without +// per-runtime fallback the first stat-OK socket short-circuits discovery and +// we'd surface "no container runtime available" even though a usable Docker +// daemon is reachable through a different socket. +func NewDockerClient(ctx context.Context) (*mobyclient.Client, string, runtime.Type, error) { var lastErr error - // We try to find a container socket for the given runtime - // We try Podman first, then Docker as fallback for _, sp := range supportedSocketPaths { - // Try to find a container socket for the given runtime - socketPath, runtimeType, err := findContainerSocket(sp) + socketPaths, runtimeType, err := findContainerSocket(sp) if err != nil { //nolint:gosec // G706: runtime type from internal config slog.Debug("failed to find socket", "runtime", sp, "error", err) @@ -65,38 +78,60 @@ func NewDockerClient(ctx context.Context) (*client.Client, string, runtime.Type, continue } - c, err := newClientWithSocketPath(ctx, socketPath) - if err != nil { - lastErr = err - //nolint:gosec // G706: runtime type from internal config - slog.Debug("failed to create client", "runtime", sp, "error", err) - continue + for _, socketPath := range socketPaths { + c, err := newClientWithSocketPath(ctx, socketPath) + if err != nil { + lastErr = err + //nolint:gosec // G706: runtime type from internal config + slog.Debug("failed to create client", "runtime", sp, "socket", socketPath, "error", err) + continue + } + + //nolint:gosec // G706: runtime type from internal detection + slog.Debug("successfully connected to runtime", "runtime", runtimeType, "socket", socketPath) + return c, socketPath, runtimeType, nil } - - //nolint:gosec // G706: runtime type from internal detection - slog.Debug("successfully connected to runtime", "runtime", runtimeType) - return c, socketPath, runtimeType, nil } if lastErr != nil { - return nil, "", "", fmt.Errorf("no supported container runtime available: %w", lastErr) + return nil, "", "", fmt.Errorf("no supported container runtime available%s: %w", dockerPermissionHint(lastErr), lastErr) } return nil, "", "", fmt.Errorf("no supported container runtime found/running") } +// dockerPermissionHint returns an actionable hint when the underlying socket +// connect was rejected with a permission error. The most common cause on +// Linux is the calling user not being a member of the docker group, which the +// raw "permission denied" error does not call out. +func dockerPermissionHint(err error) string { + if err == nil { + return "" + } + // errors.Is handles syscall.EACCES/EPERM (which map to fs.ErrPermission via + // the syscall.Errno.Is shim); the Docker client wraps the underlying + // *os.SyscallError in its own message, so also fall back to a substring + // match on the rendered text for robustness across error wrappings. + if !errors.Is(err, fs.ErrPermission) && !strings.Contains(err.Error(), "permission denied") { + return "" + } + return " (hint: the Docker socket rejected the connection with 'permission denied' — " + + "is your user in the 'docker' group? On Linux: 'sudo usermod -aG docker $USER', " + + "then log out and back in. See https://docs.docker.com/engine/install/linux-postinstall/.)" +} + // NewClientWithSocketPath creates a new container client with a specific socket path -func newClientWithSocketPath(ctx context.Context, socketPath string) (*client.Client, error) { +func newClientWithSocketPath(ctx context.Context, socketPath string) (*mobyclient.Client, error) { // Create platform-specific client _, opts := newPlatformClient(socketPath) // Create Docker client with the custom HTTP client - dockerClient, err := client.NewClientWithOpts(opts...) + dockerClient, err := mobyclient.New(opts...) if err != nil { return nil, fmt.Errorf("failed to create client: %w", err) } // Make sure we can ping the server. - _, err = dockerClient.Ping(ctx) + _, err = dockerClient.Ping(ctx, mobyclient.PingOptions{}) if err != nil { return nil, fmt.Errorf("failed to ping Docker server at %s: %w", socketPath, err) } @@ -104,8 +139,11 @@ func newClientWithSocketPath(ctx context.Context, socketPath string) (*client.Cl return dockerClient, nil } -// findContainerSocket finds a container socket path, preferring Podman over Docker -func findContainerSocket(rt runtime.Type) (string, runtime.Type, error) { +// findContainerSocket finds candidate container socket paths for the given +// runtime, preferring Podman over Docker. The returned slice is ordered from +// highest to lowest priority and may contain multiple entries on Unix when +// several known socket locations exist on disk simultaneously. +func findContainerSocket(rt runtime.Type) ([]string, runtime.Type, error) { // Use platform-specific implementation return findPlatformContainerSocket(rt) } diff --git a/pkg/container/docker/squid.go b/pkg/container/docker/squid.go index 1d53143947..c23825e46b 100644 --- a/pkg/container/docker/squid.go +++ b/pkg/container/docker/squid.go @@ -11,8 +11,8 @@ import ( "strconv" "strings" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" "github.com/stacklok/toolhive-core/permissions" "github.com/stacklok/toolhive/pkg/container/runtime" diff --git a/pkg/container/docker/squid_test.go b/pkg/container/docker/squid_test.go index 984b61c0cf..341f630230 100644 --- a/pkg/container/docker/squid_test.go +++ b/pkg/container/docker/squid_test.go @@ -10,9 +10,10 @@ import ( "strings" "testing" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" - "github.com/docker/docker/api/types/network" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -37,7 +38,7 @@ func TestCreateSquidContainer_Basics(t *testing.T) { gotHost = host return container.CreateResponse{ID: "cid-new"}, nil }, - startFunc: func(_ context.Context, id string, _ container.StartOptions) error { + startFunc: func(_ context.Context, id string, _ mobyclient.ContainerStartOptions) error { startCalled = true assert.Equal(t, "cid-new", id) return nil diff --git a/pkg/container/images/registry.go b/pkg/container/images/registry.go index fdc169d2e8..1acf52219c 100644 --- a/pkg/container/images/registry.go +++ b/pkg/container/images/registry.go @@ -16,8 +16,6 @@ import ( "path/filepath" "runtime" - "github.com/docker/docker/api/types/build" - "github.com/docker/docker/client" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -30,33 +28,20 @@ import ( // for direct registry operations without requiring a Docker daemon. // However, for building images from Dockerfiles, it still uses the Docker client. type RegistryImageManager struct { - keychain authn.Keychain - platform *v1.Platform - dockerClient *client.Client // Used for building images from Dockerfiles - daemonClient daemon.Client // Used for daemon.Image/daemon.Write (go-containerregistry) - mobyClient *mobyclient.Client + keychain authn.Keychain + platform *v1.Platform + // dockerClient is used for building images from Dockerfiles and, because the + // moby/moby client already satisfies the go-containerregistry daemon.Client + // interface, for daemon.Image/daemon.Write operations as well. + dockerClient *mobyclient.Client } // NewRegistryImageManager creates a new RegistryImageManager instance -func NewRegistryImageManager(dockerClient *client.Client) *RegistryImageManager { - // Create a moby/moby client that satisfies the daemon.Client interface - // required by go-containerregistry, using the same host and HTTP client - // as the docker client. - mobyClient, err := mobyclient.New( - mobyclient.WithHost(dockerClient.DaemonHost()), - mobyclient.WithHTTPClient(dockerClient.HTTPClient()), - ) - if err != nil { - // Fall back: this should not happen since we already have a working docker client - slog.Warn("failed to create moby client for daemon operations, daemon image operations may fail", "error", err) - } - +func NewRegistryImageManager(dockerClient *mobyclient.Client) *RegistryImageManager { return &RegistryImageManager{ keychain: NewCompositeKeychain(), // Use composite keychain (env vars + default) platform: getDefaultPlatform(), // Use a default platform based on host architecture - dockerClient: dockerClient, // Used solely for building images from Dockerfiles - daemonClient: mobyClient, // Used for go-containerregistry daemon operations - mobyClient: mobyClient, + dockerClient: dockerClient, } } @@ -78,7 +63,7 @@ func (r *RegistryImageManager) ImageExists(_ context.Context, imageName string) } // First check if image exists locally in daemon - if _, err := daemon.Image(ref, daemon.WithClient(r.daemonClient)); err != nil { + if _, err := daemon.Image(ref, daemon.WithClient(r.dockerClient)); err != nil { // Image does not exist locally return false, nil } @@ -123,7 +108,7 @@ func (r *RegistryImageManager) PullImage(ctx context.Context, imageName string) } // Save the image to the local daemon - response, err := daemon.Write(tag, img, daemon.WithClient(r.daemonClient)) + response, err := daemon.Write(tag, img, daemon.WithClient(r.dockerClient)) if err != nil { return fmt.Errorf("failed to write image to daemon: %w", err) } @@ -148,10 +133,10 @@ func (r *RegistryImageManager) pullDigestViaDockerDaemon(ctx context.Context, im } } - if r.mobyClient == nil { + if r.dockerClient == nil { return fmt.Errorf("failed to pull image %q: docker daemon client unavailable", imageName) } - resp, err := r.mobyClient.ImagePull(ctx, imageName, mobyclient.ImagePullOptions{ + resp, err := r.dockerClient.ImagePull(ctx, imageName, mobyclient.ImagePullOptions{ RegistryAuth: registryAuth, }) if err != nil { @@ -186,7 +171,7 @@ func (r *RegistryImageManager) WithPlatform(platform *v1.Platform) *RegistryImag } // buildDockerImage builds a Docker image using the Docker client API -func buildDockerImage(ctx context.Context, dockerClient *client.Client, contextDir, imageName string) error { +func buildDockerImage(ctx context.Context, dockerClient *mobyclient.Client, contextDir, imageName string) error { //nolint:gosec // G706: image name and context dir from config slog.Debug("building image", "image", imageName, "context_dir", contextDir) @@ -224,7 +209,7 @@ func buildDockerImage(ctx context.Context, dockerClient *client.Client, contextD } // Build the image - buildOptions := build.ImageBuildOptions{ + buildOptions := mobyclient.ImageBuildOptions{ Tags: []string{imageName}, Dockerfile: "Dockerfile", Remove: true, diff --git a/pkg/container/kubernetes/client.go b/pkg/container/kubernetes/client.go index 95213c7693..fed9927bf3 100644 --- a/pkg/container/kubernetes/client.go +++ b/pkg/container/kubernetes/client.go @@ -52,6 +52,30 @@ const ( serviceFieldManager = "toolhive-container-manager" // maxStatefulSetRolloutRecoveryAttempts bounds stale-pod recovery retries. maxStatefulSetRolloutRecoveryAttempts = 3 + + // RunConfigMCPServerGenerationAnnotation carries the MCPServer .metadata.generation that + // produced the RunConfig applied to this StatefulSet. Used as a monotonic version stamp + // to prevent stale proxyrunner pods (from an old Deployment ReplicaSet) from clobbering + // a newer RunConfig's apply. The gate only becomes effective once proxyrunner is upgraded + // to a version that reads this annotation; operator-only upgrades leave the race window + // in place until proxyrunner is also rolled. Exported because it forms a wire contract + // that external readers (operator, diagnostic tooling) may consume. + // + // The operator also stamps this same annotation on the proxyrunner Deployment's + // pod template and projects it into the proxyrunner container as the env var + // EnvVarMCPServerGeneration via the downward API. That projection freezes the + // generation per pod at creation time, so two coexisting proxyrunner pods cannot + // converge on the same generation by re-reading the live-mounted RunConfig + // ConfigMap (issue #5360). + RunConfigMCPServerGenerationAnnotation = "toolhive.stacklok.dev/mcpserver-generation" + + // EnvVarMCPServerGeneration is the env var name through which the proxyrunner + // container receives its frozen-per-pod MCPServer generation. Sourced via the + // downward API from the pod-template annotation RunConfigMCPServerGenerationAnnotation, + // it overrides the value read from /etc/runconfig/runconfig.json (which would + // otherwise live-update across all proxyrunner pods during a helm upgrade and + // defeat the apply-gate). See issue #5360. + EnvVarMCPServerGeneration = "THV_MCPSERVER_GENERATION" ) // RuntimeName is the name identifier for the Kubernetes runtime @@ -101,11 +125,6 @@ func NewClient(_ context.Context) (*Client, error) { return NewClientWithConfig(clientset, config), nil } -// IsAvailable checks if kubernetes is available -func IsAvailable() bool { - return k8s.IsAvailable() -} - // NewClientWithConfig creates a new container client with a provided config // This is primarily used for testing with fake clients func NewClientWithConfig(clientset kubernetes.Interface, config *rest.Config) *Client { @@ -351,16 +370,7 @@ func (c *Client) DeployWorkload(ctx context.Context, attachStdio := options == nil || options.AttachStdio - // Convert environment variables to Kubernetes format - var envVarList []*corev1apply.EnvVarApplyConfiguration - envVarNames := make([]string, 0, len(envVars)) - for k := range envVars { - envVarNames = append(envVarNames, k) - } - sort.Strings(envVarNames) - for _, k := range envVarNames { - envVarList = append(envVarList, corev1apply.EnvVar().WithName(k).WithValue(envVars[k])) - } + envVarList := buildSortedEnvVarList(envVars) // Create a pod template spec podTemplateSpec := ensureObjectMetaApplyConfigurationExists(corev1apply.PodTemplateSpec()) @@ -405,22 +415,26 @@ func (c *Client) DeployWorkload(ctx context.Context, return 0, err } - // Create an apply configuration for the statefulset - statefulSetApply := appsv1apply.StatefulSet(containerName, namespace). - WithLabels(containerLabels). - WithSpec(buildStatefulSetSpec(containerName, podTemplateSpec, options)) - - // Apply the statefulset using server-side apply - createdStatefulSet, err := c.client.AppsV1().StatefulSets(namespace). - Apply(ctx, statefulSetApply, metav1.ApplyOptions{ - FieldManager: serviceFieldManager, - Force: true, - }) + ourGen := runConfigGeneration(options) + skip, err := c.shouldSkipStatefulSetApply(ctx, namespace, containerName, ourGen) if err != nil { - return 0, fmt.Errorf("failed to apply statefulset: %w", err) + return 0, err + } + if skip { + // Intentionally skip ensureBackendServices in the gated path: this pod's RunConfig + // is stale, so reconciling services here would clobber port/config fields set by + // the newer-generation pod under the same field manager + Force: true — the same + // race this gate prevents for the StatefulSet. The newer pod already reconciled + // services; if that failed, it returns an error and retries on its own. + return 0, nil } - slog.Debug("applied statefulset", "name", createdStatefulSet.Name) + createdStatefulSet, err := c.applyStatefulSet( + ctx, namespace, containerName, containerLabels, podTemplateSpec, options, ourGen, + ) + if err != nil { + return 0, err + } err = c.ensureBackendServices( ctx, containerName, namespace, containerLabels, transportType, options, createdStatefulSet) @@ -450,6 +464,101 @@ func (c *Client) DeployWorkload(ctx context.Context, return 0, nil } +// buildSortedEnvVarList converts an env var map to Kubernetes apply configurations +// with deterministically ordered keys. Go map iteration is randomized, and any +// ordering shift changes the pod template hash and triggers an unnecessary +// StatefulSet rollout (#5063). +func buildSortedEnvVarList(envVars map[string]string) []*corev1apply.EnvVarApplyConfiguration { + envKeys := make([]string, 0, len(envVars)) + for k := range envVars { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + envVarList := make([]*corev1apply.EnvVarApplyConfiguration, 0, len(envKeys)) + for _, k := range envKeys { + envVarList = append(envVarList, corev1apply.EnvVar().WithName(k).WithValue(envVars[k])) + } + return envVarList +} + +// runConfigGeneration extracts the RunConfig MCPServer generation from options, +// returning 0 when options is nil (backward-compat / non-operator callers). +func runConfigGeneration(options *runtime.DeployWorkloadOptions) int64 { + if options == nil { + return 0 + } + return options.RunConfigMCPServerGeneration +} + +// applyStatefulSet stamps the MCPServer generation annotation when non-zero, +// builds the StatefulSet apply configuration, and performs the server-side apply. +func (c *Client) applyStatefulSet( + ctx context.Context, + namespace, containerName string, + containerLabels map[string]string, + podTemplateSpec *corev1apply.PodTemplateSpecApplyConfiguration, + options *runtime.DeployWorkloadOptions, + ourGen int64, +) (*appsv1.StatefulSet, error) { + if ourGen > 0 { + podTemplateSpec = podTemplateSpec.WithAnnotations(map[string]string{ + RunConfigMCPServerGenerationAnnotation: strconv.FormatInt(ourGen, 10), + }) + } + statefulSetApply := appsv1apply.StatefulSet(containerName, namespace). + WithLabels(containerLabels). + WithSpec(buildStatefulSetSpec(containerName, podTemplateSpec, options)) + createdStatefulSet, err := c.client.AppsV1().StatefulSets(namespace). + Apply(ctx, statefulSetApply, metav1.ApplyOptions{ + FieldManager: serviceFieldManager, + Force: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to apply statefulset: %w", err) + } + slog.Debug("applied statefulset", "name", createdStatefulSet.Name) + return createdStatefulSet, nil +} + +// shouldSkipStatefulSetApply returns true when the existing StatefulSet is already +// stamped with a strictly greater MCPServer generation than ours, meaning a newer +// proxyrunner pod has already reconciled the workload and ours would be a regression. +// Returns false (apply as normal) when ourGen is zero or negative, when the StatefulSet +// does not yet exist, when the annotation is absent, or when the annotation is unparsable. +func (c *Client) shouldSkipStatefulSetApply( + ctx context.Context, namespace, name string, ourGen int64, +) (bool, error) { + if ourGen <= 0 { + return false, nil + } + existing, err := c.client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to get existing statefulset: %w", err) + } + if existing.Spec.Template.Annotations == nil { + return false, nil + } + theirs := existing.Spec.Template.Annotations[RunConfigMCPServerGenerationAnnotation] + if theirs == "" { + return false, nil + } + theirsGen, parseErr := strconv.ParseInt(theirs, 10, 64) + if parseErr != nil { + slog.Warn("unparsable mcpserver-generation annotation; proceeding with apply", + "sts", name, "value", theirs, "err", parseErr) + return false, nil + } + if theirsGen > ourGen { + slog.Debug("skipping StatefulSet apply; newer MCPServer generation already applied", + "sts", name, "ours", ourGen, "theirs", theirsGen) + return true, nil + } + return false, nil +} + // buildStatefulSetSpec constructs the StatefulSet spec apply configuration. // WithReplicas is only included when BackendReplicas is explicitly set; omitting // the field lets the existing field manager (e.g. HPA or kubectl) retain control diff --git a/pkg/container/kubernetes/client_test.go b/pkg/container/kubernetes/client_test.go index 62324b36a1..d9b7202b37 100644 --- a/pkg/container/kubernetes/client_test.go +++ b/pkg/container/kubernetes/client_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "strconv" "strings" "testing" "time" @@ -894,6 +895,94 @@ func TestDeployWorkloadCreatesBackendServices(t *testing.T) { } } +// TestDeployWorkloadEnvVarOrderingDeterministic verifies that DeployWorkload +// produces an env list in deterministic (sorted) order regardless of Go map +// iteration randomness. Without this, the StatefulSet pod template hash +// flapped between reconciles, triggering needless pod rollouts (#5063). +func TestDeployWorkloadEnvVarOrderingDeterministic(t *testing.T) { + t.Parallel() + + containerName := "test-svc" + envVars := map[string]string{ + "ULI_MCP_TOKEN": "tok", + "MCP_PORT": "8080", + "MCP_TRANSPORT": "streamable-http", + "MCP_HOST": "0.0.0.0", + "FASTMCP_PORT": "8080", + } + + deploy := func() []corev1.EnvVar { + mockStatefulSet := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: containerName, Namespace: "default", UID: "test-uid", + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{}}}, + }, + Status: appsv1.StatefulSetStatus{ReadyReplicas: 1}, + } + clientset := fake.NewClientset(mockStatefulSet) + fakeConfig := &rest.Config{Host: "https://fake-k8s-api.example.com"} + mockDetector := &mockPlatformDetector{platform: PlatformKubernetes} + + client := NewClientWithConfigAndPlatformDetector(clientset, fakeConfig, mockDetector) + client.waitForStatefulSetReadyFunc = mockWaitForStatefulSetReady + client.namespaceFunc = func() string { return "default" } + + options := runtime.NewDeployWorkloadOptions() + options.PortBindings = map[string][]runtime.PortBinding{"8080/tcp": {{HostPort: "8080"}}} + + _, err := client.DeployWorkload( + t.Context(), + "test-image", + containerName, + []string{"serve"}, + envVars, + map[string]string{"app": containerName}, + nil, + "streamable-http", + options, + false, + ) + require.NoError(t, err) + + sts, err := clientset.AppsV1().StatefulSets("default").Get(t.Context(), containerName, metav1.GetOptions{}) + require.NoError(t, err) + var mcp *corev1.Container + for i := range sts.Spec.Template.Spec.Containers { + if sts.Spec.Template.Spec.Containers[i].Name == mcpContainerName { + mcp = &sts.Spec.Template.Spec.Containers[i] + break + } + } + require.NotNil(t, mcp, "mcp container should exist on the StatefulSet") + return mcp.Env + } + + // Sorted ordering is the contract; assert against it explicitly so the + // reason for the order is documented in the test, not just stable across runs. + expected := []string{"FASTMCP_PORT", "MCP_HOST", "MCP_PORT", "MCP_TRANSPORT", "ULI_MCP_TOKEN"} + + first := deploy() + firstNames := make([]string, len(first)) + for i, e := range first { + firstNames[i] = e.Name + } + assert.Equal(t, expected, firstNames, "first DeployWorkload should produce sorted env order") + + // Run multiple times to catch any non-determinism that would slip through + // a single-call test (Go map iteration order is randomized per call). + for i := 0; i < 10; i++ { + got := deploy() + gotNames := make([]string, len(got)) + for j, e := range got { + gotNames[j] = e.Name + } + assert.Equal(t, expected, gotNames, "iteration %d: env order must be deterministic", i) + } +} + // TestAttachToWorkloadExitFunc tests that the exit function is properly configured // and can be mocked for testing func TestAttachToWorkloadExitFunc(t *testing.T) { @@ -1554,3 +1643,256 @@ func TestDeployWorkloadUpdatesStatefulSetEnvVars(t *testing.T) { assert.Equal(t, "mcp-aws-mcp", envByName["AWS_API_MCP_ALLOWED_HOSTS"]) assert.Equal(t, "0.0.0.0", envByName["AWS_API_MCP_HOST"]) } + +func TestDeployWorkload_RunConfigMCPServerGenerationGate(t *testing.T) { + t.Parallel() + + const containerName = "test-container" + const oursGen = int64(100) + oursFormatted := strconv.FormatInt(oursGen, 10) + + // seededImage is distinct from the image passed to DeployWorkload so the + // "skipped apply" case can assert the seeded spec was NOT overwritten. + const seededImage = "seeded-image:pre-existing" + const deployImage = "test-image" + + newSeededSTS := func(annotation string) *appsv1.StatefulSet { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: containerName, + Namespace: defaultNamespace, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: mcpContainerName, + Image: seededImage, + }}, + }, + }, + }, + Status: appsv1.StatefulSetStatus{ReadyReplicas: 1}, + } + if annotation != "" { + sts.Spec.Template.Annotations = map[string]string{ + RunConfigMCPServerGenerationAnnotation: annotation, + } + } + return sts + } + + testCases := []struct { + name string + seedSTS bool + seedAnnotation string + optionsGen int64 + expectApply bool + wantAnnotation string // expected annotation value on STS after call + wantAnnotationIs string // "missing" | "equal" — how to interpret wantAnnotation + }{ + { + name: "no_existing_sts", + seedSTS: false, + optionsGen: oursGen, + expectApply: true, + wantAnnotation: oursFormatted, + wantAnnotationIs: "equal", + }, + { + name: "existing_sts_no_annotation", + seedSTS: true, + seedAnnotation: "", + optionsGen: oursGen, + expectApply: true, + wantAnnotation: oursFormatted, + wantAnnotationIs: "equal", + }, + { + name: "existing_sts_older_annotation", + seedSTS: true, + seedAnnotation: strconv.FormatInt(int64(50), 10), + optionsGen: oursGen, + expectApply: true, + wantAnnotation: oursFormatted, + wantAnnotationIs: "equal", + }, + { + name: "existing_sts_equal_annotation", + seedSTS: true, + seedAnnotation: oursFormatted, + optionsGen: oursGen, + expectApply: true, + wantAnnotation: oursFormatted, + wantAnnotationIs: "equal", + }, + { + name: "existing_sts_newer_annotation", + seedSTS: true, + seedAnnotation: strconv.FormatInt(int64(200), 10), + optionsGen: oursGen, + expectApply: false, + wantAnnotation: strconv.FormatInt(int64(200), 10), + wantAnnotationIs: "equal", + }, + { + name: "existing_sts_unparsable_annotation", + seedSTS: true, + seedAnnotation: "not-a-number", + optionsGen: oursGen, + expectApply: true, + wantAnnotation: oursFormatted, + wantAnnotationIs: "equal", + }, + { + name: "zero_options_generation", + seedSTS: true, + seedAnnotation: strconv.FormatInt(int64(200), 10), + optionsGen: int64(0), + expectApply: true, + wantAnnotation: strconv.FormatInt(int64(200), 10), + wantAnnotationIs: "equal", + }, + { + name: "zero_options_generation_no_existing_sts", + seedSTS: false, + optionsGen: int64(0), + expectApply: true, + wantAnnotationIs: "missing", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var clientset *fake.Clientset + if tc.seedSTS { + clientset = fake.NewClientset(newSeededSTS(tc.seedAnnotation)) + } else { + clientset = fake.NewClientset() + } + + client := NewClientWithConfigAndPlatformDetector( + clientset, + &rest.Config{Host: "https://fake-k8s-api.example.com"}, + &mockPlatformDetector{platform: PlatformKubernetes}, + ) + client.waitForStatefulSetReadyFunc = mockWaitForStatefulSetReady + client.namespaceFunc = func() string { return defaultNamespace } + + options := runtime.NewDeployWorkloadOptions() + options.RunConfigMCPServerGeneration = tc.optionsGen + + _, err := client.DeployWorkload( + t.Context(), + deployImage, containerName, nil, + map[string]string{}, map[string]string{}, + nil, "streamable-http", options, false, + ) + require.NoError(t, err) + + sts, getErr := clientset.AppsV1().StatefulSets(defaultNamespace).Get( + t.Context(), containerName, metav1.GetOptions{}, + ) + + if !tc.expectApply { + // Apply was gated off. The seeded STS should still exist with its + // original image and annotation untouched. + require.NoError(t, getErr) + require.NotEmpty(t, sts.Spec.Template.Spec.Containers) + assert.Equal(t, seededImage, sts.Spec.Template.Spec.Containers[0].Image, + "seeded image should be preserved when apply is gated") + assert.Equal(t, tc.seedAnnotation, + sts.Spec.Template.Annotations[RunConfigMCPServerGenerationAnnotation], + "seeded annotation should be preserved when apply is gated") + return + } + + // Apply should have occurred. + require.NoError(t, getErr) + switch tc.wantAnnotationIs { + case "missing": + _, present := sts.Spec.Template.Annotations[RunConfigMCPServerGenerationAnnotation] + assert.False(t, present, + "annotation should not be added when options.RunConfigMCPServerGeneration is zero") + case "equal": + got := sts.Spec.Template.Annotations[RunConfigMCPServerGenerationAnnotation] + // Compare as int64 so a formatting mismatch (e.g. "+100" vs "100") doesn't + // cause a false positive; the canonical representation is strconv.FormatInt. + wantInt, werr := strconv.ParseInt(tc.wantAnnotation, 10, 64) + require.NoError(t, werr, "test expected annotation must be a parseable int64") + gotInt, gerr := strconv.ParseInt(got, 10, 64) + require.NoError(t, gerr, "annotation on STS must be a parseable int64, got %q", got) + assert.Equal(t, wantInt, gotInt, + "annotation value mismatch: got %q, want %q", got, tc.wantAnnotation) + } + }) + } +} + +// TestDeployWorkload_EqualGenerationDifferentImageClobbers documents the +// regression mode for issue #5360. Two proxyrunner pods coexist during a +// rolling update; both have read the same MCPServerGeneration N from the +// live-mounted RunConfig ConfigMap, but each holds a different image in +// its CLI positional arg (frozen at pod creation). The gate at +// shouldSkipStatefulSetApply uses strict-greater-than, so equal +// generations cannot distinguish the callers — the stale-image apply +// lands successfully and clobbers the fresh-image apply. +// +// The fix is upstream of this layer: freeze MCPServerGeneration per pod +// (downward-API env var) so the two callers carry different ourGen +// values and the gate fires correctly. This test pins down what the gate +// cannot defend against alone; it continues to pass after the fix +// because the production scenario it models can no longer occur. +func TestDeployWorkload_EqualGenerationDifferentImageClobbers(t *testing.T) { + t.Parallel() + + const containerName = "test-container" + const gen = int64(100) + const freshImage = "fresh-image:new" + const staleImage = "stale-image:old" + + clientset := fake.NewClientset() + client := NewClientWithConfigAndPlatformDetector( + clientset, + &rest.Config{Host: "https://fake-k8s-api.example.com"}, + &mockPlatformDetector{platform: PlatformKubernetes}, + ) + client.waitForStatefulSetReadyFunc = mockWaitForStatefulSetReady + client.namespaceFunc = func() string { return defaultNamespace } + + options := runtime.NewDeployWorkloadOptions() + options.RunConfigMCPServerGeneration = gen + + // Fresh-image proxyrunner pod applies first. + _, err := client.DeployWorkload( + t.Context(), + freshImage, containerName, nil, + map[string]string{}, map[string]string{}, + nil, "streamable-http", options, false, + ) + require.NoError(t, err) + + // Stale-image proxyrunner pod (restarted old-RS pod that re-read the + // live ConfigMap) applies second with the SAME generation. + _, err = client.DeployWorkload( + t.Context(), + staleImage, containerName, nil, + map[string]string{}, map[string]string{}, + nil, "streamable-http", options, false, + ) + require.NoError(t, err) + + sts, err := clientset.AppsV1().StatefulSets(defaultNamespace).Get( + t.Context(), containerName, metav1.GetOptions{}, + ) + require.NoError(t, err) + require.NotEmpty(t, sts.Spec.Template.Spec.Containers) + assert.Equal(t, staleImage, sts.Spec.Template.Spec.Containers[0].Image, + "stale apply must clobber the fresh image when generations are equal; "+ + "the gate cannot defend against this and the fix must live upstream "+ + "(see issue #5360)") +} diff --git a/pkg/container/runtime/registry.go b/pkg/container/runtime/registry.go index 5c2053259b..52f4394acb 100644 --- a/pkg/container/runtime/registry.go +++ b/pkg/container/runtime/registry.go @@ -110,21 +110,6 @@ func RegisterRuntime(info *Info) { DefaultRegistry.Register(info) } -// GetRegisteredRuntime returns the Info from the DefaultRegistry for the given name. -func GetRegisteredRuntime(name string) *Info { - return DefaultRegistry.Get(name) -} - -// IsRuntimeRegistered returns true if a runtime is registered in the DefaultRegistry. -func IsRuntimeRegistered(name string) bool { - return DefaultRegistry.IsRegistered(name) -} - -// RegisteredRuntimes returns all runtimes from the DefaultRegistry. -func RegisteredRuntimes() []*Info { - return DefaultRegistry.All() -} - // RegisteredRuntimesByPriority returns all runtimes from the DefaultRegistry // sorted by priority. func RegisteredRuntimesByPriority() []*Info { diff --git a/pkg/container/runtime/types.go b/pkg/container/runtime/types.go index 04838e73a4..b65c518820 100644 --- a/pkg/container/runtime/types.go +++ b/pkg/container/runtime/types.go @@ -277,6 +277,11 @@ type DeployWorkloadOptions struct { // blocked by default in the egress proxy even when InsecureAllowAll is set. // Only applicable to Docker deployments with network isolation enabled. AllowDockerGateway bool + + // RunConfigMCPServerGeneration is the monotonic version stamp from the source RunConfig + // (the MCPServer .metadata.generation). K8s runtime uses it to refuse apply when the + // StatefulSet is already stamped with a strictly greater value. + RunConfigMCPServerGeneration int64 } // ScalingConfig holds horizontal-scaling knobs threaded from RunConfig down to diff --git a/pkg/container/templates/go.tmpl b/pkg/container/templates/go.tmpl index a0eaef9b2e..ce62e632ce 100644 --- a/pkg/container/templates/go.tmpl +++ b/pkg/container/templates/go.tmpl @@ -74,7 +74,7 @@ RUN package="{{.MCPPackage}}"; \ {{end}} # Final stage - minimal runtime image -FROM index.docker.io/library/alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 +FROM index.docker.io/library/alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 {{if .CACertContent}} # Add custom CA certificate for runtime diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go index c0f8e9b5bc..d3c3eca0ff 100644 --- a/pkg/export/k8s.go +++ b/pkg/export/k8s.go @@ -167,9 +167,12 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1beta1.MCPServer, error) // parseVolumeString parses a volume string in the format "host-path:container-path[:ro]" func parseVolumeString(volStr string, index int) (v1beta1.Volume, error) { parts := strings.Split(volStr, ":") - if len(parts) < 2 { + if len(parts) < 2 || len(parts) > 3 { return v1beta1.Volume{}, fmt.Errorf("invalid volume format, expected 'host-path:container-path[:ro]'") } + if len(parts) == 3 && parts[2] != "ro" { + return v1beta1.Volume{}, fmt.Errorf("invalid volume mode %q, expected 'ro'", parts[2]) + } volume := v1beta1.Volume{ Name: fmt.Sprintf("volume-%d", index), diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go index a3070c3f16..3add1fcfb9 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -306,6 +306,18 @@ func TestParseVolumeString(t *testing.T) { index: 0, wantErr: true, }, + { + name: "invalid format - too many fields", + volStr: "/host/path:/container/path:ro:extra", + index: 0, + wantErr: true, + }, + { + name: "invalid format - unknown mode", + volStr: "/host/path:/container/path:rw", + index: 0, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/json/any.go b/pkg/json/any.go index e055629569..47a4dbb49e 100644 --- a/pkg/json/any.go +++ b/pkg/json/any.go @@ -12,7 +12,6 @@ import ( "fmt" "gopkg.in/yaml.v3" - "k8s.io/apimachinery/pkg/runtime" ) // Data stores JSON-compatible data of type T. It supports both JSON and YAML @@ -143,11 +142,6 @@ type Any = Data[any] // +kubebuilder:validation:Type=object type Map = Data[map[string]any] -// NewData creates a Data[T] from a value. -func NewData[T any](v T) Data[T] { - return Data[T]{Value: v} -} - // NewAny creates an Any (Data[any]) from a value. // This is a convenience function for tests and programmatic use. func NewAny(v any) Any { @@ -159,42 +153,6 @@ func NewMap(m map[string]any) Map { return Map{Value: m} } -// MustParse parses a JSON string into an Any. -// This is a convenience function for tests. Panics if parsing fails. -func MustParse(jsonStr string) Any { - var v any - if err := stdjson.Unmarshal([]byte(jsonStr), &v); err != nil { - panic(fmt.Sprintf("json.MustParse: failed to parse JSON: %v", err)) - } - return Any{Value: v} -} - -// FromRawExtension creates an Any from runtime.RawExtension. -// Returns an error if the JSON cannot be unmarshaled. -func FromRawExtension(ext runtime.RawExtension) (Any, error) { - if len(ext.Raw) == 0 { - return Any{}, nil - } - var v any - if err := stdjson.Unmarshal(ext.Raw, &v); err != nil { - return Any{}, fmt.Errorf("failed to unmarshal RawExtension: %w", err) - } - return Any{Value: v}, nil -} - -// MapFromRawExtension creates a Map from runtime.RawExtension. -// Returns an error if the JSON cannot be unmarshaled. -func MapFromRawExtension(ext runtime.RawExtension) (Map, error) { - if len(ext.Raw) == 0 { - return Map{}, nil - } - var v map[string]any - if err := stdjson.Unmarshal(ext.Raw, &v); err != nil { - return Map{}, fmt.Errorf("failed to unmarshal RawExtension as map: %w", err) - } - return Map{Value: v}, nil -} - // ToMap returns the data as a map[string]any. // This is a convenience method for Any types. // Returns nil if there is no data or if the data is not a map. diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index 336b8195fa..aee1c0ea8e 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -7,7 +7,6 @@ import ( "fmt" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -77,32 +76,3 @@ func newControllerRuntimeClientWithConfig(config *rest.Config, scheme *runtime.S return k8sClient, nil } - -// NewDynamicClient creates a new dynamic client for working with arbitrary resources. -// Use this when you need to work with resources without compile-time type information, -// such as discovering resources at runtime or working with unstructured data. -func NewDynamicClient() (dynamic.Interface, error) { - config, err := GetConfig() - if err != nil { - return nil, fmt.Errorf("failed to get kubernetes config: %w", err) - } - - return newDynamicClientWithConfig(config) -} - -// newDynamicClientWithConfig is the internal implementation for creating a dynamic client -func newDynamicClientWithConfig(config *rest.Config) (dynamic.Interface, error) { - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("failed to create dynamic client: %w", err) - } - - return dynamicClient, nil -} - -// IsAvailable checks if Kubernetes is available by attempting to create a client -// and verifying connectivity. -func IsAvailable() bool { - _, _, err := NewClient() - return err == nil -} diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go index 683e2f3efd..b240037a5d 100644 --- a/pkg/k8s/client_test.go +++ b/pkg/k8s/client_test.go @@ -123,20 +123,6 @@ func TestNewControllerRuntimeClientWithConfig(t *testing.T) { } } -func TestNewDynamicClientWithConfig(t *testing.T) { - t.Parallel() - - t.Run("creates dynamic client from valid config", func(t *testing.T) { - t.Parallel() - - config := createTestConfig(t) - client, err := newDynamicClientWithConfig(config) - - assert.NoError(t, err) - assert.NotNil(t, client) - }) -} - func TestClientTypeCompatibility(t *testing.T) { t.Parallel() diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 5da8db81c5..d1ffc7ec09 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -113,16 +113,6 @@ func GetPort(labels map[string]string) (int, error) { return port, nil } -// GetGroup gets the group name from labels -func GetGroup(labels map[string]string) string { - return labels[LabelGroup] -} - -// SetGroup sets the group name in labels -func SetGroup(labels map[string]string, groupName string) { - labels[LabelGroup] = groupName -} - // IsAuxiliaryWorkload checks if a workload is an auxiliary workload (like inspector) // Auxiliary workloads don't follow standard workload management patterns and don't use proxy processes func IsAuxiliaryWorkload(labels map[string]string) bool { diff --git a/pkg/llm/config.go b/pkg/llm/config.go new file mode 100644 index 0000000000..9c2a0fb810 --- /dev/null +++ b/pkg/llm/config.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "errors" + "fmt" + + "github.com/stacklok/toolhive/pkg/networking" + pkgoidc "github.com/stacklok/toolhive/pkg/oidc" +) + +const ( + // DefaultProxyListenPort is the default port the localhost proxy listens on. + DefaultProxyListenPort = 14000 +) + +// OIDCConfig is a type alias for oidc.ClientConfig, holding OIDC provider +// settings and cached token state for the LLM gateway. Using a type alias +// ensures this type stays in sync with pkg/config.RegistryOAuthConfig, which +// is also an alias for the same underlying type. +type OIDCConfig = pkgoidc.ClientConfig + +// Config holds all LLM gateway settings persisted under the llm: key in +// ToolHive's config.yaml. +type Config struct { + GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` +} + +// ProxyConfig holds configuration for the localhost reverse proxy. +type ProxyConfig struct { + ListenPort int `yaml:"listen_port,omitempty" json:"listen_port,omitempty"` +} + +// ToolConfig records a tool that setup has configured, so teardown knows +// exactly what to reverse. +type ToolConfig struct { + // Tool is the canonical tool identifier (e.g. "claude-code", "cursor"). + Tool string `yaml:"tool" json:"tool"` + // Mode is the authentication mode: "direct" or "proxy". + Mode string `yaml:"mode" json:"mode"` + // ConfigPath is the absolute path to the tool's config file that was patched. + ConfigPath string `yaml:"config_path" json:"config_path"` + // EnvFilePath is the absolute path to the .env file written during setup, + // or empty if no .env file was managed for this tool. + EnvFilePath string `yaml:"env_file_path,omitempty" json:"env_file_path,omitempty"` +} + +// IsConfigured reports whether the minimum required fields are present for the +// LLM gateway to be usable: gateway URL, OIDC issuer, and OIDC client ID. +func (c *Config) IsConfigured() bool { + return c.GatewayURL != "" && c.OIDC.Issuer != "" && c.OIDC.ClientID != "" +} + +// ValidatePartial validates any fields that are explicitly set, without +// requiring the mandatory trio (gateway_url, oidc.issuer, oidc.client_id). +// Use this to catch URL format or port range errors during incremental +// configuration, before all required fields have been provided. +func (c *Config) ValidatePartial() error { + var errs []error + + if c.GatewayURL != "" { + if err := networking.ValidateHTTPSURL(c.GatewayURL); err != nil { + errs = append(errs, fmt.Errorf("gateway_url: %w", err)) + } + } + + if c.OIDC.Issuer != "" { + if err := networking.ValidateIssuerURL(c.OIDC.Issuer); err != nil { + errs = append(errs, fmt.Errorf("oidc.issuer: %w", err)) + } + } + + if c.Proxy.ListenPort != 0 && (c.Proxy.ListenPort < 1024 || c.Proxy.ListenPort > 65535) { + errs = append(errs, fmt.Errorf("proxy.listen_port must be between 1024 and 65535, got: %d", c.Proxy.ListenPort)) + } + + // Reuse networking.ValidateCallbackPort for the OIDC callback port — same + // range check (1024–65535), zero means ephemeral (auto-assigned). Pass the + // client ID so the validator applies strict availability checking for + // pre-registered clients (clientID != ""). + if err := networking.ValidateCallbackPort(c.OIDC.CallbackPort, c.OIDC.ClientID); err != nil { + errs = append(errs, fmt.Errorf("oidc.callback_port: %w", err)) + } + + return errors.Join(errs...) +} + +// Validate performs full validation of the LLM config, including HTTPS +// enforcement, port range checks, and OIDC field requirements. +func (c *Config) Validate() error { + var errs []error + + if c.GatewayURL == "" { + errs = append(errs, errors.New("gateway_url is required")) + } + + if c.OIDC.Issuer == "" { + errs = append(errs, errors.New("oidc.issuer is required")) + } + + if c.OIDC.ClientID == "" { + errs = append(errs, errors.New("oidc.client_id is required")) + } + + return errors.Join(append(errs, c.ValidatePartial())...) +} + +// EffectiveProxyPort returns the configured proxy listen port, or +// DefaultProxyListenPort if none is set. +func (c *Config) EffectiveProxyPort() int { + if c.Proxy.ListenPort > 0 { + return c.Proxy.ListenPort + } + return DefaultProxyListenPort +} diff --git a/pkg/llm/config_test.go b/pkg/llm/config_test.go new file mode 100644 index 0000000000..f2d69f9a4d --- /dev/null +++ b/pkg/llm/config_test.go @@ -0,0 +1,339 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "testing" +) + +func TestConfig_IsConfigured(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + want bool + }{ + { + name: "fully configured", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + }, + want: true, + }, + { + name: "missing gateway URL", + cfg: Config{ + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + }, + want: false, + }, + { + name: "missing issuer", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + ClientID: "my-client", + }, + }, + want: false, + }, + { + name: "missing client ID", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + }, + }, + want: false, + }, + { + name: "empty config", + cfg: Config{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.cfg.IsConfigured() + if got != tt.want { + t.Errorf("IsConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + t.Parallel() + + valid := Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + } + + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "valid config", + cfg: valid, + wantErr: false, + }, + { + name: "missing gateway URL", + cfg: Config{ + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + }, + wantErr: true, + }, + { + name: "HTTP gateway URL rejected", + cfg: Config{ + GatewayURL: "http://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + }, + wantErr: true, + }, + { + name: "missing issuer", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + ClientID: "my-client", + }, + }, + wantErr: true, + }, + { + name: "missing client ID", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + }, + }, + wantErr: true, + }, + { + name: "proxy port below range", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + Proxy: ProxyConfig{ListenPort: 80}, + }, + wantErr: true, + }, + { + name: "proxy port above range", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + Proxy: ProxyConfig{ListenPort: 99999}, + }, + wantErr: true, + }, + { + name: "valid custom proxy port", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + }, + Proxy: ProxyConfig{ListenPort: 8080}, + }, + wantErr: false, + }, + { + name: "callback port below range", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + CallbackPort: 100, + }, + }, + wantErr: true, + }, + { + name: "valid callback port", + cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + CallbackPort: 9000, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfig_ValidatePartial(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "empty config is valid", + cfg: Config{}, + wantErr: false, + }, + { + name: "valid gateway URL only", + cfg: Config{GatewayURL: "https://llm.example.com"}, + wantErr: false, + }, + { + name: "HTTP gateway URL rejected", + cfg: Config{GatewayURL: "http://llm.example.com"}, + wantErr: true, + }, + { + name: "valid issuer only", + cfg: Config{OIDC: OIDCConfig{Issuer: "https://auth.example.com"}}, + wantErr: false, + }, + { + name: "invalid issuer rejected", + cfg: Config{OIDC: OIDCConfig{Issuer: "not-a-url"}}, + wantErr: true, + }, + { + name: "proxy port below range rejected", + cfg: Config{Proxy: ProxyConfig{ListenPort: 80}}, + wantErr: true, + }, + { + name: "proxy port above range rejected", + cfg: Config{Proxy: ProxyConfig{ListenPort: 99999}}, + wantErr: true, + }, + { + name: "valid proxy port accepted", + cfg: Config{Proxy: ProxyConfig{ListenPort: 8080}}, + wantErr: false, + }, + { + name: "callback port below range rejected", + cfg: Config{OIDC: OIDCConfig{CallbackPort: 100}}, + wantErr: true, + }, + { + name: "valid callback port accepted", + cfg: Config{OIDC: OIDCConfig{CallbackPort: 9000}}, + wantErr: false, + }, + { + name: "multiple invalid fields all reported", + cfg: Config{ + GatewayURL: "http://llm.example.com", + Proxy: ProxyConfig{ListenPort: 80}, + }, + wantErr: true, + }, + { + name: "required fields absent but valid values accepted", + cfg: Config{ + GatewayURL: "https://llm.example.com", + Proxy: ProxyConfig{ListenPort: 8080}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cfg.ValidatePartial() + if (err != nil) != tt.wantErr { + t.Errorf("ValidatePartial() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfig_EffectiveProxyPort(t *testing.T) { + t.Parallel() + + t.Run("returns default when not set", func(t *testing.T) { + t.Parallel() + cfg := Config{} + if got := cfg.EffectiveProxyPort(); got != DefaultProxyListenPort { + t.Errorf("EffectiveProxyPort() = %d, want %d", got, DefaultProxyListenPort) + } + }) + + t.Run("returns configured port", func(t *testing.T) { + t.Parallel() + cfg := Config{Proxy: ProxyConfig{ListenPort: 8080}} + if got := cfg.EffectiveProxyPort(); got != 8080 { + t.Errorf("EffectiveProxyPort() = %d, want 8080", got) + } + }) +} + +func TestOIDCConfig_EffectiveScopes(t *testing.T) { + t.Parallel() + + t.Run("returns defaults when not set", func(t *testing.T) { + t.Parallel() + cfg := OIDCConfig{} + scopes := cfg.EffectiveScopes() + if len(scopes) == 0 { + t.Error("EffectiveScopes() returned empty slice for zero-value config") + } + }) + + t.Run("returns configured scopes", func(t *testing.T) { + t.Parallel() + cfg := OIDCConfig{Scopes: []string{"openid", "email"}} + scopes := cfg.EffectiveScopes() + if len(scopes) != 2 || scopes[0] != "openid" || scopes[1] != "email" { + t.Errorf("EffectiveScopes() = %v, want [openid email]", scopes) + } + }) +} diff --git a/pkg/llm/doc.go b/pkg/llm/doc.go new file mode 100644 index 0000000000..6a4f9d2e8e --- /dev/null +++ b/pkg/llm/doc.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package llm provides configuration types and public API for the thv llm +// command group, which bridges AI coding tools to OIDC-protected LLM gateways. +// +// Two authentication modes are planned: +// - Proxy mode: a localhost reverse proxy that injects fresh OIDC tokens for +// tools that only accept static API keys (e.g. Cursor). +// - Token helper mode: thv llm token prints a fresh JWT to stdout, suitable +// for use as apiKeyHelper or auth.command in OIDC-capable tools (e.g. Claude Code). +// +// Both modes are under active development; the corresponding CLI commands +// currently return not-implemented errors. +// +// Configuration is persisted in ToolHive's config.yaml under the llm: key via +// the existing UpdateConfig() mechanism. +package llm diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go new file mode 100644 index 0000000000..9dd6b03949 --- /dev/null +++ b/pkg/llm/manage.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "context" + "fmt" + "io" + + pkgsecrets "github.com/stacklok/toolhive/pkg/secrets" +) + +// SetFields applies the non-zero fields from the provided options to the +// config and validates the result. If the mandatory trio (gateway_url, +// oidc.issuer, oidc.client_id) is present after the update, full validation +// runs; otherwise only format/range validation runs to catch bad values early +// while still allowing incremental configuration. +func (c *Config) SetFields(opts SetOptions) error { + if opts.GatewayURL != "" { + c.GatewayURL = opts.GatewayURL + } + if opts.Issuer != "" { + c.OIDC.Issuer = opts.Issuer + } + if opts.ClientID != "" { + c.OIDC.ClientID = opts.ClientID + } + if opts.Audience != "" { + c.OIDC.Audience = opts.Audience + } + if opts.ProxyPort != 0 { + c.Proxy.ListenPort = opts.ProxyPort + } + if opts.CallbackPort != 0 { + c.OIDC.CallbackPort = opts.CallbackPort + } + if opts.TLSSkipVerify != nil { + c.TLSSkipVerify = *opts.TLSSkipVerify + } + + if !c.IsConfigured() { + return c.ValidatePartial() + } + return c.Validate() +} + +// SetOptions carries the flag values for the "config set" command. +// Zero values are treated as "not provided" and leave the existing config +// field unchanged. TLSSkipVerify uses a pointer so that false can be +// distinguished from "not provided" (enabling explicit clear via config set). +type SetOptions struct { + GatewayURL string + Issuer string + ClientID string + Audience string + ProxyPort int + CallbackPort int + TLSSkipVerify *bool // nil = not provided; &false = explicitly disable +} + +// DeleteCachedTokens removes all cached OIDC tokens stored under the LLM +// scope via the provided secrets provider. It is a no-op if the provider does +// not support listing or deletion (e.g. the environment provider), since such +// providers cannot hold cached tokens. +func DeleteCachedTokens(ctx context.Context, provider pkgsecrets.Provider) error { + scoped := pkgsecrets.NewScopedProvider(provider, pkgsecrets.ScopeLLM) + caps := scoped.Capabilities() + if !caps.CanList || !caps.CanDelete { + return nil + } + descs, err := scoped.ListSecrets(ctx) + if err != nil { + return err + } + if len(descs) == 0 { + return nil + } + names := make([]string, len(descs)) + for i, d := range descs { + names[i] = d.Key + } + return scoped.DeleteSecrets(ctx, names) +} + +// Show writes a human-readable representation of the config to w. +// If the config is not yet configured it prints a hint to run "config set". +func (c *Config) Show(w io.Writer) error { + if !c.IsConfigured() { + _, err := fmt.Fprintln(w, "LLM gateway is not configured. Run \"thv llm config set\" to configure it.") + return err + } + + var err error + writef := func(format string, args ...any) { + if err == nil { + _, err = fmt.Fprintf(w, format, args...) + } + } + + writef("Gateway URL: %s\n", c.GatewayURL) + writef("OIDC Issuer: %s\n", c.OIDC.Issuer) + writef("OIDC Client: %s\n", c.OIDC.ClientID) + if c.OIDC.Audience != "" { + writef("Audience: %s\n", c.OIDC.Audience) + } + writef("Proxy Port: %d\n", c.EffectiveProxyPort()) + writef("Scopes: %v\n", c.OIDC.EffectiveScopes()) + if c.TLSSkipVerify { + writef("TLS Skip Verify: true (WARNING: certificate verification disabled)\n") + } + if len(c.ConfiguredTools) > 0 { + writef("Configured tools:\n") + for _, t := range c.ConfiguredTools { + writef(" - %s (%s) %s\n", t.Tool, t.Mode, t.ConfigPath) + } + } + return err +} diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go new file mode 100644 index 0000000000..9235317116 --- /dev/null +++ b/pkg/llm/manage_test.go @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/secrets" + secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" +) + +// ── SetFields ──────────────────────────────────────────────────────────────── + +func TestConfig_SetFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base Config + opts SetOptions + want Config + wantErr bool + }{ + { + name: "sets all fields", + opts: SetOptions{ + GatewayURL: "https://gw.example.com", + Issuer: "https://auth.example.com", + ClientID: "client1", + Audience: "aud1", + ProxyPort: 9000, + CallbackPort: 9001, + }, + want: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "client1", + Audience: "aud1", + CallbackPort: 9001, + }, + Proxy: ProxyConfig{ListenPort: 9000}, + }, + }, + { + name: "zero options leave existing fields untouched", + base: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + opts: SetOptions{}, + want: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + }, + { + name: "partial config runs partial validation — valid partial accepted", + opts: SetOptions{GatewayURL: "https://gw.example.com"}, + want: Config{GatewayURL: "https://gw.example.com"}, + }, + { + name: "partial config runs partial validation — HTTP URL rejected", + opts: SetOptions{GatewayURL: "http://gw.example.com"}, + wantErr: true, + }, + { + name: "full config runs full validation — valid config accepted", + opts: SetOptions{ + GatewayURL: "https://gw.example.com", + Issuer: "https://auth.example.com", + ClientID: "client1", + }, + want: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + }, + { + name: "full config runs full validation — invalid issuer rejected", + opts: SetOptions{GatewayURL: "https://gw.example.com", Issuer: "not-a-url", ClientID: "c"}, + wantErr: true, + }, + { + name: "out-of-range proxy port rejected during partial validation", + opts: SetOptions{ + GatewayURL: "https://gw.example.com", + ProxyPort: 80, + }, + wantErr: true, + }, + { + name: "TLSSkipVerify pointer true sets field", + opts: SetOptions{TLSSkipVerify: boolPtr(true)}, + want: Config{TLSSkipVerify: true}, + }, + { + name: "TLSSkipVerify pointer false clears field", + base: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + }, + opts: SetOptions{TLSSkipVerify: boolPtr(false)}, + want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: false}, + }, + { + name: "nil TLSSkipVerify pointer leaves existing value unchanged", + base: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + }, + opts: SetOptions{}, + want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := tt.base + err := cfg.SetFields(tt.opts) + if (err != nil) != tt.wantErr { + t.Errorf("SetFields() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if cfg.GatewayURL != tt.want.GatewayURL { + t.Errorf("GatewayURL = %q, want %q", cfg.GatewayURL, tt.want.GatewayURL) + } + if cfg.OIDC.Issuer != tt.want.OIDC.Issuer { + t.Errorf("OIDC.Issuer = %q, want %q", cfg.OIDC.Issuer, tt.want.OIDC.Issuer) + } + if cfg.OIDC.ClientID != tt.want.OIDC.ClientID { + t.Errorf("OIDC.ClientID = %q, want %q", cfg.OIDC.ClientID, tt.want.OIDC.ClientID) + } + if cfg.OIDC.Audience != tt.want.OIDC.Audience { + t.Errorf("OIDC.Audience = %q, want %q", cfg.OIDC.Audience, tt.want.OIDC.Audience) + } + if cfg.Proxy.ListenPort != tt.want.Proxy.ListenPort { + t.Errorf("Proxy.ListenPort = %d, want %d", cfg.Proxy.ListenPort, tt.want.Proxy.ListenPort) + } + if cfg.OIDC.CallbackPort != tt.want.OIDC.CallbackPort { + t.Errorf("OIDC.CallbackPort = %d, want %d", cfg.OIDC.CallbackPort, tt.want.OIDC.CallbackPort) + } + if cfg.TLSSkipVerify != tt.want.TLSSkipVerify { + t.Errorf("TLSSkipVerify = %v, want %v", cfg.TLSSkipVerify, tt.want.TLSSkipVerify) + } + }) + } +} + +func boolPtr(b bool) *bool { return &b } + +// ── DeleteCachedTokens ─────────────────────────────────────────────────────── + +func TestDeleteCachedTokens(t *testing.T) { + t.Parallel() + + // llmScopeKey returns the fully-scoped key for an LLM secret, matching the + // __thv_llm_ prefix that ScopedProvider adds. + llmScopeKey := func(name string) string { + return secrets.SystemKeyPrefix + string(secrets.ScopeLLM) + "_" + name + } + + tests := []struct { + name string + caps secrets.ProviderCapabilities + setupMock func(m *secretsmocks.MockProvider) + wantErr bool + }{ + { + name: "no-op when provider cannot list", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: false}, + }, + { + name: "no-op when provider cannot delete", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: false, CanList: true}, + }, + { + name: "no-op when no secrets exist under LLM scope", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true}, + setupMock: func(m *secretsmocks.MockProvider) { + // Provider returns secrets from other scopes — LLM scope is empty. + m.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{ + {Key: "__thv_registry_token"}, + }, nil) + }, + }, + { + name: "deletes all secrets under LLM scope", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true}, + setupMock: func(m *secretsmocks.MockProvider) { + m.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{ + {Key: llmScopeKey("refresh_token")}, + {Key: llmScopeKey("access_token")}, + {Key: "__thv_registry_token"}, // different scope, must be ignored + }, nil) + m.EXPECT().DeleteSecrets(gomock.Any(), gomock.InAnyOrder([]string{ + llmScopeKey("refresh_token"), + llmScopeKey("access_token"), + })).Return(nil) + }, + }, + { + name: "propagates ListSecrets error", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true}, + setupMock: func(m *secretsmocks.MockProvider) { + m.EXPECT().ListSecrets(gomock.Any()).Return(nil, errors.New("storage unavailable")) + }, + wantErr: true, + }, + { + name: "propagates DeleteSecrets error", + caps: secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true}, + setupMock: func(m *secretsmocks.MockProvider) { + m.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{ + {Key: llmScopeKey("refresh_token")}, + }, nil) + m.EXPECT().DeleteSecrets(gomock.Any(), gomock.Any()).Return(errors.New("delete failed")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT().Capabilities().Return(tt.caps).AnyTimes() + if tt.setupMock != nil { + tt.setupMock(mock) + } + + err := DeleteCachedTokens(context.Background(), mock) + if (err != nil) != tt.wantErr { + t.Errorf("DeleteCachedTokens() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// ── Show ───────────────────────────────────────────────────────────────────── + +func TestConfig_Show(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + contains []string + absent []string + }{ + { + name: "not-configured message when empty", + cfg: Config{}, + contains: []string{"not configured"}, + }, + { + name: "shows required fields when configured", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + contains: []string{"https://gw.example.com", "https://auth.example.com", "client1"}, + absent: []string{"not configured"}, + }, + { + name: "audience shown only when set", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1", Audience: "myaud"}, + }, + contains: []string{"myaud"}, + }, + { + name: "audience absent when not set", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + absent: []string{"Audience"}, + }, + { + name: "configured tools listed when present", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + ConfiguredTools: []ToolConfig{{Tool: "cursor", Mode: "proxy", ConfigPath: "/home/user/.cursor/config.json"}}, + }, + contains: []string{"cursor", "proxy", "/home/user/.cursor/config.json"}, + }, + { + name: "TLS skip verify shown with warning when set", + cfg: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: true, + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + contains: []string{"TLS Skip Verify", "true", "WARNING"}, + }, + { + name: "TLS skip verify not shown when false", + cfg: Config{ + GatewayURL: "https://gw.example.com", + TLSSkipVerify: false, + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + absent: []string{"TLS Skip Verify"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := tt.cfg.Show(&buf); err != nil { + t.Fatalf("Show() returned unexpected error: %v", err) + } + out := buf.String() + for _, want := range tt.contains { + if !strings.Contains(out, want) { + t.Errorf("Show() output missing %q\ngot: %s", want, out) + } + } + for _, absent := range tt.absent { + if strings.Contains(out, absent) { + t.Errorf("Show() output should not contain %q\ngot: %s", absent, out) + } + } + }) + } +} diff --git a/pkg/llm/proxy/proxy.go b/pkg/llm/proxy/proxy.go new file mode 100644 index 0000000000..5f96bf7960 --- /dev/null +++ b/pkg/llm/proxy/proxy.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package proxy implements the LLM gateway localhost reverse proxy. +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "time" + + "github.com/stacklok/toolhive/pkg/llm" + "github.com/stacklok/toolhive/pkg/networking" +) + +// tokenFetchTimeout bounds the per-request token fetch. It is deliberately +// generous: the first request after a lazy setup ("thv llm setup --lazy") drives +// the interactive OIDC browser login through the token source, and a human needs +// time to complete it. Once a token is cached, subsequent requests are served +// from cache and return well within this bound, so the only request that ever +// approaches it is the initial login. +const tokenFetchTimeout = 3 * time.Minute + +// TokenSource obtains fresh OIDC access tokens for the LLM gateway. +// *llm.TokenSource satisfies this interface. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// Option configures optional Proxy behaviour. +type Option func(*Proxy) + +// WithTLSSkipVerify disables TLS certificate verification for the upstream +// gateway connection. This is intended for local development only (e.g., +// self-signed certificates). It must NOT be used in production. +func WithTLSSkipVerify(skip bool) Option { + return func(p *Proxy) { + if !skip { + return + } + slog.Warn("LLM proxy: TLS certificate verification disabled for upstream — non-production use only") + // Clone http.DefaultTransport so we preserve all production defaults + // (timeouts, ProxyFromEnvironment, HTTP/2, connection pooling) and only + // toggle InsecureSkipVerify. + base := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // DefaultTransport is always *http.Transport + if base.TLSClientConfig == nil { + base.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + base.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // G402: intentional for local dev with self-signed certs + p.transport = base + } +} + +// tokenContextKey is the context key used to pass the injected token to the +// Rewrite hook without modifying the original incoming request. +type tokenContextKey struct{} + +// proxyTransport is an http.RoundTripper that delegates to Proxy.transport, +// falling back to http.DefaultTransport. Using a pointer to Proxy allows tests +// to set p.transport after New() without rebuilding the ReverseProxy. +type proxyTransport Proxy + +func (pt *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if pt.transport != nil { + return pt.transport.RoundTrip(req) + } + return http.DefaultTransport.RoundTrip(req) +} + +// Proxy is a localhost reverse proxy that strips incoming Authorization headers +// and injects fresh OIDC tokens before forwarding to the LLM gateway. +type Proxy struct { + cfg *llm.Config + gatewayURL *url.URL + tokenSource TokenSource + listener net.Listener + server *http.Server + rp *httputil.ReverseProxy + // transport overrides the HTTP transport used to reach the upstream gateway. + // nil means http.DefaultTransport. Set in tests to trust self-signed TLS certs. + transport http.RoundTripper +} + +// New creates a Proxy and binds the TCP listener immediately so that Addr() +// returns the correct address before Start is called. Returns an error if +// GatewayURL is unparsable, the listen address is not loopback, or the port +// is already in use. +func New(cfg *llm.Config, ts TokenSource, opts ...Option) (*Proxy, error) { + if cfg == nil { + return nil, fmt.Errorf("cfg must not be nil") + } + if ts == nil { + return nil, fmt.Errorf("ts must not be nil") + } + gatewayURL, err := url.Parse(cfg.GatewayURL) + if err != nil { + return nil, fmt.Errorf("invalid gateway URL %q: %w", cfg.GatewayURL, err) + } + if gatewayURL.Host == "" { + return nil, fmt.Errorf("invalid gateway URL %q: must have scheme and host", cfg.GatewayURL) + } + if gatewayURL.Scheme != "https" { + return nil, fmt.Errorf("gateway URL must use HTTPS, got scheme %q", gatewayURL.Scheme) + } + listenAddr := fmt.Sprintf("127.0.0.1:%d", cfg.EffectiveProxyPort()) + if err := networking.ValidateLoopbackAddress(listenAddr); err != nil { + return nil, err + } + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("LLM proxy: failed to listen on %s: %w", listenAddr, err) + } + + p := &Proxy{ + cfg: cfg, + gatewayURL: gatewayURL, + tokenSource: ts, + listener: ln, + } + for _, o := range opts { + o(p) + } + p.rp = &httputil.ReverseProxy{ + // Transport delegates to p.transport so tests can swap it after New(). + Transport: (*proxyTransport)(p), + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(p.gatewayURL) + // Always strip the client-supplied Authorization header first so it + // is never forwarded upstream, even if the injected token is empty. + pr.Out.Header.Del("Authorization") + if tok, _ := pr.Out.Context().Value(tokenContextKey{}).(string); tok != "" { + pr.Out.Header.Set("Authorization", "Bearer "+tok) + } + }, + FlushInterval: -1, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + slog.Error("LLM proxy upstream error", "error", err, "path", r.URL.Path) + http.Error(w, "upstream error", http.StatusBadGateway) + }, + } + return p, nil +} + +// Addr returns the actual bound listen address (e.g. "127.0.0.1:14000"). +func (p *Proxy) Addr() string { + return p.listener.Addr().String() +} + +// handler returns an http.Handler that injects a fresh OIDC token and proxies +// the request to the upstream gateway. +func (p *Proxy) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guard against DNS-rebinding attacks: reject requests whose Host header + // does not resolve to a loopback address. A loopback-only bind prevents + // external TCP connections but doesn't stop browser JS from rebinding an + // attacker-controlled hostname to 127.0.0.1 and minting OIDC tokens. + if !networking.IsLoopbackHost(r.Host) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + tokenCtx, cancel := context.WithTimeout(r.Context(), tokenFetchTimeout) + defer cancel() + token, err := p.tokenSource.Token(tokenCtx) + if err != nil { + if errors.Is(err, llm.ErrTokenRequired) { + slog.Error("LLM proxy: re-authentication required") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, + `{"error":{"message":"LLM gateway authentication required: `+ + `run 'thv llm setup' to log in","type":"authentication_error","code":"token_required"}}`, + ) + } else { + slog.Error("LLM proxy: failed to obtain token", "reason", llm.SanitizeTokenError(err)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, `{"error":{"message":"LLM gateway token fetch failed","type":"server_error"}}`) + } + return + } + if token == "" { + slog.Error("LLM proxy: token source returned empty token") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, `{"error":{"message":"LLM gateway token fetch failed","type":"server_error"}}`) + return + } + + // Pass the token to the Rewrite hook via context so it can be set on + // pr.Out.Header without cloning the incoming request. + ctx := context.WithValue(r.Context(), tokenContextKey{}, token) + p.rp.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Start begins serving using the listener bound in New. It blocks until ctx is +// cancelled, then performs a graceful shutdown with a 5-second timeout. +func (p *Proxy) Start(ctx context.Context) error { + p.server = &http.Server{ + Handler: p.handler(), + ReadHeaderTimeout: 30 * time.Second, + } + + slog.Info("LLM proxy started", "addr", p.Addr(), "gateway", p.cfg.GatewayURL) + + serveErr := make(chan error, 1) + go func() { + if err := p.server.Serve(p.listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + close(serveErr) + }() + + select { + case err := <-serveErr: + _ = p.server.Close() + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := p.server.Shutdown(shutdownCtx); err != nil { + slog.Warn("LLM proxy: graceful shutdown timed out, forcing close", "error", err) + _ = p.server.Close() + } + return nil + } +} diff --git a/pkg/llm/proxy/proxy_test.go b/pkg/llm/proxy/proxy_test.go new file mode 100644 index 0000000000..11516f6638 --- /dev/null +++ b/pkg/llm/proxy/proxy_test.go @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llm" +) + +// stubTokenSource is a test double for TokenSource. +type stubTokenSource struct { + token string + err error +} + +func (s *stubTokenSource) Token(_ context.Context) (string, error) { + return s.token, s.err +} + +// testClient returns an http.Client with a 5-second timeout for use in tests. +func testClient() *http.Client { + return &http.Client{Timeout: 5 * time.Second} +} + +// loopbackRequest returns a GET server request with Host set to 127.0.0.1 so +// it passes the DNS-rebinding guard in the proxy handler. +func loopbackRequest(target string) *http.Request { + req := httptest.NewRequest(http.MethodGet, target, nil) + req.Host = "127.0.0.1" + return req +} + +// newTLSGateway starts a TLS test server and returns a Proxy configured to +// trust its self-signed certificate. +func newTLSGateway(t *testing.T, handler http.Handler) *Proxy { + t.Helper() + gateway := httptest.NewTLSServer(handler) + t.Cleanup(gateway.Close) + + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "test-token"}) + require.NoError(t, err) + p.transport = gateway.Client().Transport + t.Cleanup(func() { _ = p.listener.Close() }) + return p +} + +// freePort returns an available TCP port on loopback. +// It binds then immediately closes to discover the port number; there is a +// small TOCTOU window before the caller binds, which is acceptable in tests. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + return port +} + +func TestNew_RejectsHTTPGatewayURL(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: "http://gateway.example.com", + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + _, err := New(cfg, &stubTokenSource{token: "tok"}) + require.ErrorContains(t, err, "must use HTTPS") +} + +func TestNew_ValidConfig(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: "https://gateway.example.com", + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}) + require.NoError(t, err) + require.NotNil(t, p) + // Addr must be a valid TCP address on loopback. + host, _, splitErr := net.SplitHostPort(p.Addr()) + require.NoError(t, splitErr) + assert.Equal(t, "127.0.0.1", host) + // Close the listener to free the port. + _ = p.listener.Close() +} + +func TestHandler_InjectsToken(t *testing.T) { + t.Parallel() + var ( + mu sync.Mutex + receivedAuth string + ) + p := newTLSGateway(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + p.tokenSource = &stubTokenSource{token: "fresh-token-abc"} + + req := loopbackRequest("/v1/models") + req.Header.Set("Authorization", "Bearer old-token") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Bearer fresh-token-abc", receivedAuth, + "gateway should receive the fresh token, not the original") +} + +func TestHandler_StripsIncomingAuthorization(t *testing.T) { + t.Parallel() + var ( + mu sync.Mutex + receivedAuth string + ) + p := newTLSGateway(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + p.tokenSource = &stubTokenSource{token: "injected"} + + req := loopbackRequest("/v1/models") + req.Header.Set("Authorization", "Bearer user-supplied-token") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + mu.Lock() + defer mu.Unlock() + assert.NotContains(t, receivedAuth, "user-supplied-token", + "incoming Authorization must be stripped") + assert.Equal(t, "Bearer injected", receivedAuth) +} + +func TestHandler_RejectsDNSRebindingHost(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: "https://gateway.example.com", + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}) + require.NoError(t, err) + defer p.listener.Close() + + for _, host := range []string{"evil.com", "evil.com:80", "192.168.1.1", "192.168.1.1:8080"} { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Host = host + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "host %q should be rejected", host) + } + + // Legitimate loopback hosts must be allowed through. + for _, host := range []string{"127.0.0.1:14000", "localhost:14000", "[::1]:14000"} { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Host = host + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + assert.NotEqual(t, http.StatusForbidden, w.Code, "host %q should be allowed", host) + } +} + +func TestHandler_Returns502OnTokenError(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: "https://gateway.example.com", + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{err: errors.New("token unavailable")}) + require.NoError(t, err) + defer p.listener.Close() + + req := loopbackRequest("/v1/chat/completions") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), `"type":"server_error"`) + assert.NotContains(t, w.Body.String(), "token unavailable", + "internal error detail must not be leaked to the client") +} + +func TestHandler_Returns401WithActionableMessageOnErrTokenRequired(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: "https://gateway.example.com", + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{err: llm.ErrTokenRequired}) + require.NoError(t, err) + defer p.listener.Close() + + req := loopbackRequest("/v1/chat/completions") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + body := w.Body.String() + assert.Contains(t, body, "thv llm setup") + assert.Contains(t, body, `"type":"authentication_error"`) + assert.Contains(t, body, `"code":"token_required"`) +} + +// startTestProxy starts the proxy against the given TLS gateway using a real +// TCP listener and returns the proxy's base URL. The proxy is stopped when +// t.Cleanup runs. +func startTestProxy(t *testing.T, gateway *httptest.Server) string { + t.Helper() + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "test-token"}) + require.NoError(t, err) + p.transport = gateway.Client().Transport + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + serveErr := make(chan error, 1) + go func() { serveErr <- p.Start(ctx) }() + t.Cleanup(func() { + cancel() + if err := <-serveErr; err != nil { + t.Errorf("proxy exited with unexpected error: %v", err) + } + }) + + // Wait until the HTTP server is actually serving — a TCP dial can succeed + // as soon as the listener is bound (kernel backlog), before Serve() runs. + // An HTTP response (any status) confirms the handler loop is active. + // The request must have a loopback Host to pass the DNS-rebinding guard. + addr := p.Addr() + client := &http.Client{Timeout: 100 * time.Millisecond} + require.Eventually(t, func() bool { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://"+addr+"/readyz", nil) + req.Host = "127.0.0.1" + resp, err := client.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return true + }, 2*time.Second, 10*time.Millisecond, "proxy did not start in time") + + return "http://" + p.Addr() +} + +func TestProxy_ForwardsPathQueryAndBody(t *testing.T) { + t.Parallel() + var ( + mu sync.Mutex + gotPath string + gotQuery string + gotBody []byte + gotAuth string + ) + gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotBody = b + gotAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer gateway.Close() + + proxyURL := startTestProxy(t, gateway) + + body := strings.NewReader(`{"model":"gpt-4"}`) + resp, err := testClient().Post(proxyURL+"/v1/chat/completions?stream=true", "application/json", body) + require.NoError(t, err) + defer resp.Body.Close() + + // The HTTP response completing guarantees the handler has returned, so the + // mutex is not held at this point. Reading under the lock is still correct. + mu.Lock() + defer mu.Unlock() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "/v1/chat/completions", gotPath) + assert.Equal(t, "stream=true", gotQuery) + assert.JSONEq(t, `{"model":"gpt-4"}`, string(gotBody)) + assert.Equal(t, "Bearer test-token", gotAuth) +} + +func TestProxy_PassesThroughSSE(t *testing.T) { + t.Parallel() + gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + require.True(t, ok) + for _, chunk := range []string{ + "data: {\"id\":\"1\"}\n\n", + "data: {\"id\":\"2\"}\n\n", + "data: [DONE]\n\n", + } { + _, _ = fmt.Fprint(w, chunk) + flusher.Flush() + } + })) + defer gateway.Close() + + proxyURL := startTestProxy(t, gateway) + + resp, err := testClient().Get(proxyURL + "/v1/chat/completions") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(got), "data: {\"id\":\"1\"}") + assert.Contains(t, string(got), "data: [DONE]") +} + +func TestWithTLSSkipVerify(t *testing.T) { + t.Parallel() + + // Self-signed upstream — default transport cannot verify this certificate. + gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(gateway.Close) + + t.Run("default transport rejects self-signed cert", func(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.listener.Close() }) + + req := loopbackRequest("/v1/models") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + // Certificate verification failure surfaces as 502 Bad Gateway. + assert.Equal(t, http.StatusBadGateway, w.Code) + }) + + t.Run("WithTLSSkipVerify(true) accepts self-signed cert", func(t *testing.T) { + t.Parallel() + cfg := &llm.Config{ + GatewayURL: gateway.URL, + Proxy: llm.ProxyConfig{ListenPort: freePort(t)}, + } + p, err := New(cfg, &stubTokenSource{token: "tok"}, WithTLSSkipVerify(true)) + require.NoError(t, err) + t.Cleanup(func() { _ = p.listener.Close() }) + + req := loopbackRequest("/v1/models") + w := httptest.NewRecorder() + p.handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestProxy_PassesThroughErrorResponses(t *testing.T) { + t.Parallel() + for _, statusCode := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError} { + statusCode := statusCode + t.Run(http.StatusText(statusCode), func(t *testing.T) { + t.Parallel() + gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "upstream error", statusCode) + })) + defer gateway.Close() + + proxyURL := startTestProxy(t, gateway) + + resp, err := testClient().Get(proxyURL + "/v1/models") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, statusCode, resp.StatusCode, "error response must pass through unmodified") + }) + } +} diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go new file mode 100644 index 0000000000..faef0dc42c --- /dev/null +++ b/pkg/llm/setup.go @@ -0,0 +1,533 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/llmgateway" + pkgsecrets "github.com/stacklok/toolhive/pkg/secrets" +) + +// LoginFunc performs the interactive OIDC login during setup. It is a +// parameter so that tests can inject a no-op without touching the keyring. +type LoginFunc func(ctx context.Context, cfg *Config) error + +// GatewayManager is the subset of client.ClientManager used by Setup and +// Teardown. Defined here so pkg/llm does not import pkg/client. +type GatewayManager interface { + // DetectedLLMGatewayClients returns tool names for all installed LLM-gateway-capable tools. + DetectedLLMGatewayClients() []string + // ConfigureLLMGateway patches the tool's config file and returns the config path. + ConfigureLLMGateway(clientType string, cfg llmgateway.ApplyConfig) (string, error) + // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. + LLMGatewayModeFor(clientType string) string + // ConfigureEnvFile writes .env file entries for the client and returns the + // env file path. Returns ("", nil) when the client has no env-file entries. + ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) + // RevertEnvFile removes the .env file entries that setup wrote. envFilePath + // is the value returned by ConfigureEnvFile; a no-op when empty. + RevertEnvFile(clientType, envFilePath string) error + // RevertLLMGateway removes the LLM gateway settings from the tool's config file. + RevertLLMGateway(clientType, configPath string) error +} + +// ConfigUpdater is the subset of config.Provider used by Setup and Teardown. +// Defined here so pkg/llm does not import pkg/config. +type ConfigUpdater interface { + // GetLLMConfig returns the current LLM section of the config. + GetLLMConfig() Config + // UpdateLLMConfig atomically reads, applies fn, and persists the LLM config. + UpdateLLMConfig(fn func(*Config) error) error +} + +// Setup configures detected AI tools to use the LLM gateway. +// +// When targetClient is non-empty only that client is configured; an error is +// returned if the client is not installed. Pass an empty string to configure +// all detected clients (the original behaviour). +// +// It applies inlineOpts in-memory before login so a failed login leaves no +// persisted state. Tool config files are patched only after login succeeds; +// on any persistence failure the patches are rolled back. +// +// When lazy is true, the interactive OIDC login (login) is skipped entirely: +// tool detection, config-file patching, and config persistence still run, and a +// message is printed telling the user that login will occur on first gateway +// access. lazy is intended for unattended provisioning (e.g. an MDM profile); +// it is opt-in and does not change behaviour for interactive users. +func Setup( + ctx context.Context, out, errOut io.Writer, + gm GatewayManager, provider ConfigUpdater, login LoginFunc, + inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, + lazy bool, +) error { + llmCfg := provider.GetLLMConfig() + + // Apply inline flags in-memory so login and tool detection use the merged + // config without touching disk. Persistence happens below, only after login + // and tool patching succeed, so a failed login leaves no persisted state. + if err := llmCfg.SetFields(inlineOpts); err != nil { + return fmt.Errorf("invalid inline flag values: %w", err) + } + + if !llmCfg.IsConfigured() { + return fmt.Errorf("LLM gateway is not configured — run \"thv llm config set\" first") + } + + tokenHelperCommand, err := buildTokenHelperCommand() + if err != nil { + return err + } + + proxyBaseURL := fmt.Sprintf("http://localhost:%d/v1", llmCfg.EffectiveProxyPort()) + + // Detect tools before login so we skip the interactive browser flow when + // there is nothing to configure. In non-lazy mode login still runs before any + // files are patched, preserving the guarantee that a failed login leaves no + // state. + detected, err := filterDetectedClients(gm.DetectedLLMGatewayClients(), targetClient) + if err != nil { + return err + } + if len(detected) == 0 { + _, _ = fmt.Fprintln(out, "No supported AI tools detected.") + return nil + } + + // In lazy mode the interactive login is deferred until a configured tool + // first accesses the gateway (via "thv llm token" or the proxy). Everything + // below — tool detection, file patching, config persistence — is + // non-interactive and still runs, so the on-disk result is identical to a + // normal setup except that no OIDC token is obtained yet. + if lazy { + _, _ = fmt.Fprintln(out, "Lazy mode: skipping OIDC login. You'll be signed in on the first") + _, _ = fmt.Fprintln(out, "request a configured tool makes to the LLM gateway.") + } else { + _, _ = fmt.Fprintln(out, "Ensuring you are logged in to the LLM gateway…") + if err := login(ctx, &llmCfg); err != nil { + return fmt.Errorf("OIDC login failed: %s", SanitizeTokenError(err)) + } + _, _ = fmt.Fprintln(out, "Login successful.") + } + + // Resolve the effective path prefix for ANTHROPIC_BASE_URL. + // If the caller supplied --anthropic-path-prefix, use it directly. + // Otherwise auto-probe: a HEAD request to /anthropic/v1/messages + // that returns 401 (rather than 404) indicates the gateway uses the + // /anthropic prefix, so we apply it automatically. + // Only probe if at least one detected tool uses direct mode; proxy-mode + // tools ignore the Anthropic prefix entirely. + anthropicPrefix := resolveAnthropicPrefix(ctx, gm, detected, llmCfg, anthropicPathPrefix, anthropicPathPrefixSet) + + configured, err := configureDetectedTools( + out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, + llmCfg.TLSSkipVerify, anthropicPrefix, + ) + if err != nil { + return err + } + + warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) + + if err := provider.UpdateLLMConfig(func(c *Config) error { + // SetFields applies inline opts to the on-disk config (preserving any + // concurrent writes to unrelated fields) and merges ConfiguredTools + // atomically in a single write. + if err := c.SetFields(inlineOpts); err != nil { + return fmt.Errorf("persisting inline flags: %w", err) + } + c.ConfiguredTools = mergeToolConfigs(c.ConfiguredTools, configured) + return nil + }); err != nil { + // Roll back every tool we successfully patched so the tool config files + // are not left in a modified state without a persisted record of what + // was changed (which would make teardown unable to revert them). + rollbackConfiguredTools(errOut, gm, configured) + return fmt.Errorf("persisting tool configuration: %w", err) + } + + if hasProxyMode(configured) { + _, _ = fmt.Fprintln(out, "One or more tools use proxy mode. Start the proxy with: thv llm proxy start") + } + + return nil +} + +// Teardown removes LLM gateway configuration from all (or one) configured tools. +// +// targetTool selects which tool to revert; pass an empty string to revert all +// configured tools. An error is returned when targetTool is non-empty but not +// found in the configured tool list. +// +// If secretsProvider is non-nil and purgeTokens is true, cached OIDC tokens +// are deleted after the config update succeeds. +func Teardown( + ctx context.Context, + out, errOut io.Writer, + gm GatewayManager, + targetTool string, + purgeTokens bool, + provider ConfigUpdater, + secretsProvider pkgsecrets.Provider, +) error { + llmCfg := provider.GetLLMConfig() + + var targets []ToolConfig + if targetTool != "" { + for _, tc := range llmCfg.ConfiguredTools { + if tc.Tool == targetTool { + targets = append(targets, tc) + break + } + } + if len(targets) == 0 { + return fmt.Errorf("tool %q is not configured", targetTool) + } + } else { + targets = llmCfg.ConfiguredTools + } + + if len(targets) == 0 { + _, _ = fmt.Fprintln(out, "No tools are currently configured.") + return nil + } + + // Separate tools into those to revert and those to keep, without touching + // any files yet. We persist the new config first so that if UpdateLLMConfig + // fails, the tool files are left intact and the state stays consistent. + var toRevert, remaining []ToolConfig + for _, tc := range llmCfg.ConfiguredTools { + if isTarget(targets, tc.Tool) { + toRevert = append(toRevert, tc) + } else { + remaining = append(remaining, tc) + } + } + + // Persist the updated tool list (and clear token metadata if purging) in a + // single write before mutating any tool config files. If this fails, + // nothing on disk has changed and the caller can retry. + if err := provider.UpdateLLMConfig(func(c *Config) error { + c.ConfiguredTools = remaining + if purgeTokens { + c.OIDC.CachedRefreshTokenRef = "" + c.OIDC.CachedTokenExpiry = time.Time{} + } + return nil + }); err != nil { + return fmt.Errorf("persisting tool configuration: %w", err) + } + + // Revert tool config files best-effort; warn on failure but do not undo + // the config update above (the user can re-run setup+teardown to reconcile). + for _, tc := range toRevert { + revertToolConfig(out, errOut, gm, tc) + } + + if purgeTokens && secretsProvider != nil { + // Delete secrets after config refs are cleared so there is no window + // where secrets are gone but the config still points at them. + PurgeTokens(ctx, errOut, secretsProvider) + } + + return nil +} + +// PurgeTokens deletes all cached OIDC tokens from the provided secrets +// provider. Errors are logged as warnings rather than returned. +func PurgeTokens(ctx context.Context, errOut io.Writer, provider pkgsecrets.Provider) { + if err := DeleteCachedTokens(ctx, provider); err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: could not remove cached LLM tokens: %v\n", err) + } +} + +// isTarget reports whether toolName appears in the targets slice. +func isTarget(targets []ToolConfig, toolName string) bool { + for _, t := range targets { + if t.Tool == toolName { + return true + } + } + return false +} + +// mergeToolConfigs merges newly configured tools into the existing list, +// replacing any entry with the same tool name. +func mergeToolConfigs(existing, incoming []ToolConfig) []ToolConfig { + index := make(map[string]int, len(existing)) + result := make([]ToolConfig, len(existing)) + copy(result, existing) + for i, tc := range result { + index[tc.Tool] = i + } + for _, tc := range incoming { + if i, ok := index[tc.Tool]; ok { + result[i] = tc + } else { + index[tc.Tool] = len(result) + result = append(result, tc) + } + } + return result +} + +// warnTLSSkipVerify prints mode-accurate warnings when TLS verification is +// disabled. The impact differs by tool mode: +// - direct (Node.js tools like Claude Code, Gemini CLI): NODE_TLS_REJECT_UNAUTHORIZED=0 +// is written to the tool's settings, disabling TLS for ALL of that tool's outbound +// connections — not just the LLM gateway. +// - proxy: only the proxy's upstream connection to the gateway has TLS verification +// disabled; the tool itself is unaffected. +func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { + if !skip { + return + } + for _, tc := range configured { + switch tc.Mode { + case "direct": + _, _ = fmt.Fprintf(errOut, + "Warning: %s uses direct mode — NODE_TLS_REJECT_UNAUTHORIZED=0 has been written to its "+ + "settings, disabling TLS certificate verification for ALL of %s's outbound connections "+ + "(LLM provider APIs, MCP registry, etc.), not just the LLM gateway. "+ + "Use only in isolated local environments.\n", tc.Tool, tc.Tool) + case "proxy": + if tc.Tool == "gemini-cli" { + _, _ = fmt.Fprintf(errOut, + "Note: --tls-skip-verify is not supported for Gemini CLI "+ + "(setting NODE_TLS_REJECT_UNAUTHORIZED would affect all HTTPS connections in the process). "+ + "Ensure your proxy certificate is trusted by the system store instead.\n") + } else { + _, _ = fmt.Fprintf(errOut, + "Warning: %s uses proxy mode — TLS certificate verification is disabled for the "+ + "proxy's upstream gateway connection only. Use only in isolated local environments.\n", tc.Tool) + } + } + } +} + +// filterDetectedClients narrows the detected client list to a single entry when +// targetClient is non-empty. It returns an error if the named client is not in +// the detected list. When targetClient is empty the list is returned unchanged. +func filterDetectedClients(detected []string, targetClient string) ([]string, error) { + if targetClient == "" { + return detected, nil + } + for _, c := range detected { + if c == targetClient { + return []string{targetClient}, nil + } + } + return nil, fmt.Errorf("client %q is not installed or not detected", targetClient) +} + +// configureDetectedTools patches each detected tool's config file and returns +// the list of successfully configured tools. An error is returned only when no +// tool was configured successfully. +func configureDetectedTools( + out, errOut io.Writer, + gm GatewayManager, + detected []string, + gatewayURL, proxyBaseURL, tokenHelperCommand string, + tlsSkipVerify bool, + anthropicPathPrefix string, +) ([]ToolConfig, error) { + var configured []ToolConfig + for _, clientType := range detected { + mode := gm.LLMGatewayModeFor(clientType) + + // Only apply the Anthropic path prefix for direct-mode tools. + // Proxy-mode tools (Cursor, VS Code, Xcode) do not use ANTHROPIC_BASE_URL. + anthropicBaseURL := "" + if mode == "direct" && anthropicPathPrefix != "" { + // Trim any leading slash: url.JoinPath docs say elements should not + // start with "/", and path.Join already handles the join correctly. + if joined, err := url.JoinPath(gatewayURL, strings.TrimLeft(anthropicPathPrefix, "/")); err == nil { + anthropicBaseURL = joined + } + } + + applyCfg := llmgateway.ApplyConfig{ + GatewayURL: gatewayURL, + AnthropicBaseURL: anthropicBaseURL, + ProxyBaseURL: proxyBaseURL, + TokenHelperCommand: tokenHelperCommand, + TLSSkipVerify: tlsSkipVerify, + } + configPath, err := gm.ConfigureLLMGateway(clientType, applyCfg) + if err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) + continue + } + envFilePath, err := gm.ConfigureEnvFile(clientType, applyCfg) + if err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: failed to write .env for %s: %v\n", clientType, err) + // Roll back the settings-file patch we just made. + if revertErr := gm.RevertLLMGateway(clientType, configPath); revertErr != nil { + _, _ = fmt.Fprintf(errOut, "Warning: rollback of %s failed: %v\n", clientType, revertErr) + } + continue + } + configured = append(configured, ToolConfig{ + Tool: clientType, + Mode: mode, + ConfigPath: configPath, + EnvFilePath: envFilePath, + }) + _, _ = fmt.Fprintf(out, "Configured %s (%s mode) → %s\n", clientType, mode, configPath) + } + if len(configured) == 0 { + return nil, fmt.Errorf("failed to configure any detected tools") + } + return configured, nil +} + +// resolveAnthropicPrefix returns the effective Anthropic path prefix. When the +// caller explicitly set the flag (anthropicPathPrefixSet), the provided value is +// returned as-is (including empty string, which disables the prefix). Otherwise +// the gateway is auto-probed when at least one direct-mode client is present. +func resolveAnthropicPrefix( + ctx context.Context, gm GatewayManager, detected []string, + llmCfg Config, anthropicPathPrefix string, anthropicPathPrefixSet bool, +) string { + if anthropicPathPrefixSet || !hasDirectModeClient(gm, detected) { + return anthropicPathPrefix + } + return probeAnthropicPrefix(ctx, llmCfg.GatewayURL, llmCfg.TLSSkipVerify) +} + +// probeAnthropicPrefix performs a HEAD request to /anthropic/v1/messages. +// If the server responds with HTTP 401 (Unauthorized) — meaning the path exists but +// requires authentication — it returns "/anthropic" as the path prefix so that +// ANTHROPIC_BASE_URL is constructed as /anthropic rather than . +// Any other status code (including 404 Not Found) or any network error is treated +// as "no prefix needed" and the function returns "". +func probeAnthropicPrefix(ctx context.Context, gatewayURL string, tlsSkipVerify bool) string { + if gatewayURL == "" { + return "" + } + probeURL, err := url.JoinPath(gatewayURL, "anthropic/v1/messages") + if err != nil { + return "" + } + + // Build an http.Client that honours --tls-skip-verify so the probe works + // against gateways with self-signed certificates (local dev). Clone + // http.DefaultTransport to preserve all production defaults (timeouts, + // ProxyFromEnvironment, HTTP/2, connection pooling) and only toggle + // InsecureSkipVerify. + //nolint:forcetypeassert // DefaultTransport is always *http.Transport + transport := http.DefaultTransport.(*http.Transport).Clone() + if tlsSkipVerify { + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + transport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // G402: intentional for local dev with self-signed certs + } + httpClient := &http.Client{Transport: transport} + + // Use a short timeout so setup is not significantly slowed by an unreachable gateway. + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(probeCtx, http.MethodHead, probeURL, nil) + if err != nil { + return "" + } + resp, err := httpClient.Do(req) + if err != nil { + return "" + } + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return "/anthropic" + } + return "" +} + +// buildTokenHelperCommand returns the shell command string used as the +// token-helper for direct-mode tools. It rejects executable paths that contain +// shell metacharacters, since the command is written verbatim into long-lived +// tool config files and re-executed by the shell inside Claude Code / Gemini CLI. +// A path with '"', '\', ';', '$', '`', newline, or carriage-return would +// silently produce a broken or exploitable command. '$' and '`' are included +// because they trigger variable/command substitution inside double-quoted strings. +// +// Note: backslashes are Windows path separators, so this effectively makes +// "thv llm setup" unsupported on Windows — consistent with the rest of the LLM +// gateway feature (token-helper tools use POSIX-style shells). +func buildTokenHelperCommand() (string, error) { + self, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolving thv executable path: %w", err) + } + const shellUnsafe = `"\;$` + "`\n\r" + if strings.ContainsAny(self, shellUnsafe) { + return "", fmt.Errorf( + "executable path %q contains shell-unsafe characters; "+ + "move thv to a path without quotes, backslashes, semicolons, "+ + "dollar signs, or backticks "+ + "(Windows paths are not supported by thv llm setup)", self) + } + return fmt.Sprintf(`"%s" llm token`, self), nil +} + +// hasDirectModeClient reports whether any client in the detected list uses +// direct mode. Used to skip the Anthropic-prefix probe when no direct-mode +// tools are present (proxy-mode tools ignore ANTHROPIC_BASE_URL entirely). +func hasDirectModeClient(gm GatewayManager, detected []string) bool { + for _, clientType := range detected { + if gm.LLMGatewayModeFor(clientType) == "direct" { + return true + } + } + return false +} + +// revertToolConfig reverts the settings-file and .env patches for a single +// tool. Errors are logged as warnings so the caller can continue with others. +func revertToolConfig(out, errOut io.Writer, gm GatewayManager, tc ToolConfig) { + ok := true + if err := gm.RevertLLMGateway(tc.Tool, tc.ConfigPath); err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: failed to revert %s settings: %v\n", tc.Tool, err) + ok = false + } + if tc.EnvFilePath != "" { + if err := gm.RevertEnvFile(tc.Tool, tc.EnvFilePath); err != nil { + _, _ = fmt.Fprintf(errOut, "Warning: failed to revert %s .env: %v\n", tc.Tool, err) + ok = false + } + } + if ok { + _, _ = fmt.Fprintf(out, "Reverted %s (%s)\n", tc.Tool, tc.ConfigPath) + } +} + +// rollbackConfiguredTools reverts the settings-file and .env patches for every +// entry in configured. Errors are logged as warnings so all tools are attempted. +func rollbackConfiguredTools(errOut io.Writer, gm GatewayManager, configured []ToolConfig) { + // Use a discard writer for the "Reverted" line — rollback is silent on success. + for _, tc := range configured { + revertToolConfig(io.Discard, errOut, gm, tc) + } +} + +// hasProxyMode reports whether any of the given tool configs uses proxy mode. +func hasProxyMode(cfgs []ToolConfig) bool { + for _, t := range cfgs { + if t.Mode == "proxy" { + return true + } + } + return false +} diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go new file mode 100644 index 0000000000..b8a4af5bdd --- /dev/null +++ b/pkg/llm/setup_test.go @@ -0,0 +1,384 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/llmgateway" + "github.com/stacklok/toolhive/pkg/secrets" + secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" +) + +// ── mergeToolConfigs ────────────────────────────────────────────────────────── + +func TestMergeToolConfigs_EmptyExisting(t *testing.T) { + t.Parallel() + incoming := []ToolConfig{{Tool: "claude-code", Mode: "direct", ConfigPath: "/a"}} + got := mergeToolConfigs(nil, incoming) + assert.Equal(t, incoming, got) +} + +func TestMergeToolConfigs_AppendsNew(t *testing.T) { + t.Parallel() + existing := []ToolConfig{{Tool: "cursor", Mode: "proxy", ConfigPath: "/c"}} + incoming := []ToolConfig{{Tool: "claude-code", Mode: "direct", ConfigPath: "/a"}} + got := mergeToolConfigs(existing, incoming) + assert.Len(t, got, 2) + assert.Equal(t, "cursor", got[0].Tool) + assert.Equal(t, "claude-code", got[1].Tool) +} + +func TestMergeToolConfigs_ReplacesExisting(t *testing.T) { + t.Parallel() + existing := []ToolConfig{{Tool: "cursor", Mode: "proxy", ConfigPath: "/old"}} + incoming := []ToolConfig{{Tool: "cursor", Mode: "proxy", ConfigPath: "/new"}} + got := mergeToolConfigs(existing, incoming) + assert.Len(t, got, 1) + assert.Equal(t, "/new", got[0].ConfigPath) +} + +func TestMergeToolConfigs_MixedReplaceAndAppend(t *testing.T) { + t.Parallel() + existing := []ToolConfig{ + {Tool: "cursor", ConfigPath: "/old-cursor"}, + {Tool: "vscode", ConfigPath: "/old-vscode"}, + } + incoming := []ToolConfig{ + {Tool: "cursor", ConfigPath: "/new-cursor"}, + {Tool: "claude-code", ConfigPath: "/claude"}, + } + got := mergeToolConfigs(existing, incoming) + assert.Len(t, got, 3) + assert.Equal(t, "/new-cursor", got[0].ConfigPath) + assert.Equal(t, "/old-vscode", got[1].ConfigPath) + assert.Equal(t, "/claude", got[2].ConfigPath) +} + +func TestMergeToolConfigs_DuplicatesInIncoming(t *testing.T) { + t.Parallel() + // If incoming contains the same tool name twice, the last entry wins and + // the result must not contain duplicates. + incoming := []ToolConfig{ + {Tool: "claude-code", ConfigPath: "/first"}, + {Tool: "claude-code", ConfigPath: "/second"}, + } + got := mergeToolConfigs(nil, incoming) + assert.Len(t, got, 1) + assert.Equal(t, "/second", got[0].ConfigPath) +} + +// ── isTarget ───────────────────────────────────────────────────────────────── + +func TestIsTarget(t *testing.T) { + t.Parallel() + targets := []ToolConfig{ + {Tool: "claude-code"}, + {Tool: "cursor"}, + } + assert.True(t, isTarget(targets, "claude-code")) + assert.True(t, isTarget(targets, "cursor")) + assert.False(t, isTarget(targets, "vscode")) + assert.False(t, isTarget(targets, "")) +} + +// ── Teardown purgeTokens path ───────────────────────────────────────────────── + +// stubGatewayManager is a minimal GatewayManager for Teardown tests. +type stubGatewayManager struct { + reverted []string +} + +func (*stubGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*stubGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*stubGatewayManager) LLMGatewayModeFor(_ string) string { return "" } +func (*stubGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*stubGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (s *stubGatewayManager) RevertLLMGateway(clientType, _ string) error { + s.reverted = append(s.reverted, clientType) + return nil +} + +// stubConfigUpdater is a minimal ConfigUpdater for Teardown tests. +type stubConfigUpdater struct { + cfg Config +} + +func (s *stubConfigUpdater) GetLLMConfig() Config { return s.cfg } +func (s *stubConfigUpdater) UpdateLLMConfig(fn func(*Config) error) error { + return fn(&s.cfg) +} + +func TestTeardown_PurgeTokens_ClearsConfigRefsAndDeletesSecrets(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + sp := secretsmocks.NewMockProvider(ctrl) + sp.EXPECT().Capabilities().Return(secrets.ProviderCapabilities{ + CanRead: true, CanWrite: true, CanDelete: true, CanList: true, + }).AnyTimes() + // DeleteCachedTokens lists then deletes; for simplicity return empty list so + // the delete call is skipped — we only need to verify the config refs are cleared. + sp.EXPECT().ListSecrets(gomock.Any()).Return(nil, nil) + + expiry := time.Now() + provider := &stubConfigUpdater{cfg: Config{ + OIDC: OIDCConfig{ + CachedRefreshTokenRef: "some-ref", + CachedTokenExpiry: expiry, + }, + ConfiguredTools: []ToolConfig{{Tool: "cursor", ConfigPath: "/tmp/cursor.json"}}, + }} + gm := &stubGatewayManager{} + + var stdout, stderr bytes.Buffer + err := Teardown(context.Background(), &stdout, &stderr, gm, "", true, provider, sp) + require.NoError(t, err) + + // Config refs must be cleared. + assert.Empty(t, provider.cfg.OIDC.CachedRefreshTokenRef) + assert.True(t, provider.cfg.OIDC.CachedTokenExpiry.IsZero()) + // Tool must have been reverted. + assert.Equal(t, []string{"cursor"}, gm.reverted) +} + +func TestTeardown_NoPurge_LeavesTokenRefsIntact(t *testing.T) { + t.Parallel() + + expiry := time.Now() + provider := &stubConfigUpdater{cfg: Config{ + OIDC: OIDCConfig{ + CachedRefreshTokenRef: "some-ref", + CachedTokenExpiry: expiry, + }, + ConfiguredTools: []ToolConfig{{Tool: "cursor", ConfigPath: "/tmp/cursor.json"}}, + }} + gm := &stubGatewayManager{} + + var stdout, stderr bytes.Buffer + err := Teardown(context.Background(), &stdout, &stderr, gm, "", false, provider, nil) + require.NoError(t, err) + + // Token refs must be untouched when purgeTokens=false. + assert.Equal(t, "some-ref", provider.cfg.OIDC.CachedRefreshTokenRef) + assert.Equal(t, expiry, provider.cfg.OIDC.CachedTokenExpiry) +} + +// ── Setup --lazy path ───────────────────────────────────────────────────────── + +// setupGatewayManager reports one or more detected clients and patches them +// successfully, for Setup-level tests. mode is returned by LLMGatewayModeFor; +// use "proxy" to avoid the direct-mode Anthropic-prefix probe. +type setupGatewayManager struct { + detected []string + mode string +} + +func (g *setupGatewayManager) DetectedLLMGatewayClients() []string { return g.detected } +func (*setupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "/tmp/settings.json", nil +} +func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*setupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*setupGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*setupGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +// configuredSetupProvider returns a ConfigUpdater whose config satisfies +// IsConfigured()/Validate() so Setup proceeds past its configuration checks. +func configuredSetupProvider() *stubConfigUpdater { + return &stubConfigUpdater{cfg: Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "test-client", + }, + }} +} + +func TestSetup_Lazy_SkipsLoginAndPersistsTools(t *testing.T) { + t.Parallel() + + gm := &setupGatewayManager{detected: []string{"cursor"}, mode: "proxy"} + provider := configuredSetupProvider() + + loginCalled := false + login := func(_ context.Context, _ *Config) error { + loginCalled = true + return nil + } + + var stdout, stderr bytes.Buffer + // anthropicPathPrefixSet=true skips the network probe; lazy=true. + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, login, + SetOptions{}, "", true, "", true, + ) + require.NoError(t, err) + + assert.False(t, loginCalled, "lazy mode must not invoke the OIDC login") + // Tool config must still be persisted even though login was skipped. + require.Len(t, provider.cfg.ConfiguredTools, 1) + assert.Equal(t, "cursor", provider.cfg.ConfiguredTools[0].Tool) + // User must be told that login is deferred to the first request. + assert.Contains(t, stdout.String(), "Lazy mode") + assert.Contains(t, stdout.String(), "first") +} + +func TestSetup_NonLazy_InvokesLogin(t *testing.T) { + t.Parallel() + + gm := &setupGatewayManager{detected: []string{"cursor"}, mode: "proxy"} + provider := configuredSetupProvider() + + loginCalled := false + login := func(_ context.Context, _ *Config) error { + loginCalled = true + return nil + } + + var stdout, stderr bytes.Buffer + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, login, + SetOptions{}, "", true, "", false, + ) + require.NoError(t, err) + + assert.True(t, loginCalled, "non-lazy mode must invoke the OIDC login") + require.Len(t, provider.cfg.ConfiguredTools, 1) +} + +// ── AnthropicPathPrefix / configureDetectedTools ────────────────────────────── + +// capturingGatewayManager records the ApplyConfig passed to ConfigureLLMGateway. +type capturingGatewayManager struct { + mode string // returned by LLMGatewayModeFor + applied []llmgateway.ApplyConfig +} + +func (*capturingGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.ApplyConfig) (string, error) { + g.applied = append(g.applied, cfg) + return "/path/to/settings.json", nil +} +func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*capturingGatewayManager) LLMSetupNoteFor(_ string) string { return "" } +func (*capturingGatewayManager) RevertLLMGateway(_, _ string) error { return nil } +func (*capturingGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*capturingGatewayManager) RevertEnvFile(_, _ string) error { return nil } + +func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "/anthropic", + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // The Anthropic base URL must be gateway + prefix, not just the gateway. + assert.Equal(t, "https://gw.example.com/anthropic", gm.applied[0].AnthropicBaseURL) + assert.Equal(t, "https://gw.example.com", gm.applied[0].GatewayURL) +} + +func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "", // no prefix + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // AnthropicBaseURL must be empty so llmValueForSpec falls back to GatewayURL. + assert.Empty(t, gm.applied[0].AnthropicBaseURL) +} + +func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "proxy"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"cursor"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "/anthropic", + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // Proxy-mode tools must never receive an AnthropicBaseURL. + assert.Empty(t, gm.applied[0].AnthropicBaseURL) +} + +// ── probeAnthropicPrefix ────────────────────────────────────────────────────── + +func TestProbeAnthropicPrefix_Returns_Anthropic_On_401(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + prefix := probeAnthropicPrefix(context.Background(), srv.URL, false) + assert.Equal(t, "/anthropic", prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_On_404(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + prefix := probeAnthropicPrefix(context.Background(), srv.URL, false) + assert.Empty(t, prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_On_NetworkError(t *testing.T) { + t.Parallel() + + // Use a URL that will immediately refuse connections. + prefix := probeAnthropicPrefix(context.Background(), "http://127.0.0.1:1", false) + assert.Empty(t, prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_For_EmptyGatewayURL(t *testing.T) { + t.Parallel() + + prefix := probeAnthropicPrefix(context.Background(), "", false) + assert.Empty(t, prefix) +} diff --git a/pkg/llm/tokensource.go b/pkg/llm/tokensource.go new file mode 100644 index 0000000000..7661ed3a34 --- /dev/null +++ b/pkg/llm/tokensource.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "errors" + "fmt" + + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/auth/tokensource" + "github.com/stacklok/toolhive/pkg/secrets" +) + +// ErrTokenRequired is returned when a fresh token is needed but no cached or +// refreshable token exists and the caller is non-interactive (browser flow +// disabled). The user must first complete an interactive login so that a +// refresh token is persisted for subsequent non-interactive calls. +var ErrTokenRequired = errors.New( + "LLM gateway authentication required: no cached credentials found; " + + "run \"thv llm setup\" to log in", +) + +// TokenRefUpdater is a callback invoked when the refresh token changes — either +// after a successful browser flow (initial login) or when the OIDC provider +// rotates the refresh token during a refresh. It persists the secret key and +// the new token expiry into the application config so future CLI invocations +// can restore the session. It is NOT called on routine access-token refreshes +// where the refresh token is unchanged. +// Callers typically wire this to config.UpdateConfig. +type TokenRefUpdater = tokensource.ConfigPersister + +// TokenSource provides fresh LLM gateway access tokens. +type TokenSource = tokensource.OAuthTokenSource + +// NewTokenSource creates a TokenSource for the LLM gateway. +// secretsProvider may be nil if the secrets store is unavailable. +// tokenRefUpdater is called after login/refresh to persist the token reference +// into config — pass nil to skip config persistence (useful in tests). +// Set interactive to false for non-interactive callers such as thv llm token. +func NewTokenSource( + cfg *Config, secretsProvider secrets.Provider, interactive bool, tokenRefUpdater TokenRefUpdater, +) *TokenSource { + return tokensource.New(tokensource.Options{ + OIDC: tokensource.OIDCParams{ + Issuer: cfg.OIDC.Issuer, + ClientID: cfg.OIDC.ClientID, + Scopes: cfg.OIDC.EffectiveScopes(), + Audience: cfg.OIDC.Audience, + CallbackPort: cfg.OIDC.CallbackPort, + }, + SecretsProvider: secretsProvider, + Interactive: interactive, + KeyProvider: func() string { + if cfg.OIDC.CachedRefreshTokenRef != "" { + return cfg.OIDC.CachedRefreshTokenRef + } + return DeriveSecretKey(cfg.GatewayURL, cfg.OIDC.Issuer) + }, + ConfigPersister: tokenRefUpdater, + FallbackErr: ErrTokenRequired, + }) +} + +// SanitizeTokenError returns a log-safe string for a token-source error. +// If err wraps *oauth2.RetrieveError, only the error code and description are +// included — never the raw response body, which may contain bearer material +// echoed back by the IdP. +func SanitizeTokenError(err error) string { + var re *oauth2.RetrieveError + if errors.As(err, &re) { + if re.ErrorDescription != "" { + return fmt.Sprintf("oauth2 error %q: %s", re.ErrorCode, re.ErrorDescription) + } + return fmt.Sprintf("oauth2 error %q", re.ErrorCode) + } + return err.Error() +} + +// DeriveSecretKey computes the secrets-provider key for an LLM gateway refresh +// token. The formula is: LLM_OAUTH_<8 hex chars> where the hex is derived from +// sha256(gatewayURL + "\x00" + issuer)[:4]. +func DeriveSecretKey(gatewayURL, issuer string) string { + return tokensource.DeriveSecretKey("LLM_OAUTH_", gatewayURL, issuer) +} diff --git a/pkg/llm/tokensource_test.go b/pkg/llm/tokensource_test.go new file mode 100644 index 0000000000..cdc2632427 --- /dev/null +++ b/pkg/llm/tokensource_test.go @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llm + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/oauth2" + + secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" +) + +// minimalConfig returns a Config with the minimum fields for a configured gateway. +func minimalConfig() *Config { + return &Config{ + GatewayURL: "https://llm.example.com", + OIDC: OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "test-client", + }, + } +} + +// ── DeriveSecretKey ─────────────────────────────────────────────────────────── + +func TestDeriveSecretKey(t *testing.T) { + t.Parallel() + + key1 := DeriveSecretKey("https://llm.example.com", "https://auth.example.com") + key2 := DeriveSecretKey("https://llm.example.com", "https://auth.example.com") + key3 := DeriveSecretKey("https://other.example.com", "https://auth.example.com") + + assert.Equal(t, key1, key2, "same inputs must produce the same key") + assert.NotEqual(t, key1, key3, "different gateway URLs must produce different keys") + assert.True(t, len(key1) > len("LLM_OAUTH_"), "key must be longer than the prefix") + assert.Contains(t, key1, "LLM_OAUTH_", "key must carry the LLM-specific prefix") +} + +// ── ErrTokenRequired is returned in non-interactive mode ───────────────────── + +func TestTokenSource_NonInteractive_NoCache_ReturnsErrTokenRequired(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mockSecrets := secretsmocks.NewMockProvider(ctrl) + mockSecrets.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", errors.New("not found")).AnyTimes() + + ts := NewTokenSource(minimalConfig(), mockSecrets, false, nil) + _, err := ts.Token(context.Background()) + require.ErrorIs(t, err, ErrTokenRequired) +} + +// Backend errors surface as the specific error, not the generic ErrTokenRequired. +func TestTokenSource_NonInteractive_BackendError_ReturnsLastErr(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mockSecrets := secretsmocks.NewMockProvider(ctrl) + backendErr := errors.New("keyring is locked") + mockSecrets.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", backendErr).AnyTimes() + + ts := NewTokenSource(minimalConfig(), mockSecrets, false, nil) + _, err := ts.Token(context.Background()) + + require.Error(t, err) + assert.False(t, errors.Is(err, ErrTokenRequired), + "backend error must surface as lastErr, not the generic ErrTokenRequired") + assert.ErrorContains(t, err, "keyring is locked") +} + +// Nil secrets provider returns an actionable error, not ErrTokenRequired. +func TestTokenSource_NonInteractive_NilSecrets_ReturnsActionableError(t *testing.T) { + t.Parallel() + + ts := NewTokenSource(minimalConfig(), nil, false, nil) + _, err := ts.Token(context.Background()) + + require.Error(t, err) + assert.False(t, errors.Is(err, ErrTokenRequired)) +} + +// ── KeyProvider uses CachedRefreshTokenRef when set ─────────────────────────── + +// When CachedRefreshTokenRef is set the token source must look up that exact key +// in the secrets provider — not a newly derived key. +func TestTokenSource_UsesCachedRefreshTokenRef(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mockSecrets := secretsmocks.NewMockProvider(ctrl) + + const persistedKey = "my-persisted-key" + + cfg := minimalConfig() + cfg.OIDC.CachedRefreshTokenRef = persistedKey + + // AT cache key (_AT suffix) and the base key must both use persistedKey. + mockSecrets.EXPECT(). + GetSecret(gomock.Any(), persistedKey+"_AT"). + Return("", errors.New("not found")) + mockSecrets.EXPECT(). + GetSecret(gomock.Any(), persistedKey). + Return("", errors.New("not found")) + + ts := NewTokenSource(cfg, mockSecrets, false, nil) + _, _ = ts.Token(context.Background()) + // Expectations verify that persistedKey was used. +} + +// When CachedRefreshTokenRef is empty the key is derived from GatewayURL+Issuer. +func TestTokenSource_DerivesKeyWhenNoCachedRef(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mockSecrets := secretsmocks.NewMockProvider(ctrl) + + cfg := minimalConfig() + expectedBase := DeriveSecretKey(cfg.GatewayURL, cfg.OIDC.Issuer) + + mockSecrets.EXPECT(). + GetSecret(gomock.Any(), expectedBase+"_AT"). + Return("", errors.New("not found")) + mockSecrets.EXPECT(). + GetSecret(gomock.Any(), expectedBase). + Return("", errors.New("not found")) + + ts := NewTokenSource(cfg, mockSecrets, false, nil) + _, _ = ts.Token(context.Background()) +} + +// ── TokenRefUpdater wired as ConfigPersister ────────────────────────────────── + +// TokenRefUpdater is invoked when the OIDC provider rotates the refresh token. +// This verifies that the LLM layer correctly wires the updater through to the +// shared tokensource.ConfigPersister so callers can persist the new token ref. +func TestTokenSource_TokenRefUpdater_WiredAsConfigPersister(t *testing.T) { + t.Parallel() + + srv := newTokenServer(t, "new-access-token", "rotated-refresh-token") + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return "old-refresh-token", nil + }).AnyTimes() + mock.EXPECT().SetSecret(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + var updaterKey string + updater := TokenRefUpdater(func(key string, _ time.Time) { updaterKey = key }) + + cfg := minimalConfig() + cfg.OIDC.Issuer = srv.URL + ts := NewTokenSource(cfg, mock, false, updater) + + tok, err := ts.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "new-access-token", tok) + assert.NotEmpty(t, updaterKey, "TokenRefUpdater must be called when the refresh token is rotated") +} + +// ── SanitizeTokenError ──────────────────────────────────────────────────────── + +func TestSanitizeTokenError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + wantSubs []string + wantAbsent []string + }{ + { + name: "plain error", + err: errors.New("something went wrong"), + wantSubs: []string{"something went wrong"}, + }, + { + name: "nil-like generic", + err: errors.New("any message"), + wantSubs: []string{"any message"}, + }, + { + name: "oauth2 RetrieveError with description", + err: &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + Body: []byte("sensitive-body-content"), + }, + wantSubs: []string{"invalid_grant", "Token has been expired or revoked."}, + wantAbsent: []string{"sensitive-body-content"}, + }, + { + name: "oauth2 RetrieveError without description", + err: &oauth2.RetrieveError{ + ErrorCode: "invalid_client", + Body: []byte("sensitive-body-content"), + }, + wantSubs: []string{"invalid_client"}, + wantAbsent: []string{"sensitive-body-content"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := SanitizeTokenError(tc.err) + for _, sub := range tc.wantSubs { + assert.Contains(t, got, sub) + } + for _, absent := range tc.wantAbsent { + assert.NotContains(t, got, absent) + } + }) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// newTokenServer builds a minimal OIDC discovery + token endpoint that returns +// the given access token and refresh token on any token request. +func newTokenServer(t *testing.T, at, rt string) *httptest.Server { + t.Helper() + + var srv *httptest.Server + mux := http.NewServeMux() + + oidcHandler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":%q,"token_endpoint":%q}`, + srv.URL, srv.URL+"/authorize", srv.URL+"/token") + } + mux.HandleFunc("/.well-known/openid-configuration", oidcHandler) + mux.HandleFunc("/.well-known/oauth-authorization-server", oidcHandler) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"` + at + `","refresh_token":"` + rt + `","token_type":"Bearer","expires_in":3600}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go new file mode 100644 index 0000000000..3ad2895de9 --- /dev/null +++ b/pkg/llmgateway/config.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package llmgateway contains shared types for the LLM gateway feature, +// imported by both pkg/llm and pkg/client to avoid an import cycle. +package llmgateway + +import "net/url" + +// ProxyOriginOf returns rawURL with its path, query, fragment, and userinfo +// stripped so only the scheme and host remain (the "origin"). Tools like +// Gemini CLI that append their own API path (e.g. /v1beta/...) need the +// origin rather than the full proxy base URL to avoid doubled path segments. +// +// Falls back to rawURL when: +// - parsing fails, or +// - the parsed URL has no Host (e.g. scheme-less "localhost:14000/v1" is +// parsed by url.Parse as Scheme=localhost, Opaque=14000/v1 with no Host), +// or +// - the parsed URL has a non-empty Opaque field (opaque URI reference). +func ProxyOriginOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u == nil || u.Host == "" || u.Opaque != "" { + return rawURL + } + origin := url.URL{ + Scheme: u.Scheme, + Host: u.Host, + } + return origin.String() +} + +// ApplyConfig holds the values needed to configure a single tool's LLM +// gateway settings. Using a struct prevents positional-argument mistakes when +// the caller has multiple similar string values in scope. +type ApplyConfig struct { + GatewayURL string // direct-mode: URL of the upstream LLM gateway + AnthropicBaseURL string // direct-mode: effective ANTHROPIC_BASE_URL (gateway + optional path prefix) + ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy + TokenHelperCommand string // direct-mode: shell command that prints a fresh token + TLSSkipVerify bool // when true, instruct the tool to skip TLS verification +} diff --git a/pkg/llmgateway/config_test.go b/pkg/llmgateway/config_test.go new file mode 100644 index 0000000000..5a192beaa1 --- /dev/null +++ b/pkg/llmgateway/config_test.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package llmgateway_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +func TestProxyOriginOf(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want string + }{ + // Normal cases — path, query, and fragment stripped. + {name: "strips_path", input: "http://localhost:14000/v1", want: "http://localhost:14000"}, + {name: "strips_long_path", input: "http://localhost:9000/v1beta/openai", want: "http://localhost:9000"}, + {name: "strips_query_and_fragment", input: "http://host:8080/path?q=1#frag", want: "http://host:8080"}, + {name: "strips_fragment_only", input: "http://host:8080#frag", want: "http://host:8080"}, + // ForceQuery: trailing "?" must not persist into the origin. + {name: "strips_force_query", input: "http://host:8080/path?", want: "http://host:8080"}, + // Userinfo must not be persisted into the settings file. + {name: "strips_userinfo", input: "http://user:pass@host:8080/path", want: "http://host:8080"}, + // IPv6 host. + {name: "ipv6_host", input: "http://[::1]:14000/v1", want: "http://[::1]:14000"}, + // Empty input — url.Parse succeeds but Host is ""; fall back to rawURL. + {name: "empty_input", input: "", want: ""}, + // Scheme-less "host:port/path" — url.Parse treats this as Scheme=host, + // Opaque=port/path with no Host; fall back to rawURL. + {name: "scheme_less_host_port", input: "localhost:14000/v1", want: "localhost:14000/v1"}, + // Opaque URI — fall back to rawURL. + {name: "opaque_uri", input: "mailto:user@example.com", want: "mailto:user@example.com"}, + // Invalid URL — fall back to rawURL. + {name: "invalid_url", input: "::invalid", want: "::invalid"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, llmgateway.ProxyOriginOf(tc.input)) + }) + } +} diff --git a/pkg/mcp/client/call.go b/pkg/mcp/client/call.go new file mode 100644 index 0000000000..84e050a696 --- /dev/null +++ b/pkg/mcp/client/call.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "fmt" + "log/slog" + + "github.com/mark3labs/mcp-go/mcp" +) + +// CallTool connects to the MCP server at serverURL, performs the initialize +// handshake, invokes the named tool with the supplied arguments, and returns +// the result. The connection is closed before returning. +// +// args may be nil; in that case the `arguments` field is omitted from the +// tool call request (equivalent to calling the tool with no inputs). +// +// A nil error indicates the MCP call completed; the returned result may still +// have IsError=true to signal a tool-level failure that the caller should +// surface to the user. +func CallTool( + ctx context.Context, + serverURL, transport, clientName, toolName string, + args map[string]any, +) (*mcp.CallToolResult, error) { + c, err := Connect(ctx, serverURL, transport, clientName) + if err != nil { + return nil, err + } + defer func() { + if cerr := c.Close(); cerr != nil { + slog.Warn("failed to close MCP client", "error", cerr) + } + }() + + req := mcp.CallToolRequest{} + req.Params.Name = toolName + req.Params.Arguments = args + + result, err := c.CallTool(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to call tool %q: %w", toolName, err) + } + return result, nil +} diff --git a/pkg/mcp/client/client.go b/pkg/mcp/client/client.go new file mode 100644 index 0000000000..34db99dd3e --- /dev/null +++ b/pkg/mcp/client/client.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package client provides shared MCP client creation and initialization logic +// used by both the CLI and TUI. +// +// It wraps the mcp-go SDK client constructors and handles transport selection, +// auto-detection (streamable-http with SSE fallback), and the MCP initialize +// handshake using the ToolHive version reported by [versions.GetVersionInfo]. +package client + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive/pkg/transport/ssecommon" + "github.com/stacklok/toolhive/pkg/transport/streamable" + "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/versions" +) + +// TransportAuto is the sentinel value that triggers auto-detection of the +// transport type (try streamable-http first, then fall back to SSE). +const TransportAuto = "auto" + +// Connect creates an MCP SDK client for the given serverURL and transport, +// starts the underlying transport, and performs the MCP initialize handshake. +// +// The clientName is included in the ClientInfo sent during initialization +// (e.g. "toolhive-cli" or "toolhive-tui"). +// +// transport must be one of: +// - [TransportAuto] -- try streamable-http, fall back to SSE +// - "sse" +// - "streamable-http" +// +// The returned client is fully connected and ready for use. The caller is +// responsible for calling Close when done. +func Connect(ctx context.Context, serverURL, transport, clientName string) (*mcpclient.Client, error) { + if transport == TransportAuto { + return connectWithAutoDetect(ctx, serverURL, clientName) + } + c, err := newClient(serverURL, transport) + if err != nil { + return nil, err + } + if err := startAndInitialize(ctx, c, clientName); err != nil { + _ = c.Close() + return nil, err + } + return c, nil +} + +// newClient constructs an MCP SDK client for the given serverURL and explicit +// transport type. The client is not yet started or initialized. +func newClient(serverURL, transport string) (*mcpclient.Client, error) { + tt := resolveTransport(serverURL, transport) + switch tt { + case types.TransportTypeSSE: + c, err := mcpclient.NewSSEMCPClient(serverURL) + if err != nil { + return nil, fmt.Errorf("create SSE MCP client: %w", err) + } + return c, nil + case types.TransportTypeStreamableHTTP: + c, err := mcpclient.NewStreamableHttpClient(serverURL) + if err != nil { + return nil, fmt.Errorf("create streamable-http MCP client: %w", err) + } + return c, nil + case types.TransportTypeStdio: + return nil, fmt.Errorf("stdio transport is not supported for MCP client connections") + case types.TransportTypeInspector: + return nil, fmt.Errorf("inspector transport is not supported for MCP client connections") + default: + return nil, fmt.Errorf("unsupported transport type: %s", tt) + } +} + +// connectWithAutoDetect tries streamable-http first, then falls back to SSE. +// The returned client is fully initialized. +func connectWithAutoDetect(ctx context.Context, serverURL, clientName string) (*mcpclient.Client, error) { + slog.Debug("trying streamable-http transport", "url", serverURL) + streamableClient, err := mcpclient.NewStreamableHttpClient(serverURL) + if err == nil { + if err := startAndInitialize(ctx, streamableClient, clientName); err == nil { + slog.Debug("connected using streamable-http transport") + return streamableClient, nil + } + _ = streamableClient.Close() + slog.Debug("streamable-http transport failed, trying SSE fallback") + } + + slog.Debug("trying SSE transport", "url", serverURL) + sseClient, err := mcpclient.NewSSEMCPClient(serverURL) + if err != nil { + return nil, fmt.Errorf("create MCP client (tried streamable-http and SSE): %w", err) + } + if err := startAndInitialize(ctx, sseClient, clientName); err != nil { + _ = sseClient.Close() + return nil, fmt.Errorf("connect using both streamable-http and SSE transports: %w", err) + } + slog.Debug("connected using SSE transport") + return sseClient, nil +} + +// startAndInitialize starts the transport and performs the MCP initialize +// handshake using the ToolHive version. +func startAndInitialize(ctx context.Context, c *mcpclient.Client, clientName string) error { + if err := c.Start(ctx); err != nil { + return fmt.Errorf("start MCP client: %w", err) + } + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.Capabilities = mcp.ClientCapabilities{} + initReq.Params.ClientInfo = mcp.Implementation{ + Name: clientName, + Version: versions.GetVersionInfo().Version, + } + if _, err := c.Initialize(ctx, initReq); err != nil { + return fmt.Errorf("initialize MCP client: %w", err) + } + return nil +} + +// resolveTransport determines the transport type from the user-supplied value +// and the URL path. When the value is not a recognized transport string the +// function falls back to URL-based heuristics. +func resolveTransport(serverURL, transport string) types.TransportType { + switch transport { + case string(types.TransportTypeSSE): + return types.TransportTypeSSE + case string(types.TransportTypeStreamableHTTP): + return types.TransportTypeStreamableHTTP + } + + // Infer from URL path. + parsedURL, err := url.Parse(serverURL) + if err != nil { + slog.Warn("failed to parse server URL, defaulting to streamable-http", + "url", serverURL, "error", err) + return types.TransportTypeStreamableHTTP + } + + path := parsedURL.Path + if strings.HasSuffix(path, "/"+streamable.HTTPStreamableHTTPEndpoint) || + strings.HasSuffix(path, streamable.HTTPStreamableHTTPEndpoint) { + return types.TransportTypeStreamableHTTP + } + if strings.HasSuffix(path, ssecommon.HTTPSSEEndpoint) { + return types.TransportTypeSSE + } + + // Default to streamable-http (SSE is deprecated). + return types.TransportTypeStreamableHTTP +} diff --git a/pkg/mcp/server/handler_test.go b/pkg/mcp/server/handler_test.go index 30856db6dc..e58318f0bc 100644 --- a/pkg/mcp/server/handler_test.go +++ b/pkg/mcp/server/handler_test.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" regtypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/runner" @@ -340,11 +341,11 @@ func TestBuildServerConfig(t *testing.T) { runConfig, err := buildServerConfig(ctx, args, tt.imageURL, tt.imageMetadata, tt.extraOpts...) if tt.expectError { - assert.Error(t, err) - assert.Nil(t, runConfig) + require.Error(t, err) + require.Nil(t, runConfig) } else { - assert.NoError(t, err) - assert.NotNil(t, runConfig) + require.NoError(t, err) + require.NotNil(t, runConfig) if tt.checkConfig != nil { tt.checkConfig(t, runConfig) } diff --git a/pkg/mcp/tool_filter.go b/pkg/mcp/tool_filter.go index 241bc33aae..3bc6facca0 100644 --- a/pkg/mcp/tool_filter.go +++ b/pkg/mcp/tool_filter.go @@ -315,8 +315,15 @@ type toolFilterWriter struct { config *toolMiddlewareConfig } -// WriteHeader captures the status code +// WriteHeader captures the status code. +// +// Content-Length is stripped because applying tool filters or overrides may +// change the body size (e.g. a longer override description). Without this, +// net/http rejects the rewritten body with "http: wrote more than the declared +// Content-Length" and the client receives only the headers. Removing the +// header lets Go fall back to chunked transfer encoding. func (rw *toolFilterWriter) WriteHeader(statusCode int) { + rw.ResponseWriter.Header().Del("Content-Length") rw.ResponseWriter.WriteHeader(statusCode) } diff --git a/pkg/mcp/tool_filter_test.go b/pkg/mcp/tool_filter_test.go index a6f33e45d2..43d749ac41 100644 --- a/pkg/mcp/tool_filter_test.go +++ b/pkg/mcp/tool_filter_test.go @@ -1252,6 +1252,68 @@ func TestToolFilterWriter_Flush(t *testing.T) { } } +// TestToolFilterWriter_WriteHeader verifies that Content-Length is stripped +// from the underlying ResponseWriter's headers regardless of content type. +// The middleware re-encodes tool list responses to apply filters/overrides, +// which changes the body size; without this strip, net/http rejects the +// rewritten body with "http: wrote more than the declared Content-Length" +// and the client receives only the headers. Regression guard for #5075. +func TestToolFilterWriter_WriteHeader(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + initialHeaders http.Header + statusCode int + }{ + { + name: "application/json with Content-Length is stripped", + initialHeaders: http.Header{ + "Content-Type": []string{"application/json"}, + "Content-Length": []string{"123"}, + }, + statusCode: http.StatusOK, + }, + { + name: "text/event-stream with Content-Length is stripped", + initialHeaders: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "Content-Length": []string{"14743"}, + }, + statusCode: http.StatusOK, + }, + { + name: "no Content-Length leaves headers unchanged", + initialHeaders: http.Header{ + "Content-Type": []string{"application/json"}, + }, + statusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: tt.initialHeaders.Clone(), + buffer: &bytes.Buffer{}, + } + rw := &toolFilterWriter{ResponseWriter: mockWriter} + + rw.WriteHeader(tt.statusCode) + + assert.Equal(t, tt.statusCode, mockWriter.statusCode, "status code should pass through") + assert.Empty(t, mockWriter.headers.Get("Content-Length"), "Content-Length must be stripped") + // Other headers must survive — only Content-Length is removed. + assert.Equal(t, + tt.initialHeaders.Get("Content-Type"), + mockWriter.headers.Get("Content-Type"), + "Content-Type must be preserved") + }) + } +} + // mockResponseWriter implements http.ResponseWriter for testing type mockResponseWriter struct { headers http.Header diff --git a/pkg/networking/fetch.go b/pkg/networking/fetch.go index f9b9a4352c..0aba6f8fdf 100644 --- a/pkg/networking/fetch.go +++ b/pkg/networking/fetch.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -49,17 +50,19 @@ type FetchOption func(*fetchOptions) // fetchOptions holds the configuration for a fetch request. type fetchOptions struct { - method string - headers http.Header - body io.Reader - errorHandler func(*http.Response, []byte) error + method string + headers http.Header + body io.Reader + errorHandler func(*http.Response, []byte) error + maxResponseSize int64 } // newFetchOptions creates default fetch options. func newFetchOptions() *fetchOptions { return &fetchOptions{ - method: http.MethodGet, - headers: make(http.Header), + method: http.MethodGet, + headers: make(http.Header), + maxResponseSize: maxResponseSize, } } @@ -84,6 +87,15 @@ func WithBody(body io.Reader) FetchOption { } } +// WithMaxResponseSize sets a custom maximum response body size in bytes. +// The default is 1 MB. Use this to enforce tighter limits for endpoints that +// are expected to return small documents (e.g. OAuth metadata, CIMD documents). +func WithMaxResponseSize(size int64) FetchOption { + return func(opts *fetchOptions) { + opts.maxResponseSize = size + } +} + // WithErrorHandler sets a custom error handler for non-200 responses. // The handler receives the response and body, and should return an error. // If the handler returns nil, the default HTTPError will be returned. @@ -132,7 +144,7 @@ func FetchJSON[T any]( defer func() { _ = resp.Body.Close() }() // Read body with size limit - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + body, err := io.ReadAll(io.LimitReader(resp.Body, options.maxResponseSize)) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } @@ -150,9 +162,12 @@ func FetchJSON[T any]( return nil, NewHTTPError(resp.StatusCode, requestURL, resp.Status) } - // Validate Content-Type for successful responses + // Validate Content-Type for successful responses. Accept application/json + // and application/*+json subtypes (RFC 6839) — the old strings.Contains check + // incorrectly rejected valid subtypes like application/ld+json. contentType := resp.Header.Get("Content-Type") - if !strings.Contains(strings.ToLower(contentType), contentTypeJSON) { + mediaType, _, _ := mime.ParseMediaType(contentType) + if mediaType != contentTypeJSON && (!strings.HasPrefix(mediaType, "application/") || !strings.HasSuffix(mediaType, "+json")) { return nil, fmt.Errorf("unexpected content type: %s", contentType) } diff --git a/pkg/networking/http_client.go b/pkg/networking/http_client.go index 1116d2216e..733cd48b9e 100644 --- a/pkg/networking/http_client.go +++ b/pkg/networking/http_client.go @@ -103,6 +103,7 @@ type HttpClientBuilder struct { authTokenFile string allowPrivate bool insecureAllowHTTP bool + disableKeepAlives bool } // NewHttpClientBuilder returns a new HttpClientBuilder @@ -139,6 +140,14 @@ func (b *HttpClientBuilder) WithInsecureAllowHTTP(allow bool) *HttpClientBuilder return b } +// WithDisableKeepAlives disables HTTP keep-alive on the transport. When true, +// each request uses a fresh connection, ensuring the per-dial SSRF check fires +// on every request rather than being bypassed by a reused connection. +func (b *HttpClientBuilder) WithDisableKeepAlives(disable bool) *HttpClientBuilder { + b.disableKeepAlives = disable + return b +} + // WithTimeout sets the HTTP client timeout func (b *HttpClientBuilder) WithTimeout(timeout time.Duration) *HttpClientBuilder { b.clientTimeout = timeout @@ -151,6 +160,7 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) { TLSHandshakeTimeout: b.tlsHandshakeTimeout, ResponseHeaderTimeout: b.responseHeaderTimeout, } + transport.DisableKeepAlives = b.disableKeepAlives if !b.allowPrivate { transport.DialContext = (&net.Dialer{ diff --git a/pkg/networking/port.go b/pkg/networking/port.go index b6bf5f3e03..6e35d08248 100644 --- a/pkg/networking/port.go +++ b/pkg/networking/port.go @@ -61,41 +61,6 @@ func IsAvailable(port int) bool { return true } -// IsIPv6Available checks if IPv6 is available on the system -// by looking for IPv6 addresses on network interfaces -func IsIPv6Available() bool { - interfaces, err := net.Interfaces() - if err != nil { - return false - } - - for _, iface := range interfaces { - if iface.Flags&net.FlagUp == 0 { - // Interface is down - continue - } - - addrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - - if ipNet.IP.To4() == nil && !ipNet.IP.IsLoopback() { - // This is an IPv6 address and not a loopback - return true - } - } - } - - return false -} - // FindAvailable finds an available port func FindAvailable() int { for i := 0; i < MaxAttempts; i++ { diff --git a/pkg/networking/utilities.go b/pkg/networking/utilities.go index 67f020d244..1a30e8d017 100644 --- a/pkg/networking/utilities.go +++ b/pkg/networking/utilities.go @@ -10,6 +10,8 @@ import ( "net/url" "os" "strings" + + "github.com/stacklok/toolhive/pkg/oauthproto" ) const ( @@ -17,16 +19,58 @@ const ( ErrPrivateIpAddress = "the provided URL redirects to a private IP address, which is not allowed" ) +// nat64Prefixes are the NAT64 translation prefixes whose embedded IPv4 address +// lives in the low 32 bits (RFC 6052 §2.2 "/96" embedding). An address inside +// one of these is routed to that IPv4 by a NAT64 gateway, so its true +// reachability is determined by the embedded IPv4, not by the (global-unicast) +// IPv6 form. +// - 64:ff9b::/96 well-known prefix (RFC 6052), always a /96 +// - 64:ff9b:1::/96 the /96 sub-prefix of the RFC 8215 local-use 64:ff9b:1::/48 +// +// The rest of the local-use 64:ff9b:1::/48 uses a shorter NAT64 prefix, where +// the embedded IPv4 is NOT in the low 32 bits and cannot be located from the +// address alone. Decoding the low 32 bits there could read attacker-chosen +// suffix bytes and misclassify an internal target as public, so that remainder +// is blocked wholesale via privateIPBlocks to avoid a false-negative bypass. +var nat64Prefixes []*net.IPNet + +// embeddedIPv4 returns the IPv4 address embedded in the low 32 bits of a NAT64 +// address if ip falls inside a NAT64 translation prefix, or nil otherwise. +func embeddedIPv4(ip net.IP) net.IP { + v6 := ip.To16() + if v6 == nil || ip.To4() != nil { + return nil + } + for _, block := range nat64Prefixes { + if block.Contains(ip) { + return net.IPv4(v6[12], v6[13], v6[14], v6[15]) + } + } + return nil +} + func init() { for _, cidr := range []string{ - "127.0.0.0/8", // IPv4 loopback - "10.0.0.0/8", // RFC1918 - "172.16.0.0/12", // RFC1918 - "192.168.0.0/16", // RFC1918 - "169.254.0.0/16", // RFC3927 link-local - "::1/128", // IPv6 loopback - "fe80::/10", // IPv6 link-local - "fc00::/7", // IPv6 unique local addr + "0.0.0.0/8", // RFC1122 "this host" (incl. 0.0.0.0 unspecified) + "127.0.0.0/8", // IPv4 loopback + "10.0.0.0/8", // RFC1918 + "172.16.0.0/12", // RFC1918 + "192.168.0.0/16", // RFC1918 + "169.254.0.0/16", // RFC3927 link-local + "::1/128", // IPv6 loopback + "fe80::/10", // IPv6 link-local + "fc00::/7", // IPv6 unique local addr + "100.64.0.0/10", // RFC6598 shared address space (CGN) + "192.0.2.0/24", // RFC5737 documentation (TEST-NET-1) + "198.51.100.0/24", // RFC5737 documentation (TEST-NET-2) + "203.0.113.0/24", // RFC5737 documentation (TEST-NET-3) + "224.0.0.0/4", // IPv4 multicast + "240.0.0.0/4", // RFC1112 reserved (Class E), incl. 255.255.255.255 broadcast + "ff00::/8", // IPv6 multicast + // NAT64 local-use range (RFC 8215). The 64:ff9b:1::/96 subset is decoded + // to its embedded IPv4 below; this catch-all blocks the remaining + // non-/96 embeddings, which cannot be decoded from the address alone. + "64:ff9b:1::/48", } { _, block, err := net.ParseCIDR(cidr) if err != nil { @@ -34,13 +78,37 @@ func init() { } privateIPBlocks = append(privateIPBlocks, block) } + for _, cidr := range []string{ + "64:ff9b::/96", // NAT64 well-known prefix, /96 embedding (RFC 6052) + "64:ff9b:1::/96", // /96 sub-prefix of the local-use range (RFC 8215) + } { + _, block, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Errorf("parse error on %q: %w", cidr, err)) + } + nat64Prefixes = append(nat64Prefixes, block) + } } -// IsPrivateIP reports whether ip is a private, loopback, or link-local address. +// IsPrivateIP reports whether ip is a private, loopback, link-local, +// unspecified, or otherwise reserved/non-public address. +// +// NAT64-translated addresses are evaluated by the IPv4 address they embed: a +// NAT64 address whose low 32 bits map to a private/link-local IPv4 (e.g. +// 64:ff9b:1::a9fe:a9fe -> 169.254.169.254, the cloud metadata endpoint) is +// treated as private, because behind a NAT64 gateway it reaches exactly that +// internal IPv4, while NAT64 addresses embedding a genuinely public IPv4 remain +// allowed. This /96 decoding covers the well-known 64:ff9b::/96 (RFC 6052) and +// the 64:ff9b:1::/96 sub-prefix of the RFC 8215 local-use range; the rest of +// 64:ff9b:1::/48 uses a non-/96 embedding that cannot be decoded from the +// address alone and is blocked wholesale (see privateIPBlocks). func IsPrivateIP(ip net.IP) bool { - if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true } + if v4 := embeddedIPv4(ip); v4 != nil { + return IsPrivateIP(v4) + } for _, block := range privateIPBlocks { if block.Contains(ip) { return true @@ -95,14 +163,85 @@ func validateEndpointURLWithSkip(endpoint string, skipValidation bool) error { return nil } -// IsLocalhost checks if a host is localhost (for development) +// ValidateHTTPSURL checks that rawURL is a valid URL using the https scheme. +// Unlike ValidateEndpointURL, no localhost exception is made — HTTPS is always +// required (suitable for gateway URLs and other production endpoints). +func ValidateHTTPSURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if parsed.Host == "" { + return fmt.Errorf("URL must include a host: %s", rawURL) + } + if parsed.Scheme != HttpsScheme { + return fmt.Errorf("must use HTTPS, got scheme %q", parsed.Scheme) + } + return nil +} + +// ValidateIssuerURL validates that an OIDC issuer URL is well-formed and uses +// HTTPS. HTTP is permitted only for localhost (development). Per OIDC Core +// Section 3.1.2.1 and RFC 8414 Section 2, the issuer MUST use the "https" +// scheme. +func ValidateIssuerURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid issuer URL %q: %w", rawURL, err) + } + if u.Host == "" { + return fmt.Errorf("issuer URL must include a host: %s", rawURL) + } + if u.Scheme != HttpsScheme && !IsLocalhost(u.Host) { + return fmt.Errorf("issuer URL must use HTTPS (except localhost for development): %s", rawURL) + } + return nil +} + +// ValidateLoopbackAddress returns an error if addr (a host:port string) does +// not contain a literal loopback IP address. Both IPv4 (127.x.x.x) and IPv6 +// (::1) loopback addresses are accepted. Hostnames (including "localhost") are +// not resolved and will be rejected. +func ValidateLoopbackAddress(addr string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("invalid listen address %q: %w", addr, err) + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("listen address %q must be a loopback interface (127.x.x.x or ::1)", addr) + } + return nil +} + +// IsLocalhost checks if a host is a loopback address (for development). +// The canonical implementation lives in pkg/oauthproto.IsLoopbackHost; +// this function is a thin wrapper that preserves backward compatibility for +// all callers in this package and beyond. func IsLocalhost(host string) bool { - return strings.HasPrefix(host, "localhost:") || - strings.HasPrefix(host, "127.0.0.1:") || - strings.HasPrefix(host, "[::1]:") || - host == "localhost" || - host == "127.0.0.1" || - host == "[::1]" + return oauthproto.IsLoopbackHost(host) +} + +// IsLoopbackHost reports whether the Host header value refers to a loopback +// address. It is intended for DNS-rebinding guards on loopback-only listeners. +// It accepts the hostname "localhost" (case-insensitive), any 127.x.x.x +// address, and the IPv6 loopback ::1. Both plain-host and host:port forms are +// accepted. Hostnames other than "localhost" are NOT resolved. +func IsLoopbackHost(host string) bool { + h, _, err := net.SplitHostPort(host) + if err != nil { + // No port present — treat the whole value as the host. + h = host + // Strip brackets from bare IPv6 literals like "[::1]". + if len(h) > 2 && h[0] == '[' && h[len(h)-1] == ']' { + h = h[1 : len(h)-1] + } + } + if strings.EqualFold(h, "localhost") { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() } // IsURL checks if the input is a valid HTTP or HTTPS URL diff --git a/pkg/networking/utilities_test.go b/pkg/networking/utilities_test.go index 58d1517d29..1fd6894bc6 100644 --- a/pkg/networking/utilities_test.go +++ b/pkg/networking/utilities_test.go @@ -4,9 +4,11 @@ package networking import ( + "net" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsURL(t *testing.T) { @@ -297,6 +299,58 @@ func TestIsLocalhost(t *testing.T) { } } +func TestIsPrivateIP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ip string + want bool + }{ + {"CGN 100.64.0.1", "100.64.0.1", true}, + {"CGN 100.127.255.255", "100.127.255.255", true}, + {"documentation TEST-NET-1", "192.0.2.1", true}, + {"documentation TEST-NET-2", "198.51.100.1", true}, + {"documentation TEST-NET-3", "203.0.113.1", true}, + {"public IPv4", "8.8.8.8", false}, + {"public IPv6", "2001:db8::1", false}, + + // Unspecified / "this host" / reserved ranges (defense-in-depth). + {"unspecified IPv4", "0.0.0.0", true}, + {"unspecified IPv6", "::", true}, + {`RFC1122 "this host" 0.x`, "0.1.2.3", true}, + {"Class E reserved", "240.0.0.1", true}, + {"limited broadcast", "255.255.255.255", true}, + + // NAT64 (RFC 6052 / RFC 8215): classified by the embedded IPv4. + // IPv4-mapped form is still caught by the link-local check, not NAT64. + {"IPv4-mapped link-local", "::ffff:169.254.169.254", true}, + // Well-known prefix 64:ff9b::/96 embedding a private/link-local IPv4. + {"NAT64 well-known -> IMDS link-local", "64:ff9b::a9fe:a9fe", true}, + {"NAT64 well-known -> loopback", "64:ff9b::7f00:1", true}, + {"NAT64 well-known -> RFC1918 10.x", "64:ff9b::a00:1", true}, + {"NAT64 well-known -> RFC1918 192.168.x", "64:ff9b::c0a8:1", true}, + // Well-known prefix embedding a genuinely public IPv4 must stay allowed. + {"NAT64 well-known -> public", "64:ff9b::8.8.8.8", false}, + // Local-use /96 sub-prefix is decoded the same way. + {"NAT64 local-use /96 -> IMDS", "64:ff9b:1::a9fe:a9fe", true}, + {"NAT64 local-use /96 -> public", "64:ff9b:1::8.8.8.8", false}, + // Remainder of the local-use /48 uses a non-/96 embedding that cannot be + // decoded, so it is blocked wholesale even when the low 32 bits look + // public (would otherwise be a false-negative SSRF bypass). + {"NAT64 local-use non-/96 blocked", "64:ff9b:1:1::8.8.8.8", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ip := net.ParseIP(tt.ip) + require.NotNil(t, ip, "failed to parse test IP %s", tt.ip) + assert.Equal(t, tt.want, IsPrivateIP(ip)) + }) + } +} + func TestAddressReferencesPrivateIp(t *testing.T) { t.Parallel() @@ -358,6 +412,31 @@ func TestAddressReferencesPrivateIp(t *testing.T) { address: "[fc00::1]:80", expectError: true, }, + { + name: "NAT64 well-known to IMDS with port", + address: "[64:ff9b::a9fe:a9fe]:443", + expectError: true, + }, + { + name: "NAT64 local-use to IMDS with port", + address: "[64:ff9b:1::a9fe:a9fe]:443", + expectError: true, + }, + { + name: "NAT64 to public IPv4 with port", + address: "[64:ff9b::8.8.8.8]:443", + expectError: false, + }, + { + name: "unspecified IPv4 with port", + address: "0.0.0.0:80", + expectError: true, + }, + { + name: "unspecified IPv6 with port", + address: "[::]:80", + expectError: true, + }, // Invalid addresses (should error due to parsing) { @@ -490,3 +569,157 @@ func TestValidateEndpointURL(t *testing.T) { }) } } + +func TestValidateHTTPSURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + expectError bool + }{ + { + name: "valid HTTPS URL", + url: "https://llm.example.com", + expectError: false, + }, + { + name: "valid HTTPS URL with path", + url: "https://llm.example.com/api/v1", + expectError: false, + }, + { + name: "valid HTTPS URL with port", + url: "https://llm.example.com:8443", + expectError: false, + }, + { + name: "HTTP rejected even for localhost", + url: "http://localhost:8080", + expectError: true, + }, + { + name: "HTTP rejected for remote host", + url: "http://llm.example.com", + expectError: true, + }, + { + name: "missing host", + url: "https://", + expectError: true, + }, + { + name: "unsupported scheme", + url: "ftp://llm.example.com", + expectError: true, + }, + { + name: "invalid URL format", + url: "not-a-url", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateHTTPSURL(tt.url) + if tt.expectError { + assert.Error(t, err, "Expected error for URL: %s", tt.url) + } else { + assert.NoError(t, err, "Expected no error for URL: %s", tt.url) + } + }) + } +} + +func TestValidateIssuerURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + expectError bool + }{ + { + name: "valid HTTPS issuer", + url: "https://auth.example.com", + expectError: false, + }, + { + name: "valid HTTPS issuer with path", + url: "https://auth.example.com/realms/myrealm", + expectError: false, + }, + { + name: "localhost HTTP allowed for development", + url: "http://localhost:8080", + expectError: false, + }, + { + name: "127.0.0.1 HTTP allowed for development", + url: "http://127.0.0.1:9000", + expectError: false, + }, + { + name: "HTTP rejected for remote host", + url: "http://auth.example.com", + expectError: true, + }, + { + name: "missing host", + url: "https://", + expectError: true, + }, + { + name: "invalid URL format", + url: "not-a-url", + expectError: true, + }, + { + name: "unsupported scheme", + url: "ftp://auth.example.com", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateIssuerURL(tt.url) + if tt.expectError { + assert.Error(t, err, "Expected error for URL: %s", tt.url) + } else { + assert.NoError(t, err, "Expected no error for URL: %s", tt.url) + } + }) + } +} + +func TestValidateLoopbackAddress(t *testing.T) { + t.Parallel() + tests := []struct { + addr string + wantErr bool + }{ + {"127.0.0.1:14000", false}, + {"[::1]:14000", false}, + {"0.0.0.0:14000", true}, + {"192.168.1.1:14000", true}, + {"10.0.0.1:14000", true}, + {"notanaddr", true}, + } + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + t.Parallel() + err := ValidateLoopbackAddress(tt.addr) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/oauth/constants.go b/pkg/oauth/constants.go deleted file mode 100644 index 9c25e650f7..0000000000 --- a/pkg/oauth/constants.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package oauth provides RFC-defined types and constants for OAuth 2.0 and OpenID Connect. -// This package contains ONLY protocol-level definitions with no business logic. -// It serves as a shared foundation for both OAuth clients (consumers) and servers (producers). -package oauth - -// Well-known endpoint paths as defined by RFC 8414, OpenID Connect Discovery 1.0, and RFC 9728. -const ( - // WellKnownOIDCPath is the standard OIDC discovery endpoint path - // per OpenID Connect Discovery 1.0 specification. - WellKnownOIDCPath = "/.well-known/openid-configuration" - - // WellKnownOAuthServerPath is the standard OAuth authorization server metadata endpoint path - // per RFC 8414 (OAuth 2.0 Authorization Server Metadata). - WellKnownOAuthServerPath = "/.well-known/oauth-authorization-server" - - // WellKnownOAuthResourcePath is the RFC 9728 standard path for OAuth Protected Resource metadata. - // Per RFC 9728 Section 3, this endpoint and any subpaths under it should be accessible - // without authentication to enable OIDC/OAuth discovery. - WellKnownOAuthResourcePath = "/.well-known/oauth-protected-resource" -) - -// Grant types as defined by RFC 6749. -const ( - // GrantTypeAuthorizationCode is the authorization code grant type (RFC 6749 Section 4.1). - GrantTypeAuthorizationCode = "authorization_code" - - // GrantTypeRefreshToken is the refresh token grant type (RFC 6749 Section 6). - GrantTypeRefreshToken = "refresh_token" -) - -// Response types as defined by RFC 6749. -const ( - // ResponseTypeCode is the authorization code response type (RFC 6749 Section 4.1.1). - ResponseTypeCode = "code" -) - -// Token endpoint authentication methods as defined by RFC 7591. -const ( - // TokenEndpointAuthMethodNone indicates no client authentication (public clients). - // Typically used with PKCE for native/mobile applications. - TokenEndpointAuthMethodNone = "none" -) - -// PKCE (Proof Key for Code Exchange) methods as defined by RFC 7636. -const ( - // PKCEMethodS256 uses SHA-256 hash of the code verifier (recommended). - PKCEMethodS256 = "S256" -) diff --git a/pkg/oauth/discovery.go b/pkg/oauth/discovery.go deleted file mode 100644 index 119ae09a6d..0000000000 --- a/pkg/oauth/discovery.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oauth - -// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server Metadata -// per RFC 8414. This is the base structure that OIDC Discovery extends. -type AuthorizationServerMetadata struct { - // Issuer is the authorization server's issuer identifier (REQUIRED per RFC 8414). - Issuer string `json:"issuer"` - - // AuthorizationEndpoint is the URL of the authorization endpoint (RECOMMENDED). - // Note: No omitempty to maintain backward compatibility with existing JSON serialization. - AuthorizationEndpoint string `json:"authorization_endpoint"` - - // TokenEndpoint is the URL of the token endpoint (RECOMMENDED). - // Note: No omitempty to maintain backward compatibility with existing JSON serialization. - TokenEndpoint string `json:"token_endpoint"` - - // JWKSURI is the URL of the JSON Web Key Set document (RECOMMENDED). - // Note: No omitempty to maintain backward compatibility with existing JSON serialization. - JWKSURI string `json:"jwks_uri"` - - // RegistrationEndpoint is the URL of the Dynamic Client Registration endpoint (OPTIONAL). - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` - - // IntrospectionEndpoint is the URL of the token introspection endpoint (OPTIONAL, RFC 7662). - IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` - - // UserinfoEndpoint is the URL of the UserInfo endpoint (RECOMMENDED per OIDC Discovery, not in RFC 8414). - // Omitted from JSON when empty to avoid serializing an invalid URL value. - UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` - - // ResponseTypesSupported lists the response types supported (RECOMMENDED). - ResponseTypesSupported []string `json:"response_types_supported,omitempty"` - - // GrantTypesSupported lists the grant types supported (OPTIONAL). - GrantTypesSupported []string `json:"grant_types_supported,omitempty"` - - // CodeChallengeMethodsSupported lists the PKCE code challenge methods supported (OPTIONAL). - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` - - // TokenEndpointAuthMethodsSupported lists the authentication methods supported at the token endpoint (OPTIONAL). - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` - - // ScopesSupported lists the OAuth 2.0 scope values supported (RECOMMENDED per RFC 8414). - // For MCP authorization servers, this typically includes "openid" and "offline_access". - ScopesSupported []string `json:"scopes_supported,omitempty"` -} - -// OIDCDiscoveryDocument represents the OpenID Connect Discovery 1.0 document. -// It extends OAuth 2.0 Authorization Server Metadata (RFC 8414) with OIDC-specific fields. -// This unified type supports both producer (server) and consumer (client) use cases. -type OIDCDiscoveryDocument struct { - // Embed OAuth 2.0 AS Metadata (RFC 8414) as the base - AuthorizationServerMetadata - - // SubjectTypesSupported lists the subject identifier types supported (REQUIRED for OIDC). - SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` - - // IDTokenSigningAlgValuesSupported lists the JWS algorithms supported for ID tokens (REQUIRED for OIDC). - IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"` - - // ClaimsSupported lists the claims that can be returned (RECOMMENDED for OIDC). - ClaimsSupported []string `json:"claims_supported,omitempty"` -} - -// Validate performs basic validation on the discovery document. -// It checks for required fields based on whether this is an OIDC or pure OAuth document. -func (d *OIDCDiscoveryDocument) Validate(isOIDC bool) error { - if d.Issuer == "" { - return ErrMissingIssuer - } - if d.AuthorizationEndpoint == "" { - return ErrMissingAuthorizationEndpoint - } - if d.TokenEndpoint == "" { - return ErrMissingTokenEndpoint - } - if isOIDC && d.JWKSURI == "" { - return ErrMissingJWKSURI - } - if isOIDC && len(d.ResponseTypesSupported) == 0 { - return ErrMissingResponseTypesSupported - } - return nil -} - -// SupportsPKCE returns true if the authorization server supports PKCE with S256. -func (d *OIDCDiscoveryDocument) SupportsPKCE() bool { - for _, method := range d.CodeChallengeMethodsSupported { - if method == PKCEMethodS256 { - return true - } - } - return false -} - -// SupportsGrantType returns true if the authorization server supports the given grant type. -func (d *OIDCDiscoveryDocument) SupportsGrantType(grantType string) bool { - for _, gt := range d.GrantTypesSupported { - if gt == grantType { - return true - } - } - return false -} diff --git a/pkg/oauth/discovery_test.go b/pkg/oauth/discovery_test.go deleted file mode 100644 index 5e90245127..0000000000 --- a/pkg/oauth/discovery_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oauth - -import ( - "errors" - "testing" -) - -func TestOIDCDiscoveryDocument_Validate(t *testing.T) { - t.Parallel() - - validDoc := func() OIDCDiscoveryDocument { - return OIDCDiscoveryDocument{ - AuthorizationServerMetadata: AuthorizationServerMetadata{ - Issuer: "https://example.com", - AuthorizationEndpoint: "https://example.com/authorize", - TokenEndpoint: "https://example.com/token", - JWKSURI: "https://example.com/jwks", - ResponseTypesSupported: []string{"code"}, - }, - } - } - - tests := []struct { - name string - modify func(*OIDCDiscoveryDocument) - isOIDC bool - wantErr error - }{ - {"valid OAuth document", nil, false, nil}, - {"valid OIDC document", nil, true, nil}, - {"missing issuer", func(d *OIDCDiscoveryDocument) { d.Issuer = "" }, false, ErrMissingIssuer}, - {"missing authorization_endpoint", func(d *OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, false, ErrMissingAuthorizationEndpoint}, - {"missing token_endpoint", func(d *OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, false, ErrMissingTokenEndpoint}, - {"missing jwks_uri for OIDC", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, true, ErrMissingJWKSURI}, - {"missing jwks_uri for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, false, nil}, - {"missing response_types_supported for OIDC", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, true, ErrMissingResponseTypesSupported}, - {"missing response_types_supported for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, false, nil}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - doc := validDoc() - if tt.modify != nil { - tt.modify(&doc) - } - err := doc.Validate(tt.isOIDC) - if !errors.Is(err, tt.wantErr) { - t.Errorf("Validate() = %v, want %v", err, tt.wantErr) - } - }) - } -} - -func TestOIDCDiscoveryDocument_SupportsPKCE(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - methods []string - want bool - }{ - {"nil slice", nil, false}, - {"empty slice", []string{}, false}, - {"only plain", []string{"plain"}, false}, - {"S256 present", []string{"S256"}, true}, - {"both plain and S256", []string{"plain", "S256"}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - doc := OIDCDiscoveryDocument{ - AuthorizationServerMetadata: AuthorizationServerMetadata{ - CodeChallengeMethodsSupported: tt.methods, - }, - } - if got := doc.SupportsPKCE(); got != tt.want { - t.Errorf("SupportsPKCE() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestOIDCDiscoveryDocument_SupportsGrantType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - grants []string - grantType string - want bool - }{ - {"nil slice", nil, GrantTypeAuthorizationCode, false}, - {"empty slice", []string{}, GrantTypeAuthorizationCode, false}, - {"grant type present", []string{GrantTypeAuthorizationCode}, GrantTypeAuthorizationCode, true}, - {"grant type absent", []string{GrantTypeRefreshToken}, GrantTypeAuthorizationCode, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - doc := OIDCDiscoveryDocument{ - AuthorizationServerMetadata: AuthorizationServerMetadata{ - GrantTypesSupported: tt.grants, - }, - } - if got := doc.SupportsGrantType(tt.grantType); got != tt.want { - t.Errorf("SupportsGrantType(%q) = %v, want %v", tt.grantType, got, tt.want) - } - }) - } -} diff --git a/pkg/oauth/doc.go b/pkg/oauth/doc.go deleted file mode 100644 index 8e9cd472ea..0000000000 --- a/pkg/oauth/doc.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package oauth provides shared RFC-defined types, constants, and validation utilities -// for OAuth 2.0 and OpenID Connect. It serves as a shared foundation for both OAuth -// clients and servers, including redirect URI validation per RFC 6749 and RFC 8252. -package oauth diff --git a/pkg/oauthproto/cimd.go b/pkg/oauthproto/cimd.go new file mode 100644 index 0000000000..29b5c96c78 --- /dev/null +++ b/pkg/oauthproto/cimd.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "net/url" + "strings" +) + +// ToolHiveClientMetadataDocumentURL is the stable HTTPS URL where ToolHive's +// client metadata document is hosted. ToolHive presents this URL as its +// client_id to remote authorization servers that support CIMD. The URL must +// be live and serving the client metadata document before this feature can +// be used in production. +const ToolHiveClientMetadataDocumentURL = "https://toolhive.dev/oauth/client-metadata.json" + +// IsClientIDMetadataDocumentURL returns true if clientID looks like a CIMD URL. +// In production this means any HTTPS URL; in local development http://localhost, +// http://127.0.0.1, and http://[::1] URLs are also accepted so that integration +// tests can use plain httptest.Server instances without TLS setup. DCR-issued IDs +// are always opaque strings that never begin with a URL scheme, so there is no +// risk of false positives. Do not tighten this to an exact match against +// ToolHiveClientMetadataDocumentURL — the embedded AS must accept CIMD URLs +// from third-party clients too. +// +// This predicate is also used by pkg/auth/remote/handler.go to decide whether to +// persist a client_id as DCR credentials or as a CIMD URL. The loopback HTTP +// acceptance is safe in that context: real DCR-issued client IDs are always opaque +// strings that never start with "http://", so no live deployment is affected. +func IsClientIDMetadataDocumentURL(clientID string) bool { + if strings.HasPrefix(clientID, "https://") { + return true + } + // Allow loopback HTTP URLs (localhost, 127.0.0.1, [::1]) for local + // development and integration testing. These are the only HTTP URLs that + // FetchClientMetadataDocument / validateCIMDClientURL also accept. + if strings.HasPrefix(clientID, "http://") { + parsed, err := url.Parse(clientID) + if err != nil { + return false + } + return IsLoopbackHost(parsed.Host) + } + return false +} diff --git a/pkg/oauthproto/cimd/fetch.go b/pkg/oauthproto/cimd/fetch.go new file mode 100644 index 0000000000..0ba07bfee2 --- /dev/null +++ b/pkg/oauthproto/cimd/fetch.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package cimd implements fetching and validation of OAuth 2.0 Client ID +// Metadata Documents (CIMD) per draft-ietf-oauth-client-id-metadata-document. +// +// This package is a sub-package of pkg/oauthproto and is allowed to import +// pkg/networking (for SSRF utilities) without violating the leaf-package +// invariant of the parent package. +package cimd + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// ClientMetadataDocument represents an OAuth 2.0 Client ID Metadata Document +// per draft-ietf-oauth-client-id-metadata-document. +type ClientMetadataDocument struct { + // Required + ClientID string `json:"client_id"` + RedirectURIs []string `json:"redirect_uris"` + + // Recommended + ClientName string `json:"client_name,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + + // Optional + TosURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + ApplicationType string `json:"application_type,omitempty"` + PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` +} + +// forbiddenAuthMethods lists token_endpoint_auth_method values that MUST NOT +// appear in a CIMD document. Per the spec, CIMD clients may not use symmetric +// shared-secret methods because no pre-shared secret is ever established. +var forbiddenAuthMethods = []string{ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", +} + +// FetchClientMetadataDocument fetches and validates a Client ID Metadata Document +// from the given URL. The URL must use the HTTPS scheme (http://localhost is +// accepted in development). The document is fetched with a 5-second timeout, a +// 1-hop redirect limit, a 10 KB body cap, and SSRF protection via a per-dial IP +// check. After fetching, ValidateClientMetadataDocument is called and any +// validation error is returned. +func FetchClientMetadataDocument(ctx context.Context, rawURL string) (*ClientMetadataDocument, error) { + if err := validateCIMDClientURL(rawURL); err != nil { + return nil, err + } + + result, err := networking.FetchJSON[ClientMetadataDocument]( + ctx, + newCIMDHTTPClient(), + rawURL, + networking.WithMaxResponseSize(10*1024), + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch client metadata document: %w", err) + } + + doc := &result.Data + if err := ValidateClientMetadataDocument(doc, rawURL); err != nil { + return nil, err + } + + return doc, nil +} + +// newCIMDHTTPClient builds an *http.Client for fetching CIMD documents. +// Keep-alive connections are disabled so the per-dial SSRF check fires on +// every request. A custom DialContext allows loopback connections (for +// development) while rejecting all other private/special-use ranges via +// networking.IsPrivateIP, and dials by IP literal to prevent DNS-rebinding. +// A CheckRedirect hook validates redirect targets and enforces the 1-hop limit. +// +// HttpClientBuilder.WithPrivateIPs(false) is not used here because it also +// blocks loopback addresses (127.0.0.0/8), which breaks the intentional +// http://localhost development exception in validateCIMDClientURL. +func newCIMDHTTPClient() *http.Client { + transport := &http.Transport{ + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + DisableKeepAlives: true, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("cimd: invalid dial address %q: %w", address, err) + } + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("cimd: failed to resolve %q: %w", host, err) + } + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + // Loopback is gated by the URL scheme check (only http://localhost + // is permitted for HTTP). All other private/special-use ranges are + // rejected. Dial by IP literal to prevent DNS-rebinding TOCTOU. + if !ip.IsLoopback() && networking.IsPrivateIP(ip) { + return nil, fmt.Errorf("cimd: refusing connection to private address %s", ipStr) + } + dialer := &net.Dialer{Timeout: 5 * time.Second} + return dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port)) + } + return nil, fmt.Errorf("cimd: no valid address found for %q", host) + }, + } + + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: transport, + // CIMD §4: "The authorization server MUST NOT automatically follow + // HTTP redirects when retrieving the client registration information." + // Return the redirect response unchanged; FetchJSON will surface it + // as a non-200 error. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return client +} + +// validateCIMDClientURL enforces the client_id URL requirements from +// draft-ietf-oauth-client-id-metadata-document §3: +// - MUST use https scheme (http://localhost permitted for development only) +// - MUST contain a non-empty path component (not just "/") +// - MUST NOT contain a fragment component +// - MUST NOT contain userinfo (username or password) +// - MUST NOT contain single-dot or double-dot path segments +func validateCIMDClientURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("client_id URL is not a valid URL: %w", err) + } + isLoopback := parsed.Scheme == "http" && oauthproto.IsLoopbackHost(parsed.Hostname()) + if parsed.Scheme != "https" && !isLoopback { + return fmt.Errorf("client_id URL must use the https scheme: %s", rawURL) + } + if parsed.Host == "" { + return fmt.Errorf("client_id URL must contain a host") + } + if parsed.Fragment != "" { + return fmt.Errorf("client_id URL must not contain a fragment component") + } + if parsed.User != nil { + return fmt.Errorf("client_id URL must not contain userinfo (username or password)") + } + if parsed.Path == "" || parsed.Path == "/" { + return fmt.Errorf("client_id URL must contain a non-empty path component") + } + for _, segment := range strings.Split(parsed.Path, "/") { + if segment == "." || segment == ".." { + return fmt.Errorf("client_id URL must not contain dot-segment path components") + } + } + return nil +} + +// ValidateClientMetadataDocument validates a parsed ClientMetadataDocument +// against the URL it was fetched from. Per the CIMD draft spec, the +// client_id field must exactly equal the URL — no normalization is applied, +// because allowing normalization would permit subtle spoofing attacks where a +// document at URL A claims the identity of URL B. +func ValidateClientMetadataDocument(doc *ClientMetadataDocument, fetchedFrom string) error { + if doc.ClientID == "" { + return fmt.Errorf("client metadata document missing required field: client_id") + } + + if len(doc.RedirectURIs) == 0 { + return fmt.Errorf("client metadata document missing required field: redirect_uris") + } + + if doc.ClientID != fetchedFrom { + return fmt.Errorf("client_id %q does not match the URL it was fetched from %q", doc.ClientID, fetchedFrom) + } + + for _, uri := range doc.RedirectURIs { + if err := oauthproto.ValidateRedirectURI(uri, oauthproto.RedirectURIPolicyStrict); err != nil { + return err + } + } + + // Only symmetric shared-secret auth methods are forbidden; asymmetric methods + // (e.g. private_key_jwt, tls_client_auth, none) are permitted by the spec. + if doc.TokenEndpointAuthMethod != "" { + for _, forbidden := range forbiddenAuthMethods { + if doc.TokenEndpointAuthMethod == forbidden { + return fmt.Errorf("token_endpoint_auth_method %q is not permitted in a CIMD document: "+ + "symmetric shared-secret methods cannot be used without pre-registration", forbidden) + } + } + } + + return nil +} diff --git a/pkg/oauthproto/cimd/fetch_test.go b/pkg/oauthproto/cimd/fetch_test.go new file mode 100644 index 0000000000..cc884a13a1 --- /dev/null +++ b/pkg/oauthproto/cimd/fetch_test.go @@ -0,0 +1,350 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cimd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validDoc returns a ClientMetadataDocument that is valid when fetched from serverURL. +func validDoc(serverURL string) ClientMetadataDocument { + return ClientMetadataDocument{ + ClientID: serverURL, + RedirectURIs: []string{"https://example.com/callback"}, + ClientName: "Test Client", + } +} + +func TestFetchClientMetadataDocument(t *testing.T) { + t.Parallel() + + t.Run("valid document is fetched and parsed", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The client_id in the document must match the URL it is served from, + // so we build the expected URL dynamically from the request. + serverURL := "http://" + r.Host + r.URL.RequestURI() + doc := validDoc(serverURL) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + })) + t.Cleanup(server.Close) + + serverURL := server.URL + "/metadata.json" + // Patch the handler so the doc client_id matches the URL we will request. + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + doc := validDoc(serverURL) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + }) + + got, err := FetchClientMetadataDocument(context.Background(), serverURL) + require.NoError(t, err) + assert.Equal(t, serverURL, got.ClientID) + assert.Equal(t, []string{"https://example.com/callback"}, got.RedirectURIs) + assert.Equal(t, "Test Client", got.ClientName) + }) + + t.Run("non-HTTPS non-localhost URL is rejected", func(t *testing.T) { + t.Parallel() + + _, err := FetchClientMetadataDocument(context.Background(), "http://example.com/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "https scheme") + }) + + t.Run("localhost subdomain bypass is rejected", func(t *testing.T) { + t.Parallel() + + // http://localhost.evil.com/ must NOT be treated as loopback. + _, err := FetchClientMetadataDocument(context.Background(), "http://localhost.evil.com/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "https scheme") + }) + + t.Run("HTTP non-200 status returns error", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + _, err := FetchClientMetadataDocument(context.Background(), server.URL+"/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "404") + }) + + t.Run("body over 10KB is handled as truncated/malformed", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Write more than 10KB of data that is not valid JSON when truncated. + w.WriteHeader(http.StatusOK) + // Start a JSON object but pad it well beyond the limit so the decoder + // receives a truncated, invalid document. + fmt.Fprintf(w, `{"client_id":"x","redirect_uris":["https://a.example.com"],"padding":"`) //nolint:errcheck + w.Write([]byte(strings.Repeat("x", 11*1024))) //nolint:errcheck + fmt.Fprintf(w, `"}`) //nolint:errcheck + })) + t.Cleanup(server.Close) + + _, err := FetchClientMetadataDocument(context.Background(), server.URL+"/metadata.json") + require.Error(t, err) + }) + + t.Run("Content-Type not application/json returns error", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"client_id":"x"}`)) //nolint:errcheck + })) + t.Cleanup(server.Close) + + _, err := FetchClientMetadataDocument(context.Background(), server.URL+"/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "content type") + }) + + t.Run("application/json subtype accepted", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverURL := "http://" + r.Host + "/metadata.json" + doc := validDoc(serverURL) + w.Header().Set("Content-Type", "application/ld+json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + })) + t.Cleanup(server.Close) + + serverURL := server.URL + "/metadata.json" + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + doc := validDoc(serverURL) + w.Header().Set("Content-Type", "application/ld+json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + }) + + _, err := FetchClientMetadataDocument(context.Background(), serverURL) + require.NoError(t, err) + }) + + t.Run("client_id mismatch returns validation error", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + doc := ClientMetadataDocument{ + ClientID: "https://different.example.com/metadata.json", + RedirectURIs: []string{"https://example.com/callback"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + })) + t.Cleanup(server.Close) + + _, err := FetchClientMetadataDocument(context.Background(), server.URL+"/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "client_id") + }) + + t.Run("missing redirect_uris returns validation error", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverURL := "http://" + r.Host + "/metadata.json" + doc := ClientMetadataDocument{ + ClientID: serverURL, + RedirectURIs: nil, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + })) + t.Cleanup(server.Close) + + serverURL := server.URL + "/metadata.json" + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + doc := ClientMetadataDocument{ + ClientID: serverURL, + RedirectURIs: nil, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) //nolint:errcheck + }) + + _, err := FetchClientMetadataDocument(context.Background(), serverURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "redirect_uris") + }) + + t.Run("SSRF: URL scheme check rejects non-loopback HTTP", func(t *testing.T) { + t.Parallel() + + // This exercises validateCIMDClientURL, not the dial-guard. + _, err := FetchClientMetadataDocument(context.Background(), "http://10.0.0.1/metadata.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "https scheme") + }) + + t.Run("SSRF: dial-guard blocks private IP served via HTTPS hostname", func(t *testing.T) { + t.Parallel() + + // Spin up a real server on loopback so we have a valid port, then + // attempt to reach a private non-loopback IP over HTTPS — the DialContext + // SSRF guard must reject it at dial time. We use the HTTPS scheme so the + // URL passes validateCIMDClientURL and reaches the transport layer. + // + // 192.0.2.1 is a TEST-NET-1 documentation address (RFC5737): it is + // guaranteed to be unreachable and is in our SSRF block list. + _, err := FetchClientMetadataDocument(context.Background(), "https://192.0.2.1/metadata.json") + require.Error(t, err) + // The error must come from the dial guard, not a network timeout. + assert.Contains(t, err.Error(), "private address") + }) +} + +func TestValidateClientMetadataDocument(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc *ClientMetadataDocument + fetchedFrom string + wantErr bool + errContains string + }{ + { + name: "valid document passes", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"https://app.example.com/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: false, + }, + { + name: "valid document with loopback redirect passes", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"http://localhost:4000/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: false, + }, + { + name: "valid document with IPv4 loopback redirect passes", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"http://127.0.0.1:4000/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: false, + }, + { + name: "valid document with IPv6 loopback redirect passes", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"http://[::1]:4000/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: false, + }, + { + name: "empty client_id fails", + doc: &ClientMetadataDocument{ + ClientID: "", + RedirectURIs: []string{"https://example.com/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "client_id", + }, + { + name: "empty redirect_uris fails", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "redirect_uris", + }, + { + name: "nil redirect_uris fails", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: nil, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "redirect_uris", + }, + { + name: "client_id mismatch fails (strict string comparison)", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"https://example.com/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json/", + wantErr: true, + errContains: "client_id", + }, + { + name: "redirect_uri with ftp:// scheme fails", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"ftp://example.com/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "redirect_uri", + }, + { + name: "redirect_uri with http://example.com (non-loopback) fails", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"http://example.com/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "redirect_uri", + }, + { + name: "redirect_uri with http://192.168.1.1 (private non-loopback) fails", + doc: &ClientMetadataDocument{ + ClientID: "https://example.com/metadata.json", + RedirectURIs: []string{"http://192.168.1.1/callback"}, + }, + fetchedFrom: "https://example.com/metadata.json", + wantErr: true, + errContains: "redirect_uri", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateClientMetadataDocument(tt.doc, tt.fetchedFrom) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/oauthproto/cimd_test.go b/pkg/oauthproto/cimd_test.go new file mode 100644 index 0000000000..0f77f59f2d --- /dev/null +++ b/pkg/oauthproto/cimd_test.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "testing" +) + +func TestToolHiveClientMetadataDocumentURL(t *testing.T) { + t.Parallel() + + const want = "https://toolhive.dev/oauth/client-metadata.json" + if ToolHiveClientMetadataDocumentURL != want { + t.Errorf("ToolHiveClientMetadataDocumentURL = %q, want %q", ToolHiveClientMetadataDocumentURL, want) + } +} + +func TestIsClientIDMetadataDocumentURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + clientID string + want bool + }{ + {"CIMD URL (toolhive)", ToolHiveClientMetadataDocumentURL, true}, + {"arbitrary HTTPS URL", "https://example.com/client-metadata.json", true}, + {"HTTPS URL no path", "https://example.com", true}, + {"DCR-issued UUID", "some-uuid-client-id", false}, + {"HTTP URL non-loopback", "http://example.com/metadata.json", false}, + {"empty string", "", false}, + {"partial match", "xhttps://example.com", false}, + // Loopback HTTP URLs accepted for local development and integration tests + {"loopback localhost", "http://localhost/metadata.json", true}, + {"loopback localhost with port", "http://localhost:8080/metadata.json", true}, + {"loopback 127.0.0.1", "http://127.0.0.1/metadata.json", true}, + {"loopback 127.0.0.1 with port", "http://127.0.0.1:8080/metadata.json", true}, + {"loopback [::1]", "http://[::1]/metadata.json", true}, + {"loopback [::1] with port", "http://[::1]:8080/metadata.json", true}, + // Subdomain bypass attempts must be rejected + {"subdomain bypass localhost.evil.com", "http://localhost.evil.com/metadata.json", false}, + {"subdomain bypass 127.0.0.1.evil.com", "http://127.0.0.1.evil.com/metadata.json", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsClientIDMetadataDocumentURL(tt.clientID); got != tt.want { + t.Errorf("IsClientIDMetadataDocumentURL(%q) = %v, want %v", tt.clientID, got, tt.want) + } + }) + } +} diff --git a/pkg/oauthproto/constants.go b/pkg/oauthproto/constants.go new file mode 100644 index 0000000000..fce50b6cbd --- /dev/null +++ b/pkg/oauthproto/constants.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import "time" + +// Well-known endpoint paths as defined by RFC 8414, OpenID Connect Discovery 1.0, and RFC 9728. +const ( + // WellKnownOIDCPath is the standard OIDC discovery endpoint path + // per OpenID Connect Discovery 1.0 specification. + WellKnownOIDCPath = "/.well-known/openid-configuration" + + // WellKnownOAuthServerPath is the standard OAuth authorization server metadata endpoint path + // per RFC 8414 (OAuth 2.0 Authorization Server Metadata). + WellKnownOAuthServerPath = "/.well-known/oauth-authorization-server" + + // WellKnownOAuthResourcePath is the RFC 9728 standard path for OAuth Protected Resource metadata. + // Per RFC 9728 Section 3, this endpoint and any subpaths under it should be accessible + // without authentication to enable OIDC/OAuth discovery. + WellKnownOAuthResourcePath = "/.well-known/oauth-protected-resource" +) + +// Grant types as defined by RFC 6749. +const ( + // GrantTypeAuthorizationCode is the authorization code grant type (RFC 6749 Section 4.1). + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the refresh token grant type (RFC 6749 Section 6). + GrantTypeRefreshToken = "refresh_token" +) + +// Response types as defined by RFC 6749. +const ( + // ResponseTypeCode is the authorization code response type (RFC 6749 Section 4.1.1). + ResponseTypeCode = "code" +) + +// Token endpoint authentication methods as defined by RFC 7591. +const ( + // TokenEndpointAuthMethodNone indicates no client authentication (public clients). + // Typically used with PKCE for native/mobile applications. + TokenEndpointAuthMethodNone = "none" +) + +// PKCE (Proof Key for Code Exchange) methods as defined by RFC 7636. +const ( + // PKCEMethodS256 uses SHA-256 hash of the code verifier (recommended). + PKCEMethodS256 = "S256" +) + +// Token type URNs as defined by RFC 8693. +// +//nolint:gosec // G101: these are RFC 8693 token-type URN identifiers, not credentials +const ( + // TokenTypeAccessToken indicates an OAuth 2.0 access token (RFC 8693 Section 3). + TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" + + // TokenTypeIDToken indicates an OpenID Connect ID Token (RFC 8693 Section 3). + TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" + + // TokenTypeJWT indicates a JSON Web Token (RFC 8693 Section 3). + TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" +) + +// Grant type URNs for token exchange protocols. +// +//nolint:gosec // G101: this is an RFC 8693 grant-type URN identifier, not a credential +const ( + // GrantTypeTokenExchange is the OAuth 2.0 Token Exchange grant type (RFC 8693). + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + + // GrantTypeJWTBearer is the JWT Bearer grant type (RFC 7523). + GrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" +) + +// HTTP client constants. +const ( + // UserAgent is the User-Agent header value sent on all HTTP requests + // originating from this package and its callers. + UserAgent = "ToolHive/1.0" +) + +// HTTP client and response-handling defaults used by the OAuth grant helpers +// in this package (DoTokenRequest, ParseTokenResponse). Unexported: they are +// implementation defaults shared between grants, not part of the public API. +const ( + defaultHTTPTimeout = 30 * time.Second + maxResponseBodySize = 1 << 20 // 1 MiB — matches x/oauth2/internal/token.go. +) + +// URL scheme constants. +const ( + // schemeHTTPS is the URL scheme required for all OAuth / OIDC endpoints, + // except when the host is a loopback address (development). Unexported + // so the check stays internally consistent within this package. + schemeHTTPS = "https" +) diff --git a/pkg/auth/oauth/dynamic_registration.go b/pkg/oauthproto/dcr.go similarity index 66% rename from pkg/auth/oauth/dynamic_registration.go rename to pkg/oauthproto/dcr.go index 9a1719ac3b..0c33ec730f 100644 --- a/pkg/auth/oauth/dynamic_registration.go +++ b/pkg/oauthproto/dcr.go @@ -1,8 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package oauth provides OAuth 2.0 and OIDC authentication functionality. -package oauth +package oauthproto import ( "context" @@ -14,15 +13,12 @@ import ( "net/url" "strings" "time" - - "github.com/stacklok/toolhive/pkg/networking" - oauthproto "github.com/stacklok/toolhive/pkg/oauth" ) -// ToolHiveMCPClientName is the name of the ToolHive MCP client +// ToolHiveMCPClientName is the name advertised in dynamic client registration requests. const ToolHiveMCPClientName = "ToolHive MCP Client" -// DynamicClientRegistrationRequest represents the request for dynamic client registration (RFC 7591) +// DynamicClientRegistrationRequest represents the request for dynamic client registration (RFC 7591). type DynamicClientRegistrationRequest struct { // Required field according to RFC 7591 RedirectURIs []string `json:"redirect_uris"` @@ -33,23 +29,11 @@ type DynamicClientRegistrationRequest struct { GrantTypes []string `json:"grant_types,omitempty"` ResponseTypes []string `json:"response_types,omitempty"` Scopes ScopeList `json:"scope,omitempty"` -} - -// NewDynamicClientRegistrationRequest creates a new dynamic client registration request -func NewDynamicClientRegistrationRequest(scopes []string, callbackPort int) *DynamicClientRegistrationRequest { - redirectURIs := []string{fmt.Sprintf("http://localhost:%d/callback", callbackPort)} - - // Create dynamic registration request - registrationRequest := &DynamicClientRegistrationRequest{ - ClientName: ToolHiveMCPClientName, - RedirectURIs: redirectURIs, - TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone, // For PKCE flow - GrantTypes: []string{oauthproto.GrantTypeAuthorizationCode, oauthproto.GrantTypeRefreshToken}, - ResponseTypes: []string{oauthproto.ResponseTypeCode}, - Scopes: scopes, - } - return registrationRequest + // SoftwareID is the RFC 7591 Section 2 "software_id": a unique identifier + // for the client software. Optional; servers may capture it for audit + // or telemetry but it is not required to register. + SoftwareID string `json:"software_id,omitempty"` } // ScopeList represents the "scope" field in both dynamic client registration requests and responses. @@ -131,7 +115,7 @@ func (s *ScopeList) UnmarshalJSON(data []byte) error { return fmt.Errorf("invalid scope format: %s", string(data)) } -// DynamicClientRegistrationResponse represents the response from dynamic client registration (RFC 7591) +// DynamicClientRegistrationResponse represents the response from dynamic client registration (RFC 7591). type DynamicClientRegistrationResponse struct { // Required fields ClientID string `json:"client_id"` @@ -143,44 +127,98 @@ type DynamicClientRegistrationResponse struct { RegistrationAccessToken string `json:"registration_access_token,omitempty"` RegistrationClientURI string `json:"registration_client_uri,omitempty"` - // Echo back the essential request fields + // Echo back the essential request fields. RFC 7591 §3.2.1 requires the + // AS to return redirect_uris, token_endpoint_auth_method, grant_types, + // and response_types in every successful registration response, so + // those four are emitted unconditionally (no omitempty) — a partial + // response built by a future code path that skips ValidateDCRRequest + // would still produce a spec-conformant key set, even if the values + // are empty. ClientName string `json:"client_name,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - GrantTypes []string `json:"grant_types,omitempty"` - ResponseTypes []string `json:"response_types,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` Scopes ScopeList `json:"scope,omitempty"` } -// RegisterClientDynamically performs dynamic client registration (RFC 7591) +// RegisterClientDynamically performs RFC 7591 Dynamic Client Registration against +// the given registrationEndpoint. +// +// If client is nil, a default *http.Client with a 30 s timeout, 10 s TLS handshake +// timeout, and 10 s response-header timeout is used. Pass a non-nil client to supply +// custom transport settings (e.g., in tests using httptest.NewServer). func RegisterClientDynamically( ctx context.Context, registrationEndpoint string, request *DynamicClientRegistrationRequest, + client *http.Client, ) (*DynamicClientRegistrationResponse, error) { - return registerClientDynamicallyWithClient(ctx, registrationEndpoint, request, nil) + // Validate registration endpoint URL + if _, err := validateRegistrationEndpoint(registrationEndpoint); err != nil { + return nil, err + } + + // Reject a nil request before the dereference below; the nil check previously + // lived inside validateAndSetDefaults, but the shallow copy must come first. + if request == nil { + return nil, fmt.Errorf("registration request cannot be nil") + } + + // Shallow-copy the request before passing it to validateAndSetDefaults so + // that the caller's original struct is never mutated. Slice fields (RedirectURIs, + // GrantTypes, ResponseTypes, Scopes) share the same backing arrays, but + // validateAndSetDefaults only assigns new slices to nil/zero fields — it never + // appends to or modifies existing ones — so a shallow copy is safe here. + reqCopy := *request + if err := validateAndSetDefaults(&reqCopy); err != nil { + return nil, err + } + + // Create HTTP request + req, err := createHTTPRequest(ctx, registrationEndpoint, &reqCopy) + if err != nil { + return nil, err + } + + // Use caller-supplied client or build a default one + httpClient := buildHTTPClient(client) + + // Make the request + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform dynamic client registration: %w", err) + } + + // Handle response + response, err := handleHTTPResponse(resp) + if err != nil { + return nil, err + } + + //nolint:gosec // G706: client_id is public metadata from DCR response + slog.Debug("Successfully registered OAuth client dynamically", + "client_id", response.ClientID) + return response, nil } -// validateRegistrationEndpoint validates the registration endpoint URL +// validateRegistrationEndpoint validates the registration endpoint URL. func validateRegistrationEndpoint(registrationEndpoint string) (*url.URL, error) { registrationURL, err := url.Parse(registrationEndpoint) if err != nil { return nil, fmt.Errorf("invalid registration endpoint URL: %w", err) } - // Ensure HTTPS for security (except localhost for development) - if registrationURL.Scheme != "https" && !networking.IsLocalhost(registrationURL.Host) { + // Ensure HTTPS for security (except loopback addresses for development) + if registrationURL.Scheme != schemeHTTPS && !IsLoopbackHost(registrationURL.Host) { return nil, fmt.Errorf("registration endpoint must use HTTPS: %s", registrationEndpoint) } return registrationURL, nil } -// validateAndSetDefaults validates the request and sets default values +// validateAndSetDefaults validates the request and sets default values. func validateAndSetDefaults(request *DynamicClientRegistrationRequest) error { - if request == nil { - return fmt.Errorf("registration request cannot be nil") - } if len(request.RedirectURIs) == 0 { return fmt.Errorf("at least one redirect URI is required") } @@ -198,19 +236,19 @@ func validateAndSetDefaults(request *DynamicClientRegistrationRequest) error { request.ClientName = ToolHiveMCPClientName } if len(request.GrantTypes) == 0 { - request.GrantTypes = []string{oauthproto.GrantTypeAuthorizationCode, oauthproto.GrantTypeRefreshToken} + request.GrantTypes = []string{GrantTypeAuthorizationCode, GrantTypeRefreshToken} } if len(request.ResponseTypes) == 0 { - request.ResponseTypes = []string{oauthproto.ResponseTypeCode} + request.ResponseTypes = []string{ResponseTypeCode} } if request.TokenEndpointAuthMethod == "" { - request.TokenEndpointAuthMethod = oauthproto.TokenEndpointAuthMethodNone // For PKCE flow + request.TokenEndpointAuthMethod = TokenEndpointAuthMethodNone // For PKCE flow } return nil } -// createHTTPRequest creates the HTTP request for dynamic client registration +// createHTTPRequest creates the HTTP request for dynamic client registration. func createHTTPRequest( ctx context.Context, registrationEndpoint string, @@ -236,12 +274,19 @@ func createHTTPRequest( return req, nil } -// getHTTPClient returns the HTTP client to use for the request -func getHTTPClient(client networking.HTTPClient) networking.HTTPClient { - if client != nil { - return client - } - +// NewDefaultDCRClient returns the canonical bounded *http.Client used by +// RegisterClientDynamically when its caller does not supply one. It is +// exported so callers that need to wrap the transport (for example, to +// inject an RFC 7591 initial access token as an Authorization header) can +// reuse the same timeout policy and benefit automatically from any future +// tightening of these bounds. +// +// Timeouts: +// +// - Overall request timeout: 30 s +// - TLS handshake timeout: 10 s +// - Response-header timeout: 10 s +func NewDefaultDCRClient() *http.Client { return &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ @@ -251,7 +296,15 @@ func getHTTPClient(client networking.HTTPClient) networking.HTTPClient { } } -// handleHTTPResponse handles the HTTP response and validates it +// buildHTTPClient returns the caller-supplied client, or a default client if nil. +func buildHTTPClient(client *http.Client) *http.Client { + if client != nil { + return client + } + return NewDefaultDCRClient() +} + +// handleHTTPResponse handles the HTTP response and validates it. func handleHTTPResponse(resp *http.Response) (*DynamicClientRegistrationResponse, error) { defer func() { if err := resp.Body.Close(); err != nil { @@ -261,29 +314,39 @@ func handleHTTPResponse(resp *http.Response) (*DynamicClientRegistrationResponse // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - // Try to read error response - errorBody, _ := io.ReadAll(resp.Body) - - // Detect if DCR is not supported by the provider + // Cap the upstream's error body so a misbehaving or malicious AS + // cannot inflate operator logs by echoing arbitrary content. 8 KiB + // is far larger than any conformant RFC 7591 error response and + // still small enough that the surrounding error chain stays + // readable. + const maxErrorBodySize = 8 * 1024 + errorBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize)) + // Drain whatever exceeded the cap so net/http can reuse the TCP + // connection — without this the deferred Close terminates the + // connection mid-stream when the upstream returned more than 8 KiB. + _, _ = io.Copy(io.Discard, resp.Body) + + // Detect if DCR is not supported by the provider. // Common HTTP status codes when DCR is unsupported: - // - 404 Not Found: endpoint doesn't exist - // - 405 Method Not Allowed: endpoint exists but POST not allowed - // - 501 Not Implemented: DCR feature not implemented + // - 404 Not Found: endpoint doesn't exist + // - 405 Method Not Allowed: endpoint exists but POST not allowed + // - 501 Not Implemented: DCR feature not implemented if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented { - return nil, fmt.Errorf("this provider does not support Dynamic Client Registration (DCR) - HTTP %d. "+ - "Please configure OAuth client credentials using --remote-auth-client-id and --remote-auth-client-secret flags, "+ - "or register a client manually with the provider. Error details: %s", + return nil, fmt.Errorf( + "the provider does not support RFC 7591 Dynamic Client Registration (HTTP %d); "+ + "configure client credentials out of band. Error details: %s", resp.StatusCode, string(errorBody)) } return nil, fmt.Errorf("dynamic client registration failed with status %d: %s", resp.StatusCode, string(errorBody)) } - // Check content type + // Check content type; drain before returning to allow TCP connection reuse. contentType := resp.Header.Get("Content-Type") if !strings.Contains(contentType, "application/json") { + _, _ = io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("unexpected content type: %s", contentType) } @@ -305,47 +368,3 @@ func handleHTTPResponse(resp *http.Response) (*DynamicClientRegistrationResponse return &response, nil } - -// registerClientDynamicallyWithClient performs dynamic client registration with a custom HTTP client (private for testing) -func registerClientDynamicallyWithClient( - ctx context.Context, - registrationEndpoint string, - request *DynamicClientRegistrationRequest, - client networking.HTTPClient, -) (*DynamicClientRegistrationResponse, error) { - // Validate registration endpoint URL - if _, err := validateRegistrationEndpoint(registrationEndpoint); err != nil { - return nil, err - } - - // Validate request and set defaults - if err := validateAndSetDefaults(request); err != nil { - return nil, err - } - - // Create HTTP request - req, err := createHTTPRequest(ctx, registrationEndpoint, request) - if err != nil { - return nil, err - } - - // Get HTTP client - httpClient := getHTTPClient(client) - - // Make the request - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to perform dynamic client registration: %w", err) - } - - // Handle response - response, err := handleHTTPResponse(resp) - if err != nil { - return nil, err - } - - //nolint:gosec // G706: client_id is public metadata from DCR response - slog.Debug("Successfully registered OAuth client dynamically", - "client_id", response.ClientID) - return response, nil -} diff --git a/pkg/oauthproto/dcr_test.go b/pkg/oauthproto/dcr_test.go new file mode 100644 index 0000000000..9b373b26e9 --- /dev/null +++ b/pkg/oauthproto/dcr_test.go @@ -0,0 +1,753 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +func TestRegisterClientDynamically(t *testing.T) { + t.Parallel() + tests := []struct { + name string + request *oauthproto.DynamicClientRegistrationRequest + response string + responseStatus int + expectedError bool + expectedResult *oauthproto.DynamicClientRegistrationResponse + }{ + { + name: "successful registration", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + Scopes: []string{"openid", "profile"}, + }, + response: `{ + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_id_issued_at": 1234567890, + "client_secret_expires_at": 0, + "registration_access_token": "reg-token", + "registration_client_uri": "https://example.com/oauth/register/test-client-id" + }`, + responseStatus: http.StatusCreated, + expectedError: false, + expectedResult: &oauthproto.DynamicClientRegistrationResponse{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + ClientIDIssuedAt: 1234567890, + ClientSecretExpiresAt: 0, + RegistrationAccessToken: "reg-token", + RegistrationClientURI: "https://example.com/oauth/register/test-client-id", + }, + }, + { + name: "registration without client secret (PKCE flow)", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + }, + response: `{ + "client_id": "test-client-id", + "client_id_issued_at": 1234567890 + }`, + responseStatus: http.StatusCreated, + expectedError: false, + expectedResult: &oauthproto.DynamicClientRegistrationResponse{ + ClientID: "test-client-id", + ClientIDIssuedAt: 1234567890, + }, + }, + { + name: "server error", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + }, + response: `{"error": "invalid_request", "error_description": "Invalid request"}`, + responseStatus: http.StatusBadRequest, + expectedError: true, + }, + { + name: "DCR not supported - 404 Not Found", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + }, + response: `{"error": "not_found"}`, + responseStatus: http.StatusNotFound, + expectedError: true, + }, + { + name: "DCR not supported - 405 Method Not Allowed", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + }, + response: `{"error": "method_not_allowed"}`, + responseStatus: http.StatusMethodNotAllowed, + expectedError: true, + }, + { + name: "DCR not supported - 501 Not Implemented", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + }, + response: `{"error": "not_implemented", "error_description": "Dynamic Client Registration is not supported"}`, + responseStatus: http.StatusNotImplemented, + expectedError: true, + }, + { + name: "invalid request - no redirect URIs", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + }, + expectedError: true, + }, + { + name: "invalid request - scope with spaces", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + Scopes: []string{"openid", "profile email", "another"}, + }, + expectedError: true, + }, + { + name: "invalid request - scope with leading space", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + Scopes: []string{" openid"}, + }, + expectedError: true, + }, + { + name: "invalid request - scope with trailing space", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test Client", + RedirectURIs: []string{"http://localhost:8080/callback"}, + Scopes: []string{"openid "}, + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var server *httptest.Server + if tt.response != "" { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.responseStatus) + w.Write([]byte(tt.response)) + })) + t.Cleanup(server.Close) + } + + var registrationEndpoint string + var client *http.Client + if server != nil { + registrationEndpoint = server.URL + client = server.Client() + } else { + registrationEndpoint = "https://example.com/oauth/register" + } + + result, err := oauthproto.RegisterClientDynamically(context.Background(), registrationEndpoint, tt.request, client) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.expectedResult.ClientID, result.ClientID) + assert.Equal(t, tt.expectedResult.ClientSecret, result.ClientSecret) + assert.Equal(t, tt.expectedResult.ClientIDIssuedAt, result.ClientIDIssuedAt) + assert.Equal(t, tt.expectedResult.RegistrationAccessToken, result.RegistrationAccessToken) + assert.Equal(t, tt.expectedResult.RegistrationClientURI, result.RegistrationClientURI) + } + }) + } +} + +// TestRegisterClientDynamically_NilClientUsesDefault verifies that passing nil for client +// builds a default *http.Client that can successfully reach a loopback test server. +func TestRegisterClientDynamically_NilClientUsesDefault(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"client_id": "default-client"}`)) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + // nil client → default *http.Client is built internally + result, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "default-client", result.ClientID) +} + +// TestRegisterClientDynamically_CallerSuppliedClient verifies that a caller-supplied +// *http.Client is used (non-default path). +func TestRegisterClientDynamically_CallerSuppliedClient(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"client_id": "supplied-client"}`)) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + result, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, server.Client()) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "supplied-client", result.ClientID) +} + +// TestHandleHTTPResponse_DCRNotSupportedMessageIsProtocolNeutral verifies that the +// error message for 404/405/501 does NOT contain CLI-flag hints, which would leak +// CLI assumptions into the protocol package. +func TestHandleHTTPResponse_DCRNotSupportedMessageIsProtocolNeutral(t *testing.T) { + t.Parallel() + + cliHintPhrases := []string{ + "--remote-auth-client-id", + "--remote-auth-client-secret", + } + + for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(`{"error": "unsupported"}`)) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + _, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, server.Client()) + require.Error(t, err) + + errMsg := err.Error() + for _, phrase := range cliHintPhrases { + assert.NotContains(t, errMsg, phrase, + "error message must not contain CLI-flag hints (protocol-neutral message required)") + } + }) + } +} + +// TestValidateRegistrationEndpoint tests endpoint URL validation. +func TestValidateRegistrationEndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + name string + endpoint string + wantError bool + }{ + { + name: "HTTPS endpoint is valid", + endpoint: "https://example.com/oauth/register", + wantError: false, + }, + { + name: "localhost HTTP endpoint is valid", + endpoint: "http://localhost:8080/register", + wantError: false, + }, + { + name: "127.0.0.1 HTTP endpoint is valid", + endpoint: "http://127.0.0.1:8080/register", + wantError: false, + }, + { + name: "[::1] HTTP endpoint is valid", + endpoint: "http://[::1]:8080/register", + wantError: false, + }, + { + name: "non-HTTPS non-loopback is rejected", + endpoint: "http://example.com/oauth/register", + wantError: true, + }, + { + name: "malformed URL is rejected", + endpoint: "://bad-url", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Each subtest creates its own request so validateAndSetDefaults cannot race. + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + _, err := oauthproto.RegisterClientDynamically(context.Background(), tt.endpoint, req, &http.Client{}) + if tt.wantError { + assert.Error(t, err) + } else { + // We expect a network error (no server), not a validation error. + // The absence of "must use HTTPS" or "invalid" in the error confirms validation passed. + if err != nil { + errMsg := err.Error() + assert.NotContains(t, errMsg, "must use HTTPS") + assert.NotContains(t, errMsg, "invalid registration endpoint URL") + } + } + }) + } +} + +// TestValidateAndSetDefaults tests request validation and default population. +func TestValidateAndSetDefaults(t *testing.T) { + t.Parallel() + tests := []struct { + name string + request *oauthproto.DynamicClientRegistrationRequest + wantError bool + errorMsg string + }{ + { + name: "nil request is rejected", + request: nil, + wantError: true, + errorMsg: "cannot be nil", + }, + { + name: "empty redirect URIs is rejected", + request: &oauthproto.DynamicClientRegistrationRequest{ + ClientName: "Test", + }, + wantError: true, + errorMsg: "redirect URI", + }, + { + name: "scope with space is rejected", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + Scopes: []string{"openid profile"}, + }, + wantError: true, + errorMsg: "cannot contain spaces", + }, + { + name: "valid request sets defaults", + request: &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"client_id": "ok"}`)) + })) + t.Cleanup(server.Close) + + _, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, tt.request, server.Client()) + if tt.wantError { + require.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestScopeList_MarshalJSON tests that the ScopeList marshaling works correctly +// and produces RFC 7591 compliant space-delimited strings. +func TestScopeList_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopes oauthproto.ScopeList + wantJSON string + wantOmit bool // If true, expect omitempty to hide the field + }{ + { + name: "nil scopes => empty string (omitempty will hide at struct level)", + scopes: nil, + wantJSON: `""`, + wantOmit: true, + }, + { + name: "empty slice => empty string (omitempty will hide at struct level)", + scopes: oauthproto.ScopeList{}, + wantJSON: `""`, + wantOmit: true, + }, + { + name: "single scope => string", + scopes: oauthproto.ScopeList{"openid"}, + wantJSON: `"openid"`, + }, + { + name: "two scopes => space-delimited string", + scopes: oauthproto.ScopeList{"openid", "profile"}, + wantJSON: `"openid profile"`, + }, + { + name: "three scopes => space-delimited string", + scopes: oauthproto.ScopeList{"openid", "profile", "email"}, + wantJSON: `"openid profile email"`, + }, + { + name: "scopes with special characters", + scopes: oauthproto.ScopeList{"read:user", "write:repo"}, + wantJSON: `"read:user write:repo"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + jsonBytes, err := json.Marshal(tt.scopes) + require.NoError(t, err, "marshaling should succeed") + + jsonStr := string(jsonBytes) + assert.Equal(t, tt.wantJSON, jsonStr, "marshaled JSON should match expected format") + + // Verify omitempty behavior in a struct + if tt.wantOmit { + type testStruct struct { + Scope oauthproto.ScopeList `json:"scope,omitempty"` + } + s := testStruct{Scope: tt.scopes} + structJSON, err := json.Marshal(s) + require.NoError(t, err) + assert.Equal(t, "{}", string(structJSON), "omitempty should hide empty scope field") + } + }) + } +} + +// TestScopeList_UnmarshalJSON tests that the ScopeList unmarshaling works correctly. +func TestScopeList_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jsonIn string + want []string + wantErr bool + }{ + { + name: "space-delimited string", + jsonIn: `"openid profile email"`, + want: []string{"openid", "profile", "email"}, + }, + { + name: "empty string => nil", + jsonIn: `""`, + want: nil, + }, + { + name: "string with extra spaces", + jsonIn: `" openid profile "`, + want: []string{"openid", "profile"}, + }, + { + name: "normal array", + jsonIn: `["openid","profile","email"]`, + want: []string{"openid", "profile", "email"}, + }, + { + name: "array with whitespace and empties", + jsonIn: `[" openid ",""," profile "]`, + want: []string{"openid", "profile"}, + }, + { + name: "all-empty array => nil", + jsonIn: `[""," "]`, + want: nil, + }, + { + name: "explicit null => nil", + jsonIn: `null`, + want: nil, + }, + { + name: "invalid type (number)", + jsonIn: `123`, + wantErr: true, + }, + { + name: "invalid type (object)", + jsonIn: `{"not":"valid"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var s oauthproto.ScopeList + err := json.Unmarshal([]byte(tt.jsonIn), &s) + + if tt.wantErr { + assert.Error(t, err, "expected error but got none") + return + } + + assert.NoError(t, err, "unexpected unmarshal error") + assert.Equal(t, tt.want, []string(s)) + }) + } +} + +// TestDynamicClientRegistrationRequest_ScopeSerialization verifies RFC 7591 Section 2 compliance. +func TestDynamicClientRegistrationRequest_ScopeSerialization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopes []string + shouldOmitScope bool + expectedScopeJSON string + }{ + { + name: "nil scopes should omit scope field entirely", + scopes: nil, + shouldOmitScope: true, + }, + { + name: "empty slice scopes should omit scope field entirely", + scopes: []string{}, + shouldOmitScope: true, + }, + { + name: "single scope should be space-delimited string per RFC 7591", + scopes: []string{"openid"}, + shouldOmitScope: false, + expectedScopeJSON: `"scope":"openid"`, + }, + { + name: "multiple scopes should be space-delimited string per RFC 7591", + scopes: []string{"openid", "profile"}, + shouldOmitScope: false, + expectedScopeJSON: `"scope":"openid profile"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + request := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + Scopes: tt.scopes, + } + + jsonBytes, err := json.Marshal(request) + require.NoError(t, err, "JSON marshaling should succeed") + + jsonStr := string(jsonBytes) + + if tt.shouldOmitScope { + assert.NotContains(t, jsonStr, `"scope"`, + "JSON should NOT contain scope field when scopes are empty/nil") + } else { + assert.Contains(t, jsonStr, tt.expectedScopeJSON, + "JSON should contain expected scope field") + } + + assert.Contains(t, jsonStr, `"redirect_uris"`, "redirect_uris should be present") + }) + } +} + +// TestIsLoopbackHost exercises the private isLoopbackHost via validateRegistrationEndpoint. +// Positive cases (loopback) allow HTTP; negative cases (non-loopback) require HTTPS. +func TestIsLoopbackHost(t *testing.T) { + t.Parallel() + + tests := []struct { + endpoint string + wantHTTPS bool // true if HTTPS enforcement error is expected + }{ + // Loopback hosts: HTTP is allowed — validation passes (only network error follows) + {endpoint: "http://localhost/register", wantHTTPS: false}, + {endpoint: "http://localhost:8080/register", wantHTTPS: false}, + {endpoint: "http://127.0.0.1/register", wantHTTPS: false}, + {endpoint: "http://127.0.0.1:1234/register", wantHTTPS: false}, + {endpoint: "http://[::1]/register", wantHTTPS: false}, + {endpoint: "http://[::1]:80/register", wantHTTPS: false}, + // Non-loopback HTTP hosts: validation should fail with "must use HTTPS" + {endpoint: "http://example.com/register", wantHTTPS: true}, + {endpoint: "http://127.0.0.1.example.com/register", wantHTTPS: true}, + } + + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + t.Parallel() + + // Each subtest gets its own request to avoid races on validateAndSetDefaults. + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + _, err := oauthproto.RegisterClientDynamically(context.Background(), tt.endpoint, req, &http.Client{}) + + if tt.wantHTTPS { + require.Error(t, err) + assert.Contains(t, err.Error(), "must use HTTPS", + "non-loopback HTTP endpoint should be rejected") + } else { + // Network error expected (no server), but NOT a validation error + if err != nil { + assert.NotContains(t, err.Error(), "must use HTTPS", + "loopback host should bypass HTTPS requirement") + } + } + }) + } +} + +// TestDynamicClientRegistrationResponse_RoundTrip verifies the response type +// can be serialized and deserialized correctly. +func TestDynamicClientRegistrationResponse_RoundTrip(t *testing.T) { + t.Parallel() + + original := &oauthproto.DynamicClientRegistrationResponse{ + ClientID: "test-client-id", + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var result oauthproto.DynamicClientRegistrationResponse + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + assert.Equal(t, "test-client-id", result.ClientID) +} + +// TestRegisterClientDynamically_MissingClientID verifies that a response without +// client_id is rejected. +func TestRegisterClientDynamically_MissingClientID(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + // Missing client_id + w.Write([]byte(`{"client_secret": "secret"}`)) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + _, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, server.Client()) + require.Error(t, err) + assert.Contains(t, err.Error(), "client_id") +} + +// TestRegisterClientDynamically_NonJSONContentType verifies that non-JSON responses are rejected. +func TestRegisterClientDynamically_NonJSONContentType(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`error`)) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + _, err := oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, server.Client()) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "content type") +} + +// TestRegisterClientDynamically_LargeResponseBodyCapped verifies that handleHTTPResponse +// applies the 1 MB io.LimitReader cap and does not hang or panic when the server sends +// a body larger than the limit. The truncated body is not valid JSON, so a decode error +// is expected — the important property is "terminates promptly without OOM." +func TestRegisterClientDynamically_LargeResponseBodyCapped(t *testing.T) { + t.Parallel() + + const limitBytes = 1024 * 1024 // matches the cap in handleHTTPResponse + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Write a JSON prefix, then padding that pushes the body well past the 1 MB limit. + // The JSON decoder will read up to limitBytes through the LimitReader and then + // encounter a truncated string, returning a decode error rather than hanging. + prefix := `{"client_id":"ok","padding":"` + padding := strings.Repeat("x", limitBytes+512) + suffix := `"}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, prefix+padding+suffix) + })) + t.Cleanup(server.Close) + + req := &oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:8080/callback"}, + } + + // Must not block or panic. The truncated body produces a decode error; we do not + // assert a specific outcome beyond the call returning promptly. + _, _ = oauthproto.RegisterClientDynamically(context.Background(), server.URL, req, server.Client()) +} diff --git a/pkg/oauthproto/discovery.go b/pkg/oauthproto/discovery.go new file mode 100644 index 0000000000..6f2cd6bd18 --- /dev/null +++ b/pkg/oauthproto/discovery.go @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +// discoveryTimeout is the bounded per-call timeout applied when fetching +// authorization server metadata, regardless of the caller's context deadline. +// +// Declared as a var rather than a const so tests that need to exercise the +// timeout path can shorten it without waiting 10 s per run. Production code +// MUST NOT mutate this value — it is effectively constant outside tests. +var discoveryTimeout = 10 * time.Second + +// discoveryMaxResponseSize caps the response body size accepted from a +// discovery endpoint to prevent resource exhaustion from hostile servers. +const discoveryMaxResponseSize = 1024 * 1024 // 1MB + +// FetchAuthorizationServerMetadata fetches RFC 8414 authorization server +// metadata for the given issuer, using a three-path fallback that mirrors +// the guidance in RFC 8414 §3.1 and OpenID Connect Discovery 1.0. +// +// The paths are tried in this order: +// 1. RFC 8414 path-insertion: {origin}/.well-known/oauth-authorization-server{path} +// 2. OIDC Discovery: {issuer}/.well-known/openid-configuration +// 3. Bare RFC 8414: {origin}/.well-known/oauth-authorization-server +// +// A 200 OK response with a clean application/json body that passes the +// RFC 8414 §3.3 issuer check is a candidate. The first candidate with a +// non-empty registration_endpoint wins immediately. If no candidate has a +// registration_endpoint but at least one was otherwise valid, the first such +// partial document is returned with ErrRegistrationEndpointMissing — never +// short-circuiting on a partial doc, since a tenant-aware IdP may publish +// path-insertion metadata without DCR while the OIDC document advertises it. +// +// If client is nil, a default *http.Client with a bounded 10 s timeout is +// used. When client is non-nil, the same bounded 10 s per-call timeout is +// applied via context.WithTimeout on top of the caller's context to protect +// against callers that pass context.Background or a long deadline. +// +// Return contract: +// +// - On full success, returns (metadata, nil) with a non-empty +// RegistrationEndpoint. +// +// - When at least one candidate document was issuer-validated but every such +// document omits registration_endpoint, returns the first partial document +// paired with ErrRegistrationEndpointMissing. The metadata is non-nil so +// callers can reuse the other fields (TokenEndpoint, JWKSURI, etc.). Note +// this deviates from the usual Go "err != nil ⇒ value is invalid" idiom; +// callers must check with errors.Is and explicitly decide whether to use +// the partial doc: +// +// md, err := FetchAuthorizationServerMetadata(ctx, issuer, nil) +// switch { +// case errors.Is(err, ErrRegistrationEndpointMissing): +// // md is non-nil; DCR unavailable but token/JWKS endpoints usable. +// case err != nil: +// // md is nil; discovery failed. +// default: +// // md fully populated. +// } +// +// - On any other failure (no URL served a valid doc, transport/decode error, +// issuer mismatch), returns (nil, err). The returned error wraps the +// per-attempt errors via errors.Join so callers can use errors.Is / +// errors.As to inspect underlying causes (e.g. context.DeadlineExceeded). +func FetchAuthorizationServerMetadata( + ctx context.Context, + issuer string, + client *http.Client, +) (*AuthorizationServerMetadata, error) { + urls, err := buildDiscoveryURLs(issuer) + if err != nil { + return nil, err + } + + httpClient := buildDiscoveryHTTPClient(client) + + // Bound per-call timeout regardless of caller context. + fetchCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) + defer cancel() + + var attemptErrs []error + var partialMetadata *AuthorizationServerMetadata + for _, u := range urls { + metadata, err := fetchDiscoveryDocument(fetchCtx, httpClient, u, issuer) + if err != nil { + attemptErrs = append(attemptErrs, fmt.Errorf("%s: %w", u, err)) + continue + } + if metadata.RegistrationEndpoint == "" { + // Stash the first partial doc as a fallback, but keep iterating: + // a later URL (e.g. OIDC discovery) may advertise DCR even when + // the first hit (e.g. tenant-aware path-insertion) does not. + if partialMetadata == nil { + partialMetadata = metadata + } + continue + } + return metadata, nil + } + + if partialMetadata != nil { + return partialMetadata, ErrRegistrationEndpointMissing + } + + return nil, fmt.Errorf( + "failed to discover authorization server metadata for issuer %q: %w", + issuer, errors.Join(attemptErrs...), + ) +} + +// buildDiscoveryURLs constructs the discovery URLs to try per RFC 8414 §3.1 +// and OpenID Connect Discovery 1.0. The issuer must have an "https" scheme +// (or "http" with a loopback host, for development). +// +// URLs are returned in priority order with exact duplicates removed: for an +// issuer with no tenant path, path-insertion (1) and bare RFC 8414 (3) +// collapse to the same URL, so only two distinct URLs are returned. Callers +// must not rely on a fixed slice length. +func buildDiscoveryURLs(issuer string) ([]string, error) { + if issuer == "" { + return nil, fmt.Errorf("issuer is required") + } + + parsed, err := url.Parse(issuer) + if err != nil { + return nil, fmt.Errorf("invalid issuer URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid issuer URL: scheme and host are required") + } + if parsed.Scheme != schemeHTTPS && !IsLoopbackHost(parsed.Host) { + return nil, fmt.Errorf("issuer must use https (got %q)", parsed.Scheme) + } + + // Strip trailing slash from issuer path so URL joins stay predictable. + // Use the unescaped Path: assigning back into url.URL.Path means String() + // will escape exactly once. Using EscapedPath() here would re-escape + // percent-encoded tenants and produce e.g. "%252F" from "%2F". + tenant := strings.Trim(parsed.Path, "/") + + origin := &url.URL{Scheme: parsed.Scheme, Host: parsed.Host} + + // (1) RFC 8414 §3.1 path-insertion: /.well-known/oauth-authorization-server/{tenant} + pathInsertion := *origin + pathInsertion.Path = path.Join(WellKnownOAuthServerPath, tenant) + + // (2) OIDC Discovery: {issuer}/.well-known/openid-configuration + oidc := *origin + oidc.Path = path.Join("/", tenant, WellKnownOIDCPath) + + // (3) Bare RFC 8414: {origin}/.well-known/oauth-authorization-server + bare := *origin + bare.Path = WellKnownOAuthServerPath + + // Deduplicate while preserving order. A tenant-less issuer causes (1) and + // (3) to collapse to the same URL; emitting both would waste a round-trip + // and produce confusing duplicate entries in the aggregated error message. + candidates := []string{pathInsertion.String(), oidc.String(), bare.String()} + seen := make(map[string]struct{}, len(candidates)) + urls := make([]string, 0, len(candidates)) + for _, u := range candidates { + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + urls = append(urls, u) + } + return urls, nil +} + +// buildDiscoveryHTTPClient returns the caller-supplied client, or a default +// bounded client. The per-call timeout enforced via context.WithTimeout in +// FetchAuthorizationServerMetadata guarantees the bound is honored even when +// the caller supplies a client with a larger or missing Timeout. +func buildDiscoveryHTTPClient(client *http.Client) *http.Client { + if client != nil { + return client + } + return &http.Client{ + Timeout: discoveryTimeout, + Transport: &http.Transport{ + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + }, + } +} + +// FetchAuthorizationServerMetadataFromURL fetches RFC 8414 authorization +// server metadata from a single caller-supplied URL, bypassing the +// well-known-path fallback used by FetchAuthorizationServerMetadata. +// +// It is intended for cases where the operator has configured an explicit +// discovery document URL (for example a multi-tenant IdP that does not +// advertise the tenant-aware path at {issuer}/.well-known/...). The same +// RFC 8414 §3.3 issuer-equality check is enforced: the returned metadata's +// issuer field must exactly match the caller-supplied expectedIssuer. +// +// If client is nil, the same bounded default client used by +// FetchAuthorizationServerMetadata is constructed. A 10 s per-call timeout +// is applied via context.WithTimeout regardless of the caller's context +// deadline. +// +// Return contract mirrors FetchAuthorizationServerMetadata: +// +// - On full success, returns (metadata, nil) with a non-empty +// RegistrationEndpoint. +// - When the document is otherwise valid but omits +// registration_endpoint, returns (metadata, ErrRegistrationEndpointMissing). +// The metadata is non-nil so callers can reuse the other fields. +// - On any other failure (transport/decode error, issuer mismatch), +// returns (nil, err). +func FetchAuthorizationServerMetadataFromURL( + ctx context.Context, + discoveryURL string, + expectedIssuer string, + client *http.Client, +) (*AuthorizationServerMetadata, error) { + if discoveryURL == "" { + return nil, fmt.Errorf("discovery URL is required") + } + if expectedIssuer == "" { + return nil, fmt.Errorf("expected issuer is required") + } + + parsed, err := url.Parse(discoveryURL) + if err != nil { + return nil, fmt.Errorf("invalid discovery URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid discovery URL: scheme and host are required") + } + if parsed.Scheme != schemeHTTPS && !IsLoopbackHost(parsed.Host) { + return nil, fmt.Errorf("discovery URL must use https (got %q)", parsed.Scheme) + } + + httpClient := buildDiscoveryHTTPClient(client) + + fetchCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) + defer cancel() + + metadata, err := fetchDiscoveryDocument(fetchCtx, httpClient, discoveryURL, expectedIssuer) + if err != nil { + return nil, fmt.Errorf("fetch discovery document from %q: %w", discoveryURL, err) + } + if metadata.RegistrationEndpoint == "" { + return metadata, ErrRegistrationEndpointMissing + } + return metadata, nil +} + +// fetchDiscoveryDocument performs a single GET against a discovery URL and +// returns the parsed AuthorizationServerMetadata, enforcing RFC 8414 §3.3 +// issuer equality. +func fetchDiscoveryDocument( + ctx context.Context, + client *http.Client, + urlStr string, + expectedIssuer string, +) (*AuthorizationServerMetadata, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", UserAgent) + + resp, err := client.Do(req) //nolint:gosec // G107: URL is constructed from configured issuer + if err != nil { + return nil, fmt.Errorf("GET failed: %w", err) + } + defer func() { + // Drain fully before closing so the underlying TCP connection is + // eligible for reuse. The JSON decode path below caps the accepted + // body via io.LimitReader; we intentionally do NOT limit the drain + // here because a bounded drain leaves bytes in the kernel buffer + // and defeats connection reuse. + _, _ = io.Copy(io.Discard, resp.Body) + if cerr := resp.Body.Close(); cerr != nil { + slog.Debug("failed to close discovery response body", "error", cerr) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if !strings.Contains(contentType, "application/json") { + return nil, fmt.Errorf("unexpected content-type %q", contentType) + } + + var metadata AuthorizationServerMetadata + if err := json.NewDecoder(io.LimitReader(resp.Body, discoveryMaxResponseSize)).Decode(&metadata); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + // RFC 8414 §3.3: the issuer in the metadata MUST exactly match the caller's expected issuer. + if metadata.Issuer != expectedIssuer { + return nil, fmt.Errorf("issuer mismatch (RFC 8414 §3.3): expected %q, got %q", expectedIssuer, metadata.Issuer) + } + + return &metadata, nil +} + +// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server Metadata +// per RFC 8414. This is the base structure that OIDC Discovery extends. +type AuthorizationServerMetadata struct { + // Issuer is the authorization server's issuer identifier (REQUIRED per RFC 8414). + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the URL of the authorization endpoint (RECOMMENDED). + // Note: No omitempty to maintain backward compatibility with existing JSON serialization. + AuthorizationEndpoint string `json:"authorization_endpoint"` + + // TokenEndpoint is the URL of the token endpoint (RECOMMENDED). + // Note: No omitempty to maintain backward compatibility with existing JSON serialization. + TokenEndpoint string `json:"token_endpoint"` + + // JWKSURI is the URL of the JSON Web Key Set document (RECOMMENDED). + // Note: No omitempty to maintain backward compatibility with existing JSON serialization. + JWKSURI string `json:"jwks_uri"` + + // RegistrationEndpoint is the URL of the Dynamic Client Registration endpoint (OPTIONAL). + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // IntrospectionEndpoint is the URL of the token introspection endpoint (OPTIONAL, RFC 7662). + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // UserinfoEndpoint is the URL of the UserInfo endpoint (RECOMMENDED per OIDC Discovery, not in RFC 8414). + // Omitted from JSON when empty to avoid serializing an invalid URL value. + UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` + + // ResponseTypesSupported lists the response types supported (RECOMMENDED). + ResponseTypesSupported []string `json:"response_types_supported,omitempty"` + + // GrantTypesSupported lists the grant types supported (OPTIONAL). + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // CodeChallengeMethodsSupported lists the PKCE code challenge methods supported (OPTIONAL). + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported lists the authentication methods supported at the token endpoint (OPTIONAL). + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // ScopesSupported lists the OAuth 2.0 scope values supported (RECOMMENDED per RFC 8414). + // For MCP authorization servers, this typically includes "openid" and "offline_access". + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ClientIDMetadataDocumentSupported indicates the server accepts HTTPS URLs as client_id + // values per draft-ietf-oauth-client-id-metadata-document. + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` +} + +// OIDCDiscoveryDocument represents the OpenID Connect Discovery 1.0 document. +// It extends OAuth 2.0 Authorization Server Metadata (RFC 8414) with OIDC-specific fields. +// This unified type supports both producer (server) and consumer (client) use cases. +type OIDCDiscoveryDocument struct { + // Embed OAuth 2.0 AS Metadata (RFC 8414) as the base + AuthorizationServerMetadata + + // SubjectTypesSupported lists the subject identifier types supported (REQUIRED for OIDC). + SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` + + // IDTokenSigningAlgValuesSupported lists the JWS algorithms supported for ID tokens (REQUIRED for OIDC). + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"` + + // ClaimsSupported lists the claims that can be returned (RECOMMENDED for OIDC). + ClaimsSupported []string `json:"claims_supported,omitempty"` +} + +// Validate performs basic validation on the discovery document. +// It checks for required fields based on whether this is an OIDC or pure OAuth document. +func (d *OIDCDiscoveryDocument) Validate(isOIDC bool) error { + if d.Issuer == "" { + return ErrMissingIssuer + } + if d.AuthorizationEndpoint == "" { + return ErrMissingAuthorizationEndpoint + } + if d.TokenEndpoint == "" { + return ErrMissingTokenEndpoint + } + if isOIDC && d.JWKSURI == "" { + return ErrMissingJWKSURI + } + if isOIDC && len(d.ResponseTypesSupported) == 0 { + return ErrMissingResponseTypesSupported + } + return nil +} + +// SupportsPKCE returns true if the authorization server supports PKCE with S256. +func (d *OIDCDiscoveryDocument) SupportsPKCE() bool { + for _, method := range d.CodeChallengeMethodsSupported { + if method == PKCEMethodS256 { + return true + } + } + return false +} + +// SupportsGrantType returns true if the authorization server supports the given grant type. +func (d *OIDCDiscoveryDocument) SupportsGrantType(grantType string) bool { + for _, gt := range d.GrantTypesSupported { + if gt == grantType { + return true + } + } + return false +} diff --git a/pkg/oauthproto/discovery_test.go b/pkg/oauthproto/discovery_test.go new file mode 100644 index 0000000000..c09efac835 --- /dev/null +++ b/pkg/oauthproto/discovery_test.go @@ -0,0 +1,705 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCDiscoveryDocument_Validate(t *testing.T) { + t.Parallel() + + validDoc := func() OIDCDiscoveryDocument { + return OIDCDiscoveryDocument{ + AuthorizationServerMetadata: AuthorizationServerMetadata{ + Issuer: "https://example.com", + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + JWKSURI: "https://example.com/jwks", + ResponseTypesSupported: []string{"code"}, + }, + } + } + + tests := []struct { + name string + modify func(*OIDCDiscoveryDocument) + isOIDC bool + wantErr error + }{ + {"valid OAuth document", nil, false, nil}, + {"valid OIDC document", nil, true, nil}, + {"missing issuer", func(d *OIDCDiscoveryDocument) { d.Issuer = "" }, false, ErrMissingIssuer}, + {"missing authorization_endpoint", func(d *OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, false, ErrMissingAuthorizationEndpoint}, + {"missing token_endpoint", func(d *OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, false, ErrMissingTokenEndpoint}, + {"missing jwks_uri for OIDC", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, true, ErrMissingJWKSURI}, + {"missing jwks_uri for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, false, nil}, + {"missing response_types_supported for OIDC", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, true, ErrMissingResponseTypesSupported}, + {"missing response_types_supported for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, false, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + doc := validDoc() + if tt.modify != nil { + tt.modify(&doc) + } + err := doc.Validate(tt.isOIDC) + if !errors.Is(err, tt.wantErr) { + t.Errorf("Validate() = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestOIDCDiscoveryDocument_SupportsPKCE(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + methods []string + want bool + }{ + {"nil slice", nil, false}, + {"empty slice", []string{}, false}, + {"only plain", []string{"plain"}, false}, + {"S256 present", []string{"S256"}, true}, + {"both plain and S256", []string{"plain", "S256"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + doc := OIDCDiscoveryDocument{ + AuthorizationServerMetadata: AuthorizationServerMetadata{ + CodeChallengeMethodsSupported: tt.methods, + }, + } + if got := doc.SupportsPKCE(); got != tt.want { + t.Errorf("SupportsPKCE() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOIDCDiscoveryDocument_SupportsGrantType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + grants []string + grantType string + want bool + }{ + {"nil slice", nil, GrantTypeAuthorizationCode, false}, + {"empty slice", []string{}, GrantTypeAuthorizationCode, false}, + {"grant type present", []string{GrantTypeAuthorizationCode}, GrantTypeAuthorizationCode, true}, + {"grant type absent", []string{GrantTypeRefreshToken}, GrantTypeAuthorizationCode, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + doc := OIDCDiscoveryDocument{ + AuthorizationServerMetadata: AuthorizationServerMetadata{ + GrantTypesSupported: tt.grants, + }, + } + if got := doc.SupportsGrantType(tt.grantType); got != tt.want { + t.Errorf("SupportsGrantType(%q) = %v, want %v", tt.grantType, got, tt.want) + } + }) + } +} + +// writeJSONMetadata serves an AuthorizationServerMetadata document as JSON. +// Silently swallows encoding errors: the caller is an httptest server handler, +// which has no way to surface an error back to the test. +func writeJSONMetadata(w http.ResponseWriter, md AuthorizationServerMetadata) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) +} + +func TestFetchAuthorizationServerMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // handler builds the test HTTP handler given the issuer URL, since the + // issuer is only known after httptest.NewServer is started. + handler func(issuer string) http.HandlerFunc + // tenantPath is appended to the server URL to form the issuer under test. + // Empty string means the issuer is the server's root URL. + tenantPath string + }{ + { + name: "serves metadata from RFC 8414 path-insertion", + // Issuer has a tenant path; only the path-insertion URL responds. + tenantPath: "/tenants/acme", + handler: func(issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/oauth-authorization-server/tenants/acme" { + http.NotFound(w, r) + return + } + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + }) + } + }, + }, + { + name: "serves metadata from OIDC discovery path", + // Issuer has a tenant path. Path-insertion 404s; OIDC wins. + tenantPath: "/tenants/acme", + handler: func(issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tenants/acme/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + }) + } + }, + }, + { + name: "serves metadata from bare RFC 8414 path", + // Issuer has a tenant path so attempts 1 and 3 are distinct URLs. + // Only the bare path responds, proving the fallback reaches + // iteration 3 after 1 and 2 404. + tenantPath: "/tenants/acme", + handler: func(issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/oauth-authorization-server" { + http.NotFound(w, r) + return + } + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + }) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a placeholder server so we can derive an issuer URL; swap + // the real handler in after the URL is known. + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + + issuer := server.URL + tt.tenantPath + server.Config.Handler = tt.handler(issuer) + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), issuer, server.Client()) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) + assert.Equal(t, issuer+"/token", metadata.TokenEndpoint) + assert.Equal(t, issuer+"/register", metadata.RegistrationEndpoint) + }) + } +} + +func TestFetchAuthorizationServerMetadata_InvalidIssuer(t *testing.T) { + t.Parallel() + + // Exercise the input-validation branches of buildDiscoveryURLs via the + // public entrypoint: a nil client means no HTTP server is needed, since + // these inputs must be rejected before any request is made. + tests := []struct { + name string + issuer string + errSub string + }{ + {name: "empty issuer", issuer: "", errSub: "issuer is required"}, + {name: "malformed URL", issuer: "://not a url", errSub: "invalid issuer URL"}, + {name: "missing scheme and host", issuer: "example.com", errSub: "scheme and host are required"}, + {name: "http non-loopback host", issuer: "http://example.com", errSub: "issuer must use https"}, + {name: "ftp scheme", issuer: "ftp://example.com", errSub: "issuer must use https"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + metadata, err := FetchAuthorizationServerMetadata(context.Background(), tt.issuer, nil) + require.Error(t, err) + assert.Nil(t, metadata) + assert.Contains(t, err.Error(), tt.errSub) + }) + } +} + +func TestFetchAuthorizationServerMetadata_AllowsLoopbackHTTP(t *testing.T) { + t.Parallel() + + // httptest.NewServer binds to 127.0.0.1 over http, which must be accepted + // so local development and tests can run without a TLS certificate. + // Start with a placeholder handler so we can capture the server URL before + // the real handler closes over it. + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + issuer := server.URL + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + }) + }) + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), issuer, server.Client()) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) +} + +// TestFetchAuthorizationServerMetadata_TimeoutOverridesCallerContext cannot +// run with t.Parallel() because it mutates the package-level discoveryTimeout +// var; concurrent parallel tests would race when reading it. +// +//nolint:paralleltest // see comment above +func TestFetchAuthorizationServerMetadata_TimeoutOverridesCallerContext(t *testing.T) { + // Verifies the documented contract that the function applies a bounded + // per-call timeout via context.WithTimeout on top of the caller's context, + // so a caller passing context.Background does not hang forever on an + // unresponsive server. We shorten discoveryTimeout so the test finishes + // in well under a second rather than waiting the production 10 s. + originalTimeout := discoveryTimeout + discoveryTimeout = 100 * time.Millisecond + t.Cleanup(func() { discoveryTimeout = originalTimeout }) + + // Handler blocks until the request context is cancelled, so every URL + // the function tries will exceed the internal timeout. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + // Caller passes context.Background (no deadline). If the internal + // timeout were not applied, this call would hang indefinitely and the + // test runner would eventually time out with an unclear message. + done := make(chan struct{}) + var fetchErr error + go func() { + _, fetchErr = FetchAuthorizationServerMetadata(context.Background(), server.URL, server.Client()) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("FetchAuthorizationServerMetadata did not honor bounded internal timeout") + } + + require.Error(t, fetchErr) + assert.Contains(t, fetchErr.Error(), "failed to discover authorization server metadata") +} + +func TestFetchAuthorizationServerMetadata_IssuerMismatch(t *testing.T) { + t.Parallel() + + // Server returns metadata whose issuer disagrees with the caller's expected + // issuer. Every well-known URL the function tries returns the same bad + // document, so all three attempts fail and the aggregated error surfaces + // the RFC 8414 §3.3 mismatch. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{ + Issuer: "https://evil.example.com", + TokenEndpoint: "https://evil.example.com/token", + RegistrationEndpoint: "https://evil.example.com/register", + }) + })) + t.Cleanup(server.Close) + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), server.URL, server.Client()) + + require.Error(t, err) + require.Nil(t, metadata) + assert.Contains(t, err.Error(), "issuer mismatch") + assert.Contains(t, err.Error(), "RFC 8414") +} + +func TestFetchAuthorizationServerMetadata_MissingRegistrationEndpoint(t *testing.T) { + t.Parallel() + + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only the first attempted URL (path-insertion) serves a document; + // the others 404, so the first one is the winner. + if !strings.HasPrefix(r.URL.Path, "/.well-known/oauth-authorization-server") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + // RegistrationEndpoint intentionally omitted. + }) + })) + t.Cleanup(server.Close) + + issuer = server.URL + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), issuer, server.Client()) + + require.ErrorIs(t, err, ErrRegistrationEndpointMissing) + // Documented return contract: when the winning document lacks + // registration_endpoint, the function returns (non-nil metadata, + // ErrRegistrationEndpointMissing) so callers can still reuse the other + // fields via errors.Is. Assert the full partial document, not just + // non-nil, so future regressions that drop the metadata (or that stop + // populating a specific field) are caught. + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) + assert.Equal(t, issuer+"/token", metadata.TokenEndpoint) + assert.Empty(t, metadata.RegistrationEndpoint) +} + +// TestFetchAuthorizationServerMetadata_PreferFullDocOverPartial pins the +// no-short-circuit-on-partial behavior: when an earlier URL serves a valid +// document missing registration_endpoint and a later URL serves the full +// document, the full document must win. A real-world failure mode this +// guards against is a tenant-aware IdP whose path-insertion document does +// not advertise DCR while its OIDC discovery document does. +func TestFetchAuthorizationServerMetadata_PreferFullDocOverPartial(t *testing.T) { + t.Parallel() + + // Use a tenant-aware issuer so path-insertion and bare RFC 8414 URLs are + // distinct; that lets us serve a partial doc on path-insertion and a + // full doc on OIDC discovery without collisions. + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + issuer := server.URL + "/tenants/acme" + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server/tenants/acme": + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token-from-partial", + // RegistrationEndpoint intentionally omitted: partial doc. + }) + case "/tenants/acme/.well-known/openid-configuration": + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token-from-full", + RegistrationEndpoint: issuer + "/register", + }) + default: + http.NotFound(w, r) + } + }) + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), issuer, server.Client()) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer+"/register", metadata.RegistrationEndpoint, + "the OIDC document with a registration_endpoint must win over the partial path-insertion document") + assert.Equal(t, issuer+"/token-from-full", metadata.TokenEndpoint, + "the OIDC document, not the partial doc, must be returned in full") +} + +// errSentinelTransport is a stand-in for a transport-level failure (e.g. +// TLS or DNS error). It returns errSentinelTransportFailure from RoundTrip so the +// test can confirm the per-attempt error is wrapped and reachable via +// errors.Is on the aggregated discovery error. +var errSentinelTransportFailure = errors.New("oauthproto-test: simulated transport failure") + +type errSentinelTransport struct{} + +func (errSentinelTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + return nil, errSentinelTransportFailure +} + +// TestFetchAuthorizationServerMetadata_AggregatedErrorPreservesWrap verifies +// that per-attempt errors are joined via errors.Join (not flattened to a +// string) so callers can still inspect causes through errors.Is/errors.As. +func TestFetchAuthorizationServerMetadata_AggregatedErrorPreservesWrap(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: errSentinelTransport{}} + + metadata, err := FetchAuthorizationServerMetadata( + context.Background(), "https://idp.example.com/tenants/acme", client) + + require.Error(t, err) + require.Nil(t, metadata) + assert.ErrorIs(t, err, errSentinelTransportFailure, + "aggregated discovery error must wrap per-attempt errors so errors.Is can find them") +} + +// TestFetchAuthorizationServerMetadata_TenantWithEscapedChars guards against +// the EscapedPath/Path double-encoding regression: a tenant containing +// characters that url.PathEscape actually transforms (here, a space) must +// reach the IdP encoded exactly once. +func TestFetchAuthorizationServerMetadata_TenantWithEscapedChars(t *testing.T) { + t.Parallel() + + // Issuer path "tenants/acme corp" — the literal space MUST end up + // encoded as %20 in the request path, not as %2520. + const escapedTenant = "/tenants/acme%20corp" + const wantPathInsertion = "/.well-known/oauth-authorization-server/tenants/acme%20corp" + + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + issuer := server.URL + escapedTenant + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // EscapedPath always returns the wire form, regardless of whether + // Go's URL parser populated RawPath (it leaves RawPath empty when + // the canonical escaping of Path matches the on-the-wire form). + // A regression that double-encodes "%20" → "%2520" would alter + // EscapedPath here and produce a 404 below. + if r.URL.EscapedPath() != wantPathInsertion { + http.NotFound(w, r) + return + } + writeJSONMetadata(w, AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + }) + }) + + metadata, err := FetchAuthorizationServerMetadata(context.Background(), issuer, server.Client()) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) +} + +func TestFetchAuthorizationServerMetadataFromURL(t *testing.T) { + t.Parallel() + + var issuer string + var wellKnownHits int32 + var customHits int32 + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", func(_ http.ResponseWriter, _ *http.Request) { + // Tripwire — must not be contacted when caller pins an exact URL. + atomic.AddInt32(&wellKnownHits, 1) + }) + mux.HandleFunc("/tenants/acme/metadata", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&customHits, 1) + md := AuthorizationServerMetadata{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + JWKSURI: issuer + "/jwks", + RegistrationEndpoint: issuer + "/register", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + issuer = server.URL + + metadata, err := FetchAuthorizationServerMetadataFromURL( + context.Background(), + issuer+"/tenants/acme/metadata", + issuer, + server.Client(), + ) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) + assert.Equal(t, issuer+"/register", metadata.RegistrationEndpoint) + assert.EqualValues(t, 1, atomic.LoadInt32(&customHits), + "caller-supplied discovery URL must be fetched exactly once") + assert.EqualValues(t, 0, atomic.LoadInt32(&wellKnownHits), + "well-known fallback must not be contacted") +} + +func TestFetchAuthorizationServerMetadataFromURL_IssuerMismatchRejected(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + md := AuthorizationServerMetadata{ + Issuer: "https://different.example.com", + TokenEndpoint: "https://different.example.com/token", + RegistrationEndpoint: "https://different.example.com/register", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + })) + t.Cleanup(server.Close) + + _, err := FetchAuthorizationServerMetadataFromURL( + context.Background(), + server.URL+"/metadata", + server.URL, // expected issuer disagrees with server-advertised issuer + server.Client(), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer mismatch") +} + +func TestFetchAuthorizationServerMetadataFromURL_MissingRegistrationEndpoint(t *testing.T) { + t.Parallel() + + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + md := AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + // RegistrationEndpoint intentionally omitted. + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + })) + t.Cleanup(server.Close) + issuer = server.URL + + metadata, err := FetchAuthorizationServerMetadataFromURL( + context.Background(), + issuer+"/metadata", + issuer, + server.Client(), + ) + require.ErrorIs(t, err, ErrRegistrationEndpointMissing) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) + assert.Equal(t, issuer+"/token", metadata.TokenEndpoint) + assert.Empty(t, metadata.RegistrationEndpoint) +} + +func TestFetchAuthorizationServerMetadataFromURL_InputValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + discoveryURL string + issuer string + wantErrMsg string + }{ + { + name: "empty discovery URL", + discoveryURL: "", + issuer: "https://example.com", + wantErrMsg: "discovery URL is required", + }, + { + name: "empty issuer", + discoveryURL: "https://example.com/metadata", + issuer: "", + wantErrMsg: "expected issuer is required", + }, + { + name: "http non-loopback discovery URL rejected", + discoveryURL: "http://example.com/metadata", + issuer: "http://example.com", + wantErrMsg: "discovery URL must use https", + }, + { + name: "missing scheme", + discoveryURL: "example.com/metadata", + issuer: "https://example.com", + wantErrMsg: "scheme and host are required", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := FetchAuthorizationServerMetadataFromURL( + context.Background(), tc.discoveryURL, tc.issuer, nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrMsg) + }) + } +} + +func TestFetchAuthorizationServerMetadataFromURL_AllowsLoopbackHTTP(t *testing.T) { + t.Parallel() + + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + md := AuthorizationServerMetadata{ + Issuer: issuer, + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(md) + })) + t.Cleanup(server.Close) + issuer = server.URL + + metadata, err := FetchAuthorizationServerMetadataFromURL( + context.Background(), + issuer+"/metadata", + issuer, + server.Client(), + ) + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, issuer, metadata.Issuer) +} + +// TestFetchAuthorizationServerMetadata_DedupesPathInsertionAndBare locks in +// the documented behavior that, for a tenant-less issuer, the path-insertion +// (1) and bare RFC 8414 (3) URLs collapse to the same request, so only two +// distinct discovery requests are made: oauth-authorization-server and +// openid-configuration, in that priority order. +func TestFetchAuthorizationServerMetadata_DedupesPathInsertionAndBare(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + gotPaths []string + seenPaths = map[string]struct{}{} + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + if _, ok := seenPaths[r.URL.Path]; !ok { + seenPaths[r.URL.Path] = struct{}{} + gotPaths = append(gotPaths, r.URL.Path) + } + mu.Unlock() + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + // Tenant-less issuer; with no tenant path, URLs (1) and (3) are textually + // identical and must be deduplicated before the loop runs. + _, err := FetchAuthorizationServerMetadata(context.Background(), server.URL, server.Client()) + require.Error(t, err) // every URL 404s + + mu.Lock() + defer mu.Unlock() + require.Equal(t, + []string{ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + }, + gotPaths, + "expected exactly two distinct discovery requests in priority order: path-insertion before OIDC", + ) +} diff --git a/pkg/oauthproto/doc.go b/pkg/oauthproto/doc.go new file mode 100644 index 0000000000..b7d7c78ea6 --- /dev/null +++ b/pkg/oauthproto/doc.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package oauthproto provides shared RFC-defined types, constants, and validation +// utilities for OAuth 2.0 and OpenID Connect. It serves as a shared foundation for +// both OAuth clients and servers. +// +// Surface area: +// - RFC 8414 authorization server metadata types and well-known paths +// - Redirect URI validation per RFC 6749 and RFC 8252 +// - RFC 7591 Dynamic Client Registration client-side types: request/response +// types, ScopeList JSON codec, RegisterClientDynamically, ToolHiveMCPClientName. +// The authserver hosts its own server-side DCR types in +// pkg/authserver/server/registration. +// - Shared constants: UserAgent, well-known paths, grant types, PKCE methods +// +// Leaf-package invariant: this package has no dependency on +// github.com/stacklok/toolhive/pkg/networking. All callers that need both +// networking helpers and oauthproto types must import both packages independently. +package oauthproto diff --git a/pkg/oauth/errors.go b/pkg/oauthproto/errors.go similarity index 58% rename from pkg/oauth/errors.go rename to pkg/oauthproto/errors.go index 198eeec10b..aec870b2c4 100644 --- a/pkg/oauth/errors.go +++ b/pkg/oauthproto/errors.go @@ -1,18 +1,7 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oauth +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto import "errors" @@ -32,4 +21,9 @@ var ( // ErrMissingResponseTypesSupported indicates the response_types_supported field is missing (required for OIDC). ErrMissingResponseTypesSupported = errors.New("missing response_types_supported") + + // ErrRegistrationEndpointMissing indicates the winning authorization server metadata document + // does not advertise a registration_endpoint (RFC 7591). Callers that require Dynamic Client + // Registration should handle this sentinel and fall back to out-of-band client configuration. + ErrRegistrationEndpointMissing = errors.New("authorization server metadata missing registration_endpoint") ) diff --git a/pkg/oauthproto/grants.go b/pkg/oauthproto/grants.go new file mode 100644 index 0000000000..376baabb38 --- /dev/null +++ b/pkg/oauthproto/grants.go @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// The tokenJSON struct shape and expirationTime.UnmarshalJSON below are +// adapted from golang.org/x/oauth2/internal/token.go (BSD-3-Clause, +// compatible with Apache-2.0). The upstream file is the authoritative +// reference for handling the PayPal-style JSON-string expires_in field and +// for the RFC 6749 Section 5.2 "2xx body carries an error" rule. The shape +// here is extended with the RFC 8693 issued_token_type field and the scope +// field so a single entry point serves both the authorization-code and +// token-exchange grants. + +// Redact returns "" for an empty input and "[REDACTED]" otherwise. +// Grant-subpackage Config.String() methods use it to keep secrets (client +// secrets, JWT assertions, refresh tokens) out of logs and error output +// without each Config reimplementing the empty-vs-nonempty branch. +func Redact(value string) string { + if value == "" { + return "" + } + return "[REDACTED]" +} + +// TokenResponse is the public result of a successful token endpoint exchange. +// It composes *oauth2.Token rather than embedding it, so the Token's +// Valid() / Extra() / Type() helpers are not promoted onto this type and +// future oauth2.Token additions cannot leak into this package's API. +// +// For the fields that RFC 8693 Section 2.2.1 requires beyond the standard +// oauth2 token (issued_token_type and scope), callers read them off the +// response directly. +type TokenResponse struct { + // Token carries AccessToken, TokenType, RefreshToken, and Expiry populated + // from the response body. The Raw and other unexported fields on + // oauth2.Token are not set — use the sibling fields on TokenResponse. + Token *oauth2.Token + + // IssuedTokenType is the RFC 8693 issued_token_type URN when the server + // performed a token exchange. Empty for plain RFC 6749 responses. + IssuedTokenType string + + // Scope is the raw (space-separated) scope string returned by the server. + // Callers that need the list form can strings.Fields(scope). + Scope string +} + +// ParseTokenResponse is the single entry point for decoding a token endpoint +// response. It enforces RFC 6749 Sections 5.1 and 5.2 in a single place: +// +// - The body is decoded as JSON first, independent of the HTTP status. +// - If the status is non-2xx, OR the body carries an "error" field (RFC +// 6749 §5.2 — some providers return HTTP 200 with an error payload), +// an *oauth2.RetrieveError is returned and *TokenResponse is nil. +// - On success, access_token must be non-empty (RFC 6749 §5.1). token_type +// is intentionally NOT required here: the x/oauth2 library treats it as +// optional (Token.Type() defaults to "Bearer") and Google historically +// omits it. Per-grant callers are responsible for any stricter validation +// their specification demands. +// +// The caller is responsible for grant-specific validation (for example, +// RFC 8693 Section 2.2.1 requires the issued_token_type field to be present; +// that check belongs in the token-exchange grant's call site, not here). +// +// Malformed JSON on a failure status still yields an *oauth2.RetrieveError +// with the raw body preserved, so callers can surface the server's reply +// verbatim. Malformed JSON on a 2xx status is returned as a wrapped +// json.SyntaxError / json.UnmarshalTypeError via fmt.Errorf("%w", ...). +func ParseTokenResponse(resp *http.Response, body []byte) (*TokenResponse, error) { + failureStatus := resp.StatusCode < 200 || resp.StatusCode > 299 + + var tj tokenJSON + if err := json.Unmarshal(body, &tj); err != nil { + if failureStatus { + return nil, parseRetrieveError(resp, body) + } + return nil, fmt.Errorf("oauth: cannot parse token response: %w", err) + } + + if failureStatus || tj.ErrorCode != "" { + return nil, parseRetrieveError(resp, body) + } + + if tj.AccessToken == "" { + return nil, fmt.Errorf("oauth: token response missing access_token (RFC 6749 Section 5.1)") + } + + token := &oauth2.Token{ + AccessToken: tj.AccessToken, + TokenType: tj.TokenType, + RefreshToken: tj.RefreshToken, + } + if tj.ExpiresIn > 0 { + token.Expiry = time.Now().Add(time.Duration(tj.ExpiresIn) * time.Second) + } + + return &TokenResponse{ + Token: token, + IssuedTokenType: tj.IssuedTokenType, + Scope: tj.Scope, + }, nil +} + +// NewFormRequest builds an HTTP POST request for an OAuth 2.0 token endpoint +// call. The body is the URL-encoded form in data, with Content-Type and +// Content-Length set. When both clientID and clientSecret are non-empty, +// HTTP Basic authentication is attached per RFC 6749 Section 2.3.1; the +// credentials are URL-encoded before being passed to SetBasicAuth, which is +// what Go's SetBasicAuth and the OAuth 2.0 spec both require. +// +// Callers own the request's Context — pass the deadline or cancellation +// signal they want DoTokenRequest to honour. +func NewFormRequest( + ctx context.Context, + endpoint string, + data url.Values, + clientID, clientSecret string, +) (*http.Request, error) { + encoded := data.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("oauth: build token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Length", strconv.Itoa(len(encoded))) + + // RFC 6749 Section 2.3.1 requires URL-encoding of client credentials when + // sent via Basic auth, and Go's SetBasicAuth docs mirror that requirement + // for OAuth2 compatibility. + if clientID != "" && clientSecret != "" { + req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret)) + } + + return req, nil +} + +// DoTokenRequest executes a prepared token endpoint request and returns the +// parsed response. It is the high-level counterpart to NewFormRequest: most +// grants call NewFormRequest followed by DoTokenRequest. +// +// Passing a nil client is explicitly supported and selects the package-level +// shared default (see DefaultHTTPClient). Callers that need custom timeouts, +// a custom transport, or a test double MUST supply their own *http.Client — +// the nil shortcut does not take any caller-visible options. +// +// Behavior: +// +// - If client is nil, DefaultHTTPClient is used so callers automatically +// get the shared transport (connection reuse, consistent timeouts). +// - The response body is read with io.LimitReader capped at +// maxResponseBodySize (1 MiB, matching x/oauth2) before any parsing, so +// a pathological server cannot exhaust memory. +// - On every exit path — success, JSON decode failure, and RetrieveError +// — the body is closed. The body is deliberately NOT drained: +// io.Copy(io.Discard, resp.Body) would be unbounded on oversized or +// never-terminating bodies and would defeat the 1 MiB cap above. When +// the body exceeds the cap, net/http cannot reuse the connection; that +// is the intended tradeoff and matches x/oauth2/internal/token.go. +// - RFC 6749 Section 5.2 routing (a 2xx body with an "error" field) is +// handled inside ParseTokenResponse; DoTokenRequest surfaces the +// resulting *oauth2.RetrieveError unchanged. +// +// The request's own Context is authoritative: NewFormRequest builds the +// request with the caller's context attached, and client.Do observes +// req.Context() for cancellation and deadlines. A cancelled context fails +// fast without reaching the server. +func DoTokenRequest(client *http.Client, req *http.Request) (*TokenResponse, error) { + if client == nil { + client = DefaultHTTPClient() + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("oauth: token request failed: %w", err) + } + defer func() { + // Close without draining. Matching x/oauth2/internal/token.go — the + // LimitReader below caps how much we read, and draining the remainder + // via io.Copy(io.Discard, resp.Body) would be unbounded on oversized + // or never-terminating bodies, which defeats the 1 MiB memory cap. + // The tradeoff: when the body exceeds maxResponseBodySize, net/http + // cannot reuse the underlying connection. That is acceptable — the + // response is already pathological and connection reuse is not worth + // unbounded reads. + if closeErr := resp.Body.Close(); closeErr != nil { + slog.Debug("oauth: close token response body", "error", closeErr) + } + }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("oauth: read token response body: %w", err) + } + + return ParseTokenResponse(resp, body) +} + +// DefaultHTTPClient returns the process-wide *http.Client used by OAuth +// grant helpers when no explicit client is injected. Callers that build +// their own requests and call client.Do directly (without going through +// DoTokenRequest) use this to pick up the shared transport and inherit +// the same connection-reuse and timeout behavior as the helper path. +// +// The returned client is a process-wide singleton — callers MUST NOT mutate +// its Timeout or Transport fields. Code wanting custom timeouts must +// construct its own *http.Client and pass it to DoTokenRequest. +// +// http.Client is documented as safe for concurrent use by multiple goroutines +// (see https://pkg.go.dev/net/http#Client), so the returned value can be +// shared across goroutines without additional synchronization. +// +// TODO: consider a future opt-in SSRF-protected variant backed by +// pkg/networking.NewHttpClientBuilder. The builder blocks loopback and RFC +// 1918 ranges, which would break localhost IdPs (dex, Keycloak-in-Docker) +// and the httptest.NewServer-based tests that bind to 127.0.0.1. Not a +// default today for behavior-compatibility with pkg/oauthproto/tokenexchange. +func DefaultHTTPClient() *http.Client { + return sharedHTTPClient +} + +// sharedHTTPClient is the process-wide default client used by OAuth grant +// helpers (see DoTokenRequest, DefaultHTTPClient). Initialized once in init +// so callers share the underlying transport and benefit from connection +// reuse across grants. +var sharedHTTPClient *http.Client + +func init() { + // Base on http.DefaultTransport.Clone() so we inherit stdlib defaults: + // DialContext, Proxy: ProxyFromEnvironment (honors HTTP_PROXY/NO_PROXY + // for corporate deployments), MaxIdleConns=100, IdleConnTimeout=90s, + // ForceAttemptHTTP2=true. Overriding only the handshake timeout keeps + // pool tuning and HTTP/2 auto-upgrade consistent with the rest of the + // ecosystem. + transport := http.DefaultTransport.(*http.Transport).Clone() + // Tighter than stdlib's 10s default is not needed here, but an explicit + // cap prevents a hanging TLS handshake from silently consuming the + // outer 30s request budget. + transport.TLSHandshakeTimeout = 10 * time.Second + // Deliberately NOT setting ResponseHeaderTimeout: some corporate IdP + // chains (Entra OBO, federated Okta) legitimately take >10s to produce + // the first byte. The outer Client.Timeout (defaultHTTPTimeout) is the + // authoritative budget for the whole exchange. + sharedHTTPClient = &http.Client{ + Timeout: defaultHTTPTimeout, + Transport: transport, + } +} + +// tokenJSON is the wire shape decoded from a token endpoint response body. +// It intentionally covers both success and failure paths: RFC 6749 §5.2 +// allows an "error" code to appear in a body returned with a 2xx status, so +// ParseTokenResponse decodes into this struct unconditionally before +// deciding which branch to return. +type tokenJSON struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn expirationTime `json:"expires_in"` // number OR decimal string (PayPal, etc.) + IssuedTokenType string `json:"issued_token_type"` + Scope string `json:"scope"` + + // RFC 6749 Section 5.2 error fields; may legitimately appear in 2xx bodies. + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` +} + +// expirationTime accepts either a JSON number or a JSON string containing a +// decimal integer. At least PayPal returns expires_in as a string; a naive +// int field would fail to decode there. Negative values are treated as zero +// (no expiry), values larger than math.MaxInt32 are clamped. Copied with +// attribution from golang.org/x/oauth2/internal/token.go. +type expirationTime int32 + +// UnmarshalJSON implements json.Unmarshaler. +func (e *expirationTime) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + var n json.Number + if err := json.Unmarshal(b, &n); err != nil { + return err + } + i, err := n.Int64() + if err != nil { + return err + } + if i < 0 { + i = 0 + } + if i > math.MaxInt32 { + i = math.MaxInt32 + } + *e = expirationTime(i) + return nil +} + +// retrieveErrorBody mirrors the RFC 6749 Section 5.2 fields that may appear +// in a token endpoint error response (both 2xx and non-2xx bodies — some +// providers return 200 with "error" set). +type retrieveErrorBody struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` +} + +// parseRetrieveError builds an *oauth2.RetrieveError from a token endpoint +// response. The resulting error always has Response and Body populated; the +// three OAuth fields are best-effort — a malformed or non-JSON body yields +// empty strings rather than an error. This helper is the single funnel +// ParseTokenResponse uses to report both non-2xx responses and 2xx bodies +// that carry an "error" field (RFC 6749 §5.2). +// +// Unexported: callers route through ParseTokenResponse. +func parseRetrieveError(resp *http.Response, body []byte) *oauth2.RetrieveError { + retrieveErr := &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + + // Best-effort decode; malformed JSON leaves the OAuth fields empty. + var parsed retrieveErrorBody + if err := json.Unmarshal(body, &parsed); err == nil { + retrieveErr.ErrorCode = parsed.ErrorCode + retrieveErr.ErrorDescription = parsed.ErrorDescription + retrieveErr.ErrorURI = parsed.ErrorURI + } + + return retrieveErr +} diff --git a/pkg/oauthproto/grants_test.go b/pkg/oauthproto/grants_test.go new file mode 100644 index 0000000000..b44c8e7447 --- /dev/null +++ b/pkg/oauthproto/grants_test.go @@ -0,0 +1,912 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/sync/errgroup" +) + +func TestRedact(t *testing.T) { + t.Parallel() + assert.Equal(t, "", Redact("")) + assert.Equal(t, "[REDACTED]", Redact("secret")) +} + +func TestDefaultHTTPClient_TimeoutsAndTransport(t *testing.T) { + t.Parallel() + + client := DefaultHTTPClient() + require.NotNil(t, client) + + tests := []struct { + name string + check func(t *testing.T) + }{ + { + name: "total timeout is 30s", + check: func(t *testing.T) { + t.Helper() + assert.Equal(t, 30*time.Second, client.Timeout) + }, + }, + { + name: "transport is *http.Transport", + check: func(t *testing.T) { + t.Helper() + _, ok := client.Transport.(*http.Transport) + assert.True(t, ok, "Transport must be *http.Transport for tuning, got %T", client.Transport) + }, + }, + { + name: "TLS handshake timeout is 10s", + check: func(t *testing.T) { + t.Helper() + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + assert.Equal(t, 10*time.Second, transport.TLSHandshakeTimeout) + }, + }, + { + // ResponseHeaderTimeout is intentionally NOT set: some corporate + // IdP chains (Entra OBO, federated Okta) legitimately take >10s + // to respond with the first byte. The outer Client.Timeout is + // the authoritative budget for the whole exchange. + name: "response header timeout is unset", + check: func(t *testing.T) { + t.Helper() + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + assert.Zero(t, transport.ResponseHeaderTimeout, + "ResponseHeaderTimeout must remain unset so slow IdP chains can respond within the outer Timeout") + }, + }, + { + // Base-transport is http.DefaultTransport.Clone(), so Proxy is + // ProxyFromEnvironment — honors HTTP_PROXY/NO_PROXY for corporate + // deployments. A nil Proxy here would mean we lost the stdlib + // default and are shipping a client that cannot traverse a + // corporate egress proxy. + name: "proxy inherits from environment", + check: func(t *testing.T) { + t.Helper() + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + assert.NotNil(t, transport.Proxy, + "Proxy must be set (expected ProxyFromEnvironment inherited from http.DefaultTransport.Clone())") + }, + }, + { + name: "returns same singleton on repeated calls", + check: func(t *testing.T) { + t.Helper() + // Connection reuse depends on callers receiving the same client. + assert.Same(t, client, DefaultHTTPClient()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.check(t) + }) + } +} + +// TestDefaultHTTPClient_ConcurrentUse exercises the documented goroutine +// safety of *http.Client. A race here would signal a regression in either +// the client configuration or the test itself; run with `task test` which +// includes `-race`. +func TestDefaultHTTPClient_ConcurrentUse(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + t.Cleanup(server.Close) + + const workers = 100 + var g errgroup.Group + for i := 0; i < workers; i++ { + g.Go(func() error { + resp, err := DefaultHTTPClient().Get(server.URL) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, resp.Body) + return resp.Body.Close() + }) + } + // errgroup.Wait fails fast on any goroutine error (including panics + // surfaced via recover at the caller level if they were to be added), + // so a regression surfaces here instead of hanging until `go test` + // hits its global timeout. + require.NoError(t, g.Wait()) +} + +func TestExpirationTime_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want expirationTime + wantErr bool + }{ + {name: "empty input", input: ``, want: 0}, + {name: "null literal", input: `null`, want: 0}, + {name: "number", input: `3600`, want: 3600}, + {name: "decimal string", input: `"3600"`, want: 3600}, + {name: "negative number clamped to zero", input: `-1`, want: 0}, + {name: "overflow clamped to MaxInt32", input: `99999999999`, want: 2147483647}, + {name: "non-numeric string", input: `"abc"`, wantErr: true}, + {name: "bool", input: `true`, wantErr: true}, + {name: "decimal (float) rejected", input: `3.14`, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var got expirationTime + err := got.UnmarshalJSON([]byte(tc.input)) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseTokenResponse_Success(t *testing.T) { + t.Parallel() + + type want struct { + accessToken string + tokenType string + refreshToken string + issuedTokenType string + scope string + expectExpiry bool // true: Expiry must be ~now+seconds; false: Expiry.IsZero() + expirySeconds int + } + + tests := []struct { + name string + body string + want want + }{ + { + name: "happy path all 6 fields populated", + body: `{ + "access_token":"at-1", + "token_type":"Bearer", + "refresh_token":"rt-1", + "expires_in":3600, + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "scope":"read write" + }`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + refreshToken: "rt-1", + issuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + scope: "read write", + expectExpiry: true, + expirySeconds: 3600, + }, + }, + { + name: "expires_in as JSON number", + body: `{"access_token":"at-1","token_type":"Bearer","expires_in":7200}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + expectExpiry: true, + expirySeconds: 7200, + }, + }, + { + // This is the critical PayPal-style case: some IdPs return + // expires_in as a JSON string. A naive `int` field would fail + // to decode and ship a latent regression. + name: "expires_in as JSON string (PayPal-style)", + body: `{"access_token":"at-1","token_type":"Bearer","expires_in":"3600"}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + expectExpiry: true, + expirySeconds: 3600, + }, + }, + { + name: "expires_in missing", + body: `{"access_token":"at-1","token_type":"Bearer"}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + expectExpiry: false, + }, + }, + { + name: "expires_in is zero", + body: `{"access_token":"at-1","token_type":"Bearer","expires_in":0}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + expectExpiry: false, + }, + }, + { + name: "expires_in is negative (clamped to zero)", + body: `{"access_token":"at-1","token_type":"Bearer","expires_in":-1}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + expectExpiry: false, + }, + }, + { + // Google historically omits token_type. The library accepts it + // (Token.Type() defaults to "Bearer"); rejecting it would be + // stricter than x/oauth2. Per-grant validation lives at the call + // site. + name: "missing token_type is accepted", + body: `{"access_token":"at-1"}`, + want: want{ + accessToken: "at-1", + }, + }, + { + name: "unknown extra fields are ignored", + body: `{"access_token":"at-1","token_type":"Bearer","unknown_field":"x","nested":{"a":1}}`, + want: want{ + accessToken: "at-1", + tokenType: "Bearer", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{StatusCode: http.StatusOK} + before := time.Now() + tokenResp, err := ParseTokenResponse(resp, []byte(tc.body)) + after := time.Now() + + require.NoError(t, err) + require.NotNil(t, tokenResp) + require.NotNil(t, tokenResp.Token, "TokenResponse must compose a non-nil *oauth2.Token") + + assert.Equal(t, tc.want.accessToken, tokenResp.Token.AccessToken) + assert.Equal(t, tc.want.tokenType, tokenResp.Token.TokenType) + assert.Equal(t, tc.want.refreshToken, tokenResp.Token.RefreshToken) + assert.Equal(t, tc.want.issuedTokenType, tokenResp.IssuedTokenType) + assert.Equal(t, tc.want.scope, tokenResp.Scope) + + if tc.want.expectExpiry { + minExpiry := before.Add(time.Duration(tc.want.expirySeconds) * time.Second) + maxExpiry := after.Add(time.Duration(tc.want.expirySeconds) * time.Second) + assert.False(t, tokenResp.Token.Expiry.IsZero(), "Expiry should be set") + assert.True(t, + !tokenResp.Token.Expiry.Before(minExpiry) && !tokenResp.Token.Expiry.After(maxExpiry), + "Expiry %v not in [%v, %v]", tokenResp.Token.Expiry, minExpiry, maxExpiry, + ) + } else { + assert.True(t, tokenResp.Token.Expiry.IsZero(), "Expiry should be zero") + } + }) + } +} + +func TestParseTokenResponse_Failures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantErrMsg string // substring of err.Error() + }{ + { + name: "missing access_token", + body: `{"token_type":"Bearer"}`, + wantErrMsg: "missing access_token", + }, + { + name: "malformed JSON on 2xx is a decode error", + body: `{not valid json`, + wantErrMsg: "cannot parse token response", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{StatusCode: http.StatusOK} + tokenResp, err := ParseTokenResponse(resp, []byte(tc.body)) + + require.Error(t, err) + assert.Nil(t, tokenResp) + assert.Contains(t, err.Error(), tc.wantErrMsg) + }) + } +} + +func TestParseTokenResponse_RetrieveErrorPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + wantErrorCode string + wantErrorDesc string + wantErrorURI string + wantBodyContains string + }{ + { + // RFC 6749 §5.2: the "error" field can appear in a 2xx body. + // x/oauth2 handles this; a naive "status<400?parseOK:parseError" + // split would silently ship a bug. ParseTokenResponse routes + // this case to *oauth2.RetrieveError. + name: "2xx body with error field", + statusCode: http.StatusOK, + body: `{"error":"invalid_grant","error_description":"token expired"}`, + wantErrorCode: "invalid_grant", + wantErrorDesc: "token expired", + }, + { + name: "4xx body with all three fields", + statusCode: http.StatusBadRequest, + body: `{"error":"invalid_grant","error_description":"token expired","error_uri":"https://idp.example/errors/invalid_grant"}`, + wantErrorCode: "invalid_grant", + wantErrorDesc: "token expired", + wantErrorURI: "https://idp.example/errors/invalid_grant", + }, + { + name: "4xx non-JSON body", + statusCode: http.StatusInternalServerError, + body: `upstream down`, + wantBodyContains: "upstream down", + }, + { + name: "4xx with only error code", + statusCode: http.StatusBadRequest, + body: `{"error":"invalid_client"}`, + wantErrorCode: "invalid_client", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{StatusCode: tc.statusCode} + tokenResp, err := ParseTokenResponse(resp, []byte(tc.body)) + + require.Error(t, err) + assert.Nil(t, tokenResp) + + var retrieveErr *oauth2.RetrieveError + require.True(t, errors.As(err, &retrieveErr), + "expected *oauth2.RetrieveError, got %T: %v", err, err) + + assert.Same(t, resp, retrieveErr.Response) + assert.Equal(t, []byte(tc.body), retrieveErr.Body) + assert.Equal(t, tc.wantErrorCode, retrieveErr.ErrorCode) + assert.Equal(t, tc.wantErrorDesc, retrieveErr.ErrorDescription) + assert.Equal(t, tc.wantErrorURI, retrieveErr.ErrorURI) + if tc.wantBodyContains != "" { + assert.Contains(t, string(retrieveErr.Body), tc.wantBodyContains) + } + }) + } +} + +func TestNewFormRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + endpoint string + data url.Values + clientID string + clientSecret string + wantBasicAuth bool + wantUser string // URL-encoded form expected on the wire + wantPass string + }{ + { + name: "form body with basic auth", + endpoint: "https://idp.example/token", + data: url.Values{"grant_type": {"authorization_code"}, "code": {"abc"}}, + clientID: "client-1", + clientSecret: "secret-1", + wantBasicAuth: true, + wantUser: "client-1", + wantPass: "secret-1", + }, + { + name: "no basic auth when clientID is empty", + endpoint: "https://idp.example/token", + data: url.Values{"grant_type": {"authorization_code"}}, + clientID: "", + clientSecret: "secret-1", + wantBasicAuth: false, + }, + { + name: "no basic auth when clientSecret is empty", + endpoint: "https://idp.example/token", + data: url.Values{"grant_type": {"authorization_code"}}, + clientID: "client-1", + clientSecret: "", + wantBasicAuth: false, + }, + { + name: "credentials with special characters are URL-encoded", + endpoint: "https://idp.example/token", + data: url.Values{"grant_type": {"authorization_code"}}, + clientID: "client:with@special", + clientSecret: "pa ss/wd+?", + wantBasicAuth: true, + wantUser: "client%3Awith%40special", + wantPass: "pa+ss%2Fwd%2B%3F", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := NewFormRequest(context.Background(), tc.endpoint, tc.data, tc.clientID, tc.clientSecret) + require.NoError(t, err) + + // Method + URL. + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, tc.endpoint, req.URL.String()) + + // Content-Type. + assert.Equal(t, "application/x-www-form-urlencoded", req.Header.Get("Content-Type")) + + // Body bytes match the URL-encoded form. + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, tc.data.Encode(), string(bodyBytes)) + + // Basic auth expectations via req.BasicAuth (Go's built-in decoder). + user, pass, ok := req.BasicAuth() + if tc.wantBasicAuth { + require.True(t, ok, "expected Basic auth, Authorization=%q", req.Header.Get("Authorization")) + assert.Equal(t, tc.wantUser, user) + assert.Equal(t, tc.wantPass, pass) + } else { + assert.False(t, ok, "expected no Basic auth") + assert.Empty(t, req.Header.Get("Authorization")) + } + }) + } +} + +func TestNewFormRequest_InvalidEndpoint(t *testing.T) { + t.Parallel() + + // http.NewRequestWithContext rejects control characters in the URL. + _, err := NewFormRequest(context.Background(), "http://\x00invalid", url.Values{}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "oauth: build token request") +} + +func TestDoTokenRequest_NilClientUsesDefault(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"at-1","token_type":"Bearer"}`)) + })) + t.Cleanup(server.Close) + + req, err := NewFormRequest(context.Background(), server.URL, url.Values{"grant_type": {"x"}}, "", "") + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(nil, req) + require.NoError(t, err) + require.NotNil(t, tokenResp) + assert.Equal(t, "at-1", tokenResp.Token.AccessToken) +} + +func TestDoTokenRequest_TwoXXWithErrorRoutesToRetrieveError(t *testing.T) { + t.Parallel() + + // RFC 6749 §5.2 gotcha: 200 OK with an "error" field in the body. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"token expired"}`)) + })) + t.Cleanup(server.Close) + + req, err := NewFormRequest(context.Background(), server.URL, url.Values{}, "", "") + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(server.Client(), req) + assert.Nil(t, tokenResp) + require.Error(t, err) + + var retrieveErr *oauth2.RetrieveError + require.True(t, errors.As(err, &retrieveErr)) + assert.Equal(t, "invalid_grant", retrieveErr.ErrorCode) + assert.Equal(t, "token expired", retrieveErr.ErrorDescription) +} + +// TestDoTokenRequest_ContextCancellation verifies a pre-cancelled context +// is rejected before the server is contacted. +func TestDoTokenRequest_ContextCancellation(t *testing.T) { + t.Parallel() + + var hit atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + req, err := NewFormRequest(ctx, server.URL, url.Values{}, "", "") + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(server.Client(), req) + require.Error(t, err) + assert.Nil(t, tokenResp) + assert.Zero(t, hit.Load(), "server should not have been contacted with cancelled context") +} + +// trackingBody wraps an io.Reader and records whether the body was read +// and closed, plus the total number of bytes Read was allowed to consume. +// DoTokenRequest reads through a LimitReader capped at maxResponseBodySize +// and then closes without draining; bytesRead lets tests assert the cap +// is honored even when the underlying body is much larger. +type trackingBody struct { + reader io.Reader + readHit atomic.Bool + closed atomic.Bool + bytesRead atomic.Int64 +} + +func (b *trackingBody) Read(p []byte) (int, error) { + b.readHit.Store(true) + n, err := b.reader.Read(p) + b.bytesRead.Add(int64(n)) + return n, err +} + +func (b *trackingBody) Close() error { + b.closed.Store(true) + return nil +} + +// trackingTransport returns a response with a trackingBody so the test can +// inspect whether DoTokenRequest drained and closed the body. +type trackingTransport struct { + status int + bodyBytes []byte + lastBody *trackingBody + contentTyp string +} + +func (t *trackingTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + t.lastBody = &trackingBody{reader: strings.NewReader(string(t.bodyBytes))} + header := http.Header{} + if t.contentTyp != "" { + header.Set("Content-Type", t.contentTyp) + } + return &http.Response{ + StatusCode: t.status, + Header: header, + Body: t.lastBody, + }, nil +} + +// TestDoTokenRequest_ClosesBody verifies that both the success and error +// paths close the response body. The body is intentionally NOT drained past +// the LimitReader cap (see TestDoTokenRequest_DoesNotDrainOversizedBody for +// the regression test on that property). +func TestDoTokenRequest_ClosesBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + body []byte + assertResp func(t *testing.T, tokenResp *TokenResponse, err error) + }{ + { + name: "success (200 with valid token JSON)", + status: http.StatusOK, + body: []byte(`{"access_token":"at-1","token_type":"Bearer"}`), + assertResp: func(t *testing.T, tokenResp *TokenResponse, err error) { + t.Helper() + require.NoError(t, err) + require.NotNil(t, tokenResp) + require.NotNil(t, tokenResp.Token) + assert.Equal(t, "at-1", tokenResp.Token.AccessToken) + }, + }, + { + name: "error (400 with RFC 6749 §5.2 error JSON plus trailing bytes)", + status: http.StatusBadRequest, + body: []byte(`{"error":"invalid_grant"}` + strings.Repeat(" ", 1024)), + assertResp: func(t *testing.T, tokenResp *TokenResponse, err error) { + t.Helper() + require.Error(t, err) + assert.Nil(t, tokenResp) + var retrieveErr *oauth2.RetrieveError + require.True(t, errors.As(err, &retrieveErr)) + assert.Equal(t, "invalid_grant", retrieveErr.ErrorCode) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tr := &trackingTransport{ + status: tc.status, + bodyBytes: tc.body, + contentTyp: "application/json", + } + client := &http.Client{Transport: tr} + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example/token", strings.NewReader("")) + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(client, req) + tc.assertResp(t, tokenResp, err) + + require.NotNil(t, tr.lastBody) + assert.True(t, tr.lastBody.readHit.Load(), "body must be read") + assert.True(t, tr.lastBody.closed.Load(), "body must be closed") + }) + } +} + +// TestDoTokenRequest_DoesNotDrainOversizedBody pins the behavior that the +// response body is closed without an unbounded drain. A malicious or +// misbehaving IdP could return an arbitrarily large body; the earlier +// io.Copy(io.Discard, resp.Body) drain in the defer would read all of it, +// defeating the maxResponseBodySize cap and (with a caller-supplied +// no-timeout client) blocking the goroutine indefinitely. +// +// This test wires a response body ten times larger than the cap and asserts +// the number of bytes read from the underlying body stays within one Read +// buffer of maxResponseBodySize — i.e., only the LimitReader's quota is +// consumed, not the full body. +func TestDoTokenRequest_DoesNotDrainOversizedBody(t *testing.T) { + t.Parallel() + + // 10 MiB body — well over the 1 MiB cap. + oversized := make([]byte, 10*maxResponseBodySize) + for i := range oversized { + oversized[i] = 'A' + } + + tr := &trackingTransport{ + status: http.StatusOK, + bodyBytes: oversized, + contentTyp: "application/json", + } + client := &http.Client{Transport: tr} + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example/token", strings.NewReader("")) + require.NoError(t, err) + + // Expected: ParseTokenResponse fails to unmarshal 'AAAA…' as JSON on a + // 2xx status, returning a wrapped parse error. The key property under + // test is bytesRead, not the error surface. + _, _ = DoTokenRequest(client, req) + + require.NotNil(t, tr.lastBody) + assert.True(t, tr.lastBody.closed.Load(), "body must be closed") + + // io.LimitReader stops exactly at N bytes; the underlying Read is not + // called again after the limit is hit. Allow a small slop (one typical + // Read buffer, 32 KiB) for implementations that may over-fill on the + // final Read. + const slop = 32 << 10 + bytesRead := tr.lastBody.bytesRead.Load() + assert.LessOrEqual(t, bytesRead, int64(maxResponseBodySize)+int64(slop), + "DoTokenRequest must not drain the response body past the LimitReader cap") +} + +// TestDoTokenRequest_ClientDoError surfaces transport-level errors via %w. +func TestDoTokenRequest_ClientDoError(t *testing.T) { + t.Parallel() + + errTransport := errors.New("simulated dial failure") + client := &http.Client{ + Transport: roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errTransport + }), + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example/token", strings.NewReader("")) + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(client, req) + require.Error(t, err) + assert.Nil(t, tokenResp) + assert.ErrorIs(t, err, errTransport) +} + +// roundTripperFunc adapts a function to http.RoundTripper. +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// failingBody returns errFailingBodyRead from Read and errFailingBodyClose +// from Close so the test can drive DoTokenRequest's read-and-drain error +// paths simultaneously. +type failingBody struct{} + +var ( + errFailingBodyRead = errors.New("simulated body read failure") + errFailingBodyClose = errors.New("simulated body close failure") +) + +func (failingBody) Read(_ []byte) (int, error) { return 0, errFailingBodyRead } +func (failingBody) Close() error { return errFailingBodyClose } + +// TestDoTokenRequest_BodyReadError surfaces transport-level body read +// failures via %w and still closes the body on the way out. +func TestDoTokenRequest_BodyReadError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: failingBody{}, + }, nil + }), + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example/token", strings.NewReader("")) + require.NoError(t, err) + + tokenResp, err := DoTokenRequest(client, req) + require.Error(t, err) + assert.Nil(t, tokenResp) + assert.ErrorIs(t, err, errFailingBodyRead) +} + +// TestDoTokenRequest_RespectsContextTimeout proves req.Context() carries the +// caller's deadline through to transport cancellation. +func TestDoTokenRequest_RespectsContextTimeout(t *testing.T) { + t.Parallel() + + // Server delays longer than the client's deadline. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-time.After(500 * time.Millisecond): + w.WriteHeader(http.StatusOK) + case <-r.Context().Done(): + } + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + t.Cleanup(cancel) + + req, err := NewFormRequest(ctx, server.URL, url.Values{}, "", "") + require.NoError(t, err) + + start := time.Now() + tokenResp, err := DoTokenRequest(server.Client(), req) + elapsed := time.Since(start) + + require.Error(t, err) + assert.Nil(t, tokenResp) + assert.Less(t, elapsed, 500*time.Millisecond, "should have been cancelled before server replied") +} + +func TestParseRetrieveError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body []byte + wantErrorCode string + wantErrorDesc string + wantErrorURI string + wantBodyPreservedAs []byte // if non-nil, asserted explicitly (otherwise equals body) + }{ + { + name: "empty body", + body: []byte{}, + }, + { + name: "nil body", + body: nil, + }, + { + name: "only error field", + body: []byte(`{"error":"invalid_grant"}`), + wantErrorCode: "invalid_grant", + }, + { + name: "error and description", + body: []byte(`{"error":"invalid_grant","error_description":"token expired"}`), + wantErrorCode: "invalid_grant", + wantErrorDesc: "token expired", + }, + { + name: "all three fields", + body: []byte(`{"error":"invalid_grant","error_description":"token expired","error_uri":"https://idp.example/docs/invalid_grant"}`), + wantErrorCode: "invalid_grant", + wantErrorDesc: "token expired", + wantErrorURI: "https://idp.example/docs/invalid_grant", + }, + { + name: "non-JSON body", + body: []byte("upstream is down"), + }, + { + name: "JSON but not an object", + body: []byte(`"just a string"`), + }, + { + name: "unicode body", + body: []byte(`{"error":"invalid_grant","error_description":"トークンの有効期限が切れています"}`), + wantErrorCode: "invalid_grant", + wantErrorDesc: "トークンの有効期限が切れています", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + resp := &http.Response{StatusCode: http.StatusBadRequest} + retrieveErr := parseRetrieveError(resp, tc.body) + + require.NotNil(t, retrieveErr) + assert.Same(t, resp, retrieveErr.Response, "Response must be populated") + // Body is preserved verbatim regardless of whether decode succeeds. + if tc.wantBodyPreservedAs != nil { + assert.Equal(t, tc.wantBodyPreservedAs, retrieveErr.Body) + } else { + assert.Equal(t, tc.body, retrieveErr.Body) + } + assert.Equal(t, tc.wantErrorCode, retrieveErr.ErrorCode) + assert.Equal(t, tc.wantErrorDesc, retrieveErr.ErrorDescription) + assert.Equal(t, tc.wantErrorURI, retrieveErr.ErrorURI) + }) + } +} diff --git a/pkg/oauthproto/jwtbearer/doc.go b/pkg/oauthproto/jwtbearer/doc.go new file mode 100644 index 0000000000..2ec13d970f --- /dev/null +++ b/pkg/oauthproto/jwtbearer/doc.go @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package jwtbearer provides an OAuth 2.0 JWT Bearer Grant (RFC 7523) implementation. +// It exchanges a JWT assertion (such as an ID-JAG) for an access token at a target +// authorization server. +package jwtbearer diff --git a/pkg/oauthproto/jwtbearer/grant.go b/pkg/oauthproto/jwtbearer/grant.go new file mode 100644 index 0000000000..7f676dd271 --- /dev/null +++ b/pkg/oauthproto/jwtbearer/grant.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package jwtbearer + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// Config holds configuration for an OAuth 2.0 JWT Bearer Grant (RFC 7523). +type Config struct { + // TokenURL is the target authorization server's token endpoint (required). + TokenURL string + + // ClientID is the OAuth client identifier at the target AS. When both ClientID + // and ClientSecret are set, the request is authenticated with HTTP Basic per + // RFC 6749 Section 2.3.1. Public-client identification via a body client_id + // parameter (RFC 6749 Section 3.2.1) is not supported — XAA / ID-JAG §8.1 + // requires confidential clients, and that is the only intended consumer. + ClientID string + + // ClientSecret is the OAuth client secret at the target AS. + ClientSecret string //nolint:gosec // G101: field name, not a credential + + // Scopes are the requested scopes for the access token. + Scopes []string + + // AssertionProvider returns the JWT assertion (e.g., the ID-JAG from Step A). + // Called on each Token() invocation; must not be nil. The returned JWT must + // satisfy RFC 7523 Section 3 (iss/sub/aud/exp); aud should typically be the + // target AS's token endpoint (TokenURL). The provider must be safe for + // concurrent use — Token() may be called from multiple goroutines (e.g., + // when wrapped in oauth2.ReuseTokenSource). + AssertionProvider func() (string, error) + + // HTTPClient is the HTTP client to use. If nil, oauthproto.DefaultHTTPClient() + // is used. + HTTPClient *http.Client +} + +// Validate checks that the Config contains all required fields. +func (c *Config) Validate() error { + if c.TokenURL == "" { + return fmt.Errorf("TokenURL is required") + } + + if c.AssertionProvider == nil { + return fmt.Errorf("AssertionProvider is required") + } + + // Validate TokenURL: must be https (or http on localhost) per RFC 6749 Section 3.2 + // and RFC 7523 Section 7. Reuses the repo-wide endpoint validator for scheme, + // and adds host and fragment checks that it does not perform. + if err := networking.ValidateEndpointURL(c.TokenURL); err != nil { + return fmt.Errorf("TokenURL: %w", err) + } + u, err := url.Parse(c.TokenURL) + if err != nil { + return fmt.Errorf("TokenURL is not a valid URL: %w", err) + } + if u.Host == "" { + return fmt.Errorf("TokenURL must include a host") + } + if u.Fragment != "" { + return fmt.Errorf("TokenURL must not contain a fragment") + } + if u.User != nil { + return fmt.Errorf("TokenURL must not contain embedded credentials") + } + + return nil +} + +// String implements fmt.Stringer for Config, redacting sensitive fields. +func (c *Config) String() string { + assertion := oauthproto.Redact("") + if c.AssertionProvider != nil { + assertion = oauthproto.Redact("set") + } + + return fmt.Sprintf("Config{TokenURL: %s, ClientID: %s, ClientSecret: %s, Scopes: %v, Assertion: %s}", + c.TokenURL, c.ClientID, oauthproto.Redact(c.ClientSecret), c.Scopes, assertion) +} + +// Compile-time assertion that *tokenSource implements oauth2.TokenSource. +var _ oauth2.TokenSource = (*tokenSource)(nil) + +// TokenSource returns an oauth2.TokenSource that performs the JWT Bearer grant. +func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { + return &tokenSource{ + ctx: ctx, + conf: c, + } +} + +// tokenSource implements oauth2.TokenSource for the JWT Bearer grant. +type tokenSource struct { + ctx context.Context + conf *Config +} + +// Token implements the oauth2.TokenSource interface. +// It performs the JWT Bearer grant and returns an oauth2.Token. +func (ts *tokenSource) Token() (*oauth2.Token, error) { + conf := ts.conf + + if err := conf.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + assertion, err := conf.AssertionProvider() + if err != nil { + return nil, fmt.Errorf("failed to get assertion: %w", err) + } + + data := buildFormData(assertion, conf.Scopes) + + req, err := oauthproto.NewFormRequest(ts.ctx, conf.TokenURL, data, conf.ClientID, conf.ClientSecret) + if err != nil { + return nil, fmt.Errorf("jwtbearer: build request: %w", err) + } + + resp, err := oauthproto.DoTokenRequest(conf.HTTPClient, req) + if err != nil { + // Scrub RetrieveError.Body so raw upstream content cannot leak into + // error strings via err.Error(). pkg/oauthproto deliberately preserves + // Body for general-purpose callers; jwtbearer opts back into the + // stricter behavior because its errors propagate through vmcp / runner + // paths that may log them. Matches pkg/oauthproto/tokenexchange. + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) { + retrieveErr.Body = nil + } + return nil, err + } + + // RFC 6749 Section 5.1 requires token_type in the response. The shared + // oauthproto.ParseTokenResponse is intentionally permissive on this field + // (matching x/oauth2); the JWT Bearer grant tightens it back. + if resp.Token.TokenType == "" { + return nil, fmt.Errorf("jwtbearer: server returned empty token_type (required by RFC 6749 Section 5.1)") + } + + return resp.Token, nil +} + +// buildFormData constructs the form data for a JWT Bearer grant request. +func buildFormData(assertion string, scopes []string) url.Values { + data := url.Values{} + data.Set("grant_type", oauthproto.GrantTypeJWTBearer) + data.Set("assertion", assertion) + + if len(scopes) > 0 { + data.Set("scope", strings.Join(scopes, " ")) + } + + return data +} diff --git a/pkg/oauthproto/jwtbearer/grant_test.go b/pkg/oauthproto/jwtbearer/grant_test.go new file mode 100644 index 0000000000..32449a8566 --- /dev/null +++ b/pkg/oauthproto/jwtbearer/grant_test.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package jwtbearer + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" + "github.com/stacklok/toolhive/pkg/oauthproto/oauthtest" +) + +const ( + testAssertion = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-assertion-payload.signature" + testClientID = "test-client-id" + testClientSecret = "test-client-secret" //nolint:gosec // G101: test value, not a real credential +) + +// baseConfig returns a Config wired to serverURL with the standard test +// client credentials and a static assertion provider. Subtests override only +// the fields that vary. +func baseConfig(serverURL string) *Config { + return &Config{ + TokenURL: serverURL, + ClientID: testClientID, + ClientSecret: testClientSecret, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + } +} + +func TestConfig_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + wantErr string + }{ + { + name: "valid config with all fields", + config: Config{ + TokenURL: "https://as.example.com/token", + ClientID: testClientID, + ClientSecret: testClientSecret, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + }, + { + name: "valid config without client credentials", + config: Config{ + TokenURL: "https://as.example.com/token", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + }, + { + name: "missing TokenURL", + config: Config{ + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "TokenURL is required", + }, + { + name: "missing AssertionProvider", + config: Config{ + TokenURL: "https://as.example.com/token", + }, + wantErr: "AssertionProvider is required", + }, + { + name: "http on localhost is permitted", + config: Config{ + TokenURL: "http://localhost:8080/token", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + }, + { + name: "http on non-localhost is rejected", + config: Config{ + TokenURL: "http://as.example.com/token", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must use HTTPS", + }, + { + name: "non-http scheme is rejected", + config: Config{ + TokenURL: "file:///etc/passwd", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must use HTTPS", + }, + { + name: "fragment is rejected", + config: Config{ + TokenURL: "https://as.example.com/token#section", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must not contain a fragment", + }, + { + name: "missing host is rejected", + config: Config{ + TokenURL: "https:///token", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must include a host", + }, + { + name: "embedded credentials are rejected", + config: Config{ + TokenURL: "https://user:pass@as.example.com/token", + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must not contain embedded credentials", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.config.Validate() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestConfig_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *Config + expectedContains []string + expectedExcludes []string + }{ + { + name: "redacts client secret", + config: &Config{ + TokenURL: "https://as.example.com/token", + ClientID: testClientID, + ClientSecret: testClientSecret, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + expectedContains: []string{"[REDACTED]"}, + expectedExcludes: []string{testClientSecret}, + }, + { + name: "shows empty for missing secret", + config: &Config{ + TokenURL: "https://as.example.com/token", + ClientID: testClientID, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + expectedContains: []string{""}, + }, + { + name: "shows empty for nil assertion provider", + config: &Config{ + TokenURL: "https://as.example.com/token", + }, + expectedContains: []string{"Assertion: "}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := tt.config.String() + for _, want := range tt.expectedContains { + assert.Contains(t, s, want) + } + for _, forbidden := range tt.expectedExcludes { + assert.NotContains(t, s, forbidden) + } + }) + } +} + +func TestTokenSource_Token_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopes []string + expectedScope string + responseBuilder func() []byte + wantToken string + wantExpiresIn int + }{ + { + name: "without scopes", + scopes: nil, + expectedScope: "", + responseBuilder: func() []byte { + return oauthtest.NewResponse(). + WithAccessToken("target-access-token"). + WithExpiresIn(7200). + Build() + }, + wantToken: "target-access-token", + wantExpiresIn: 7200, + }, + { + name: "with scopes joined by space", + scopes: []string{"todos.read", "todos.write"}, + expectedScope: "todos.read todos.write", + responseBuilder: func() []byte { + return oauthtest.NewResponse(). + WithAccessToken("scoped-token"). + WithExpiresIn(3600). + WithScope("todos.read todos.write"). + Build() + }, + wantToken: "scoped-token", + wantExpiresIn: 3600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + username, password, ok := r.BasicAuth() + require.True(t, ok, "Basic Auth credentials should be present") + assert.Equal(t, testClientID, username) + assert.Equal(t, testClientSecret, password) + + require.NoError(t, r.ParseForm()) + assert.Equal(t, oauthproto.GrantTypeJWTBearer, r.Form.Get("grant_type")) + assert.Equal(t, testAssertion, r.Form.Get("assertion")) + assert.Equal(t, tt.expectedScope, r.Form.Get("scope")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tt.responseBuilder()) + })) + t.Cleanup(server.Close) + + config := baseConfig(server.URL) + config.Scopes = tt.scopes + + token, err := config.TokenSource(context.Background()).Token() + + require.NoError(t, err) + assert.Equal(t, tt.wantToken, token.AccessToken) + assert.Equal(t, "Bearer", token.TokenType) + assert.WithinDuration(t, time.Now().Add(time.Duration(tt.wantExpiresIn)*time.Second), token.Expiry, 5*time.Second) + }) + } +} + +func TestTokenSource_Token_Errors(t *testing.T) { + t.Parallel() + + t.Run("assertion provider returns error", func(t *testing.T) { + t.Parallel() + + config := baseConfig("https://as.example.com/token") + config.AssertionProvider = func() (string, error) { + return "", fmt.Errorf("assertion unavailable") + } + + _, err := config.TokenSource(context.Background()).Token() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get assertion") + assert.Contains(t, err.Error(), "assertion unavailable") + }) + + t.Run("server returns 400 with OAuth error, body scrubbed", func(t *testing.T) { + t.Parallel() + + rawBody := oauthtest.NewErrorResponse(). + WithError("invalid_grant"). + WithDescription("assertion expired"). + WithURI("https://example.com/errors/invalid_grant"). + Build() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(rawBody) + })) + t.Cleanup(server.Close) + + _, err := baseConfig(server.URL).TokenSource(context.Background()).Token() + + require.Error(t, err) + + var re *oauth2.RetrieveError + require.True(t, errors.As(err, &re), "error must unwrap to *oauth2.RetrieveError, got %T: %v", err, err) + assert.Equal(t, "invalid_grant", re.ErrorCode) + assert.Equal(t, "assertion expired", re.ErrorDescription) + assert.Equal(t, "https://example.com/errors/invalid_grant", re.ErrorURI) + require.NotNil(t, re.Response) + assert.Equal(t, http.StatusBadRequest, re.Response.StatusCode) + // Body is scrubbed to keep raw upstream content out of error strings. + assert.Nil(t, re.Body, "RetrieveError.Body should be scrubbed") + }) + + t.Run("empty token_type in response is rejected (RFC 6749 §5.1)", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body := oauthtest.NewResponse(). + WithAccessToken("target-access-token"). + WithTokenType(""). + Build() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + })) + t.Cleanup(server.Close) + + _, err := baseConfig(server.URL).TokenSource(context.Background()).Token() + + require.Error(t, err) + assert.Contains(t, err.Error(), "empty token_type") + assert.Contains(t, err.Error(), "jwtbearer:") + }) +} diff --git a/pkg/oauthproto/locality.go b/pkg/oauthproto/locality.go new file mode 100644 index 0000000000..943a340100 --- /dev/null +++ b/pkg/oauthproto/locality.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import "strings" + +// IsLoopbackHost reports whether host is a loopback hostname or IP address. +// pkg/networking wraps this function in its own IsLocalhost to avoid a +// reverse import dependency from this leaf package into networking. +// +// Recognised forms: "localhost", "localhost:", "127.0.0.1", "127.0.0.1:", +// "[::1]", "[::1]:". +func IsLoopbackHost(host string) bool { + return strings.HasPrefix(host, "localhost:") || + strings.HasPrefix(host, "127.0.0.1:") || + strings.HasPrefix(host, "[::1]:") || + host == "localhost" || + host == "127.0.0.1" || + host == "[::1]" +} diff --git a/pkg/oauthproto/oauthtest/fixtures.go b/pkg/oauthproto/oauthtest/fixtures.go new file mode 100644 index 0000000000..cae73e8e36 --- /dev/null +++ b/pkg/oauthproto/oauthtest/fixtures.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package oauthtest provides shared test fixtures for OAuth 2.0 response +// decoding. It is intended for use by tests in pkg/oauth and by sibling +// grant packages (token exchange, JWT bearer, etc.) so they share a single +// canonical response-builder rather than each maintaining a parallel copy +// that can drift. +package oauthtest + +import "encoding/json" + +// ResponseBuilder composes a JSON-encoded OAuth 2.0 token endpoint success +// response. Fluent setters leave unset fields as their zero value, matching +// the behavior of a minimal IdP reply. Build returns the marshaled JSON +// bytes ready to write into an httptest response. +type ResponseBuilder struct { + AccessToken string `json:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuedTokenType string `json:"issued_token_type,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// NewResponse returns a builder pre-populated with a minimal valid RFC 8693 +// success response (access token, Bearer type, access-token URN for issued +// type). Tests override any field they care about via the With* setters. +// +//nolint:gosec // G101: literal test-fixture value, not a real credential. +func NewResponse() *ResponseBuilder { + return &ResponseBuilder{ + AccessToken: "test-access-token", + TokenType: "Bearer", + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + } +} + +// WithAccessToken overrides the access_token field (including the empty +// string, which lets tests exercise RFC 6749 §5.1 validation). +func (b *ResponseBuilder) WithAccessToken(token string) *ResponseBuilder { + b.AccessToken = token + return b +} + +// WithTokenType overrides the token_type field. +func (b *ResponseBuilder) WithTokenType(tokenType string) *ResponseBuilder { + b.TokenType = tokenType + return b +} + +// WithExpiresIn sets the expires_in field. Zero suppresses the field +// (omitempty) so callers can assert the no-expiry path. +func (b *ResponseBuilder) WithExpiresIn(seconds int) *ResponseBuilder { + b.ExpiresIn = seconds + return b +} + +// WithRefreshToken sets the refresh_token field. +func (b *ResponseBuilder) WithRefreshToken(token string) *ResponseBuilder { + b.RefreshToken = token + return b +} + +// WithIssuedTokenType sets the RFC 8693 issued_token_type field. +func (b *ResponseBuilder) WithIssuedTokenType(tokenType string) *ResponseBuilder { + b.IssuedTokenType = tokenType + return b +} + +// WithScope sets the scope field (space-separated). +func (b *ResponseBuilder) WithScope(scope string) *ResponseBuilder { + b.Scope = scope + return b +} + +// Build marshals the builder to JSON. Panics on marshaling errors; the +// underlying types are simple and failure indicates a programming error +// in the test itself. +func (b *ResponseBuilder) Build() []byte { + out, err := json.Marshal(b) + if err != nil { + panic("oauthtest: marshal response: " + err.Error()) + } + return out +} + +// ErrorResponseBuilder composes a JSON-encoded OAuth 2.0 error response per +// RFC 6749 Section 5.2. Fluent setters populate only the listed fields — +// unset fields are omitted from the JSON output so callers can simulate +// minimal servers that return only the required error code. +type ErrorResponseBuilder struct { + ErrorCode string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} + +// NewErrorResponse returns an empty builder. Tests call WithError at minimum +// to produce a valid RFC 6749 §5.2 body. +func NewErrorResponse() *ErrorResponseBuilder { + return &ErrorResponseBuilder{} +} + +// WithError sets the error code (RFC 6749 §5.2 required field). +func (b *ErrorResponseBuilder) WithError(code string) *ErrorResponseBuilder { + b.ErrorCode = code + return b +} + +// WithDescription sets error_description. +func (b *ErrorResponseBuilder) WithDescription(description string) *ErrorResponseBuilder { + b.ErrorDescription = description + return b +} + +// WithURI sets error_uri. +func (b *ErrorResponseBuilder) WithURI(uri string) *ErrorResponseBuilder { + b.ErrorURI = uri + return b +} + +// Build marshals the builder to JSON. Panics on marshaling errors; the +// underlying types are simple and failure indicates a programming error +// in the test itself. +func (b *ErrorResponseBuilder) Build() []byte { + out, err := json.Marshal(b) + if err != nil { + panic("oauthtest: marshal error response: " + err.Error()) + } + return out +} diff --git a/pkg/oauth/redirect.go b/pkg/oauthproto/redirect.go similarity index 99% rename from pkg/oauth/redirect.go rename to pkg/oauthproto/redirect.go index f9302bacbb..bbdacde38c 100644 --- a/pkg/oauth/redirect.go +++ b/pkg/oauthproto/redirect.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package oauth +package oauthproto import ( "context" diff --git a/pkg/oauth/redirect_test.go b/pkg/oauthproto/redirect_test.go similarity index 99% rename from pkg/oauth/redirect_test.go rename to pkg/oauthproto/redirect_test.go index 3db89084b7..711875578c 100644 --- a/pkg/oauth/redirect_test.go +++ b/pkg/oauthproto/redirect_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package oauth +package oauthproto import ( "strings" diff --git a/pkg/oauthproto/tokenexchange/exchange.go b/pkg/oauthproto/tokenexchange/exchange.go new file mode 100644 index 0000000000..5f3018b539 --- /dev/null +++ b/pkg/oauthproto/tokenexchange/exchange.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package tokenexchange provides OAuth 2.0 Token Exchange (RFC 8693) support. +package tokenexchange + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// NormalizeTokenType converts a short token type name to its full URN. +// Accepts both short forms ("access_token", "id_token", "jwt") and full URNs. +// Returns the full URN or an error if the token type is invalid. +// +// This is primarily intended for CLI/user input processing. Internal APIs +// should use full URNs directly. +func NormalizeTokenType(tokenType string) (string, error) { + // Empty string is valid (will use default) + if tokenType == "" { + return "", nil + } + + // Check if already a full URN (backward compatibility) + switch tokenType { + case oauthproto.TokenTypeAccessToken, oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT: + return tokenType, nil + } + + // Convert short form to full URN + switch tokenType { + case "access_token": + return oauthproto.TokenTypeAccessToken, nil + case "id_token": + return oauthproto.TokenTypeIDToken, nil + case "jwt": + return oauthproto.TokenTypeJWT, nil + default: + return "", fmt.Errorf("invalid token type %q: must be one of: access_token, id_token, jwt", tokenType) + } +} + +// actingParty represents the acting party in a token exchange delegation scenario. +// When present, it indicates that the actor token holder is acting on behalf of the subject token holder. +type actingParty struct { + ActorToken string + ActorTokenType string +} + +// exchangeRequest contains fields necessary to make an OAuth 2.0 token exchange. +// Based on RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693 +type exchangeRequest struct { + // Required fields + GrantType string + SubjectToken string + SubjectTokenType string + RequestedTokenType string + + // Optional fields + Resource string + Audience string + Scope []string + ActingParty *actingParty +} + +// clientAuthentication represents OAuth client credentials for token exchange. +type clientAuthentication struct { + ClientID string + ClientSecret string +} + +// ExchangeConfig holds the configuration for token exchange. +type ExchangeConfig struct { + // TokenURL is the OAuth 2.0 token endpoint URL + TokenURL string + + // ClientID is the OAuth 2.0 client identifier + ClientID string + + // ClientSecret is the OAuth 2.0 client secret + ClientSecret string + + // Audience is the target audience for the exchanged token (optional per RFC 8693) + Audience string + + // Scopes is the list of scopes to request (optional per RFC 8693) + Scopes []string + + // SubjectTokenType specifies the type of the subject token being exchanged. + // Common values: oauthproto.TokenTypeAccessToken (default), oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT. + // If empty, defaults to oauthproto.TokenTypeAccessToken. + SubjectTokenType string + + // RequestedTokenType specifies the desired token type in the exchange response. + // Defaults to oauthproto.TokenTypeAccessToken per RFC 8693. Set to any RFC 8693 + // token-type URN to request a different issued token type. + RequestedTokenType string + + // Resource identifies the target resource per RFC 8693 Section 2.1 and RFC 8707. + // Must be an absolute URI without a fragment. This implementation accepts a + // single resource indicator; RFC 8707 permits multiple but multi-value support + // is not yet exposed at this layer. + Resource string + + // SubjectTokenProvider is a function that returns the subject token to exchange + // we use a function to allow dynamic retrieval of the token (e.g. from request context) + // and also to lazy-load the token only when needed, load from dynamic sources, etc. + SubjectTokenProvider func() (string, error) + + // HTTPClient is the HTTP client to use for token exchange requests. + // If nil, oauthproto.DefaultHTTPClient() will be used. + HTTPClient *http.Client +} + +// Validate checks if the ExchangeConfig contains all required fields. +// +// Side effect: when SubjectTokenType is provided as a short form +// ("access_token", "id_token", "jwt"), Validate normalizes it to the full +// RFC 8693 URN and writes the result back onto the receiver. Callers in +// pkg/vmcp/auth/strategies/tokenexchange.go and pkg/runner/config_builder.go +// read the normalized value after Validate returns, so this mutation is part +// of the documented contract. +func (c *ExchangeConfig) Validate() error { + if c.TokenURL == "" { + return fmt.Errorf("TokenURL is required") + } + + if c.SubjectTokenProvider == nil { + return fmt.Errorf("SubjectTokenProvider is required") + } + + // ClientID is optional - some token exchange endpoints (like Google STS) + // don't require client credentials and rely on the trust relationship + // configured in the identity provider (e.g., Workload Identity Federation) + + // Validate and normalize SubjectTokenType if provided + if c.SubjectTokenType != "" { + normalized, err := NormalizeTokenType(c.SubjectTokenType) + if err != nil { + return fmt.Errorf("invalid SubjectTokenType: %w", err) + } + // Update the config with the normalized value + c.SubjectTokenType = normalized + } + + // Validate Resource per RFC 8707 Section 2: absolute URI, no fragment. + if c.Resource != "" { + u, err := url.Parse(c.Resource) + if err != nil { + return fmt.Errorf("invalid Resource URI: %w", err) + } + if !u.IsAbs() { + return fmt.Errorf("invalid Resource: must be an absolute URI (got %q)", c.Resource) + } + if u.Fragment != "" { + return fmt.Errorf("invalid Resource: must not include a fragment (RFC 8707 Section 2)") + } + } + + // Validate URL format + _, err := url.Parse(c.TokenURL) + if err != nil { + return fmt.Errorf("TokenURL is not a valid URL: %w", err) + } + + return nil +} + +// tokenSource implements oauth2.TokenSource for token exchange. +type tokenSource struct { + ctx context.Context + conf *ExchangeConfig +} + +// Token implements oauth2.TokenSource interface. +// It performs the token exchange and returns an oauth2.Token. +func (ts *tokenSource) Token() (*oauth2.Token, error) { + conf := ts.conf + + // Validate configuration + if err := conf.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + // Get the subject token from the provider + subjectToken, err := conf.SubjectTokenProvider() + if err != nil { + return nil, fmt.Errorf("failed to get subject token: %w", err) + } + + // Determine subject token type (default to access_token if not specified) + subjectTokenType := conf.SubjectTokenType + if subjectTokenType == "" { + subjectTokenType = oauthproto.TokenTypeAccessToken + } + + // Build the token exchange request + requestedTokenType := conf.RequestedTokenType + if requestedTokenType == "" { + requestedTokenType = oauthproto.TokenTypeAccessToken + } + + request := &exchangeRequest{ + GrantType: oauthproto.GrantTypeTokenExchange, + Audience: conf.Audience, + Scope: conf.Scopes, + RequestedTokenType: requestedTokenType, + Resource: conf.Resource, + SubjectToken: subjectToken, + SubjectTokenType: subjectTokenType, + } + + clientAuth := clientAuthentication{ + ClientID: conf.ClientID, + ClientSecret: conf.ClientSecret, + } + + // Perform the exchange + resp, err := exchangeToken(ts.ctx, conf.TokenURL, request, clientAuth, conf.HTTPClient) + if err != nil { + return nil, err + } + + // RFC 8693 Section 2.2.1 requires token_type in the response. The shared + // oauthproto.ParseTokenResponse is intentionally permissive on this field + // (matching x/oauth2); token exchange tightens it back. + if resp.Token.TokenType == "" { + return nil, fmt.Errorf("token exchange: server returned empty token_type (required by RFC 8693)") + } + // RFC 8693 Section 2.2.1 requires issued_token_type in the response. + if resp.IssuedTokenType == "" { + return nil, fmt.Errorf("token exchange: server returned empty issued_token_type (required by RFC 8693)") + } + + return resp.Token, nil +} + +// TokenSource returns an oauth2.TokenSource that performs token exchange. +func (c *ExchangeConfig) TokenSource(ctx context.Context) oauth2.TokenSource { + return &tokenSource{ + ctx: ctx, + conf: c, + } +} + +// exchangeToken performs the actual HTTP request for token exchange. +// This is the internal implementation used by tokenSource.Token(). +func exchangeToken( + ctx context.Context, + endpoint string, + request *exchangeRequest, + auth clientAuthentication, + client *http.Client, +) (*oauthproto.TokenResponse, error) { + data, err := buildFormData(request) + if err != nil { + return nil, err + } + + req, err := oauthproto.NewFormRequest(ctx, endpoint, data, auth.ClientID, auth.ClientSecret) + if err != nil { + return nil, fmt.Errorf("tokenexchange: build request: %w", err) + } + + resp, err := oauthproto.DoTokenRequest(client, req) + if err != nil { + // Preserve the pre-refactor behavior: scrub RetrieveError.Body so raw + // upstream content cannot leak into error strings via err.Error(). + // pkg/oauthproto deliberately preserves Body for general-purpose + // callers; tokenexchange opts back into the stricter behavior because + // its errors propagate through vmcp / runner paths that may log them. + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) { + retrieveErr.Body = nil + } + return nil, err + } + return resp, nil +} + +// buildFormData constructs the form data for a token exchange request according to RFC 8693. +func buildFormData(request *exchangeRequest) (url.Values, error) { + data := url.Values{} + + // Grant type is always token exchange + if request.GrantType == "" { + request.GrantType = oauthproto.GrantTypeTokenExchange + } + data.Set("grant_type", request.GrantType) + + // Subject token is required + if request.SubjectToken == "" { + return nil, fmt.Errorf("subject_token is required") + } + data.Set("subject_token", request.SubjectToken) + + // Subject token type defaults to access_token if not specified + if request.SubjectTokenType == "" { + request.SubjectTokenType = oauthproto.TokenTypeAccessToken + } + data.Set("subject_token_type", request.SubjectTokenType) + + // Requested token type defaults to access_token if not specified + if request.RequestedTokenType == "" { + request.RequestedTokenType = oauthproto.TokenTypeAccessToken + } + data.Set("requested_token_type", request.RequestedTokenType) + + addOptionalFields(data, request) + + return data, nil +} + +// addOptionalFields adds optional RFC 8693 fields to the form data. +func addOptionalFields(data url.Values, request *exchangeRequest) { + if request.Audience != "" { + data.Set("audience", request.Audience) + } + if len(request.Scope) > 0 { + data.Set("scope", strings.Join(request.Scope, " ")) + } + if request.Resource != "" { + data.Set("resource", request.Resource) + } + + // Actor token (for delegation scenarios) + if request.ActingParty != nil && request.ActingParty.ActorToken != "" { + data.Set("actor_token", request.ActingParty.ActorToken) + if request.ActingParty.ActorTokenType != "" { + data.Set("actor_token_type", request.ActingParty.ActorTokenType) + } + } +} diff --git a/pkg/auth/tokenexchange/exchange_test.go b/pkg/oauthproto/tokenexchange/exchange_test.go similarity index 76% rename from pkg/auth/tokenexchange/exchange_test.go rename to pkg/oauthproto/tokenexchange/exchange_test.go index 4877e9b90e..7e4c4b475c 100644 --- a/pkg/auth/tokenexchange/exchange_test.go +++ b/pkg/oauthproto/tokenexchange/exchange_test.go @@ -19,6 +19,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/stacklok/toolhive/pkg/oauthproto" + "github.com/stacklok/toolhive/pkg/oauthproto/oauthtest" ) const ( @@ -26,54 +29,6 @@ const ( testSubjectToken = "test-subject-token" ) -// Test helper - builder pattern for creating mock responses - -// responseBuilder builds test OAuth 2.0 token exchange responses. -type responseBuilder struct { - resp response -} - -// newResponse creates a new response builder with sensible defaults. -// Returns a minimal valid response (access_token, token_type, issued_token_type). -func newResponse() *responseBuilder { - return &responseBuilder{ - resp: response{ - AccessToken: "token", - IssuedTokenType: tokenTypeAccessToken, - TokenType: "Bearer", - }, - } -} - -// withAccessToken sets a custom access token. -func (b *responseBuilder) withAccessToken(token string) *responseBuilder { - b.resp.AccessToken = token - return b -} - -// withExpiry sets the token expiry in seconds. -func (b *responseBuilder) withExpiry(seconds int) *responseBuilder { - b.resp.ExpiresIn = seconds - return b -} - -// withRefreshToken adds a refresh token to the response. -func (b *responseBuilder) withRefreshToken(token string) *responseBuilder { - b.resp.RefreshToken = token - return b -} - -// withScope sets the scope for the response. -func (b *responseBuilder) withScope(scope string) *responseBuilder { - b.resp.Scope = scope - return b -} - -// build returns the constructed response. -func (b *responseBuilder) build() response { - return b.resp -} - // TestNormalizeTokenType tests the NormalizeTokenType function. func TestNormalizeTokenType(t *testing.T) { t.Parallel() @@ -93,37 +48,37 @@ func TestNormalizeTokenType(t *testing.T) { { name: "short form access_token", input: "access_token", - want: tokenTypeAccessToken, + want: oauthproto.TokenTypeAccessToken, wantError: false, }, { name: "short form id_token", input: "id_token", - want: tokenTypeIDToken, + want: oauthproto.TokenTypeIDToken, wantError: false, }, { name: "short form jwt", input: "jwt", - want: tokenTypeJWT, + want: oauthproto.TokenTypeJWT, wantError: false, }, { name: "full URN access_token", - input: tokenTypeAccessToken, - want: tokenTypeAccessToken, + input: oauthproto.TokenTypeAccessToken, + want: oauthproto.TokenTypeAccessToken, wantError: false, }, { name: "full URN id_token", - input: tokenTypeIDToken, - want: tokenTypeIDToken, + input: oauthproto.TokenTypeIDToken, + want: oauthproto.TokenTypeIDToken, wantError: false, }, { name: "full URN jwt", - input: tokenTypeJWT, - want: tokenTypeJWT, + input: oauthproto.TokenTypeJWT, + want: oauthproto.TokenTypeJWT, wantError: false, }, { @@ -203,18 +158,18 @@ func TestTokenSource_Token_Success(t *testing.T) { assert.Empty(t, r.Form.Get("client_secret"), "client_secret should not be in request body") // Return successful response - resp := newResponse(). - withAccessToken("exchanged-access-token"). - withScope("read write"). - withExpiry(3600). - build() + body := oauthtest.NewResponse(). + WithAccessToken("exchanged-access-token"). + WithScope("read write"). + WithExpiresIn(3600). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - err = json.NewEncoder(w).Encode(resp) + _, err = w.Write(body) require.NoError(t, err) })) - defer server.Close() + t.Cleanup(server.Close) // Create config with test server config := &ExchangeConfig{ @@ -246,17 +201,17 @@ func TestTokenSource_Token_WithRefreshToken(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := newResponse(). - withAccessToken("exchanged-access-token"). - withRefreshToken("refresh-token-value"). - withExpiry(3600). - build() + body := oauthtest.NewResponse(). + WithAccessToken("exchanged-access-token"). + WithRefreshToken("refresh-token-value"). + WithExpiresIn(3600). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) config := &ExchangeConfig{ TokenURL: server.URL, @@ -281,14 +236,14 @@ func TestTokenSource_Token_NoExpiry(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := newResponse().withAccessToken("exchanged-access-token").build() - // No expiry (ExpiresIn: 0) + body := oauthtest.NewResponse().WithAccessToken("exchanged-access-token").Build() + // No expiry (ExpiresIn unset → omitted) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) config := &ExchangeConfig{ TokenURL: server.URL, @@ -341,7 +296,7 @@ func TestTokenSource_Token_ContextCancellation(t *testing.T) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusOK) })) - defer server.Close() + t.Cleanup(server.Close) config := &ExchangeConfig{ TokenURL: server.URL, @@ -361,7 +316,9 @@ func TestTokenSource_Token_ContextCancellation(t *testing.T) { require.Error(t, err) assert.Nil(t, token) - assert.Contains(t, err.Error(), "token exchange request failed") + // oauth.DoTokenRequest wraps transport-level failures (including + // context cancellation) with "oauth: token request failed". + assert.Contains(t, err.Error(), "oauth: token request failed") } // TestExchangeToken_HTTPErrorResponses tests various HTTP error responses. @@ -369,40 +326,42 @@ func TestExchangeToken_HTTPErrorResponses(t *testing.T) { t.Parallel() tests := []struct { - name string - statusCode int - responseBody string - expectedError string + name string + statusCode int + responseBody string + expectedErrorCode string + expectedDescription string }{ { - name: "400 Bad Request", - statusCode: http.StatusBadRequest, - responseBody: `{"error":"invalid_request","error_description":"Missing required parameter"}`, - expectedError: "OAuth error \"invalid_request\" (status 400)", + name: "400 Bad Request", + statusCode: http.StatusBadRequest, + responseBody: `{"error":"invalid_request","error_description":"Missing required parameter"}`, + expectedErrorCode: "invalid_request", + expectedDescription: "Missing required parameter", }, { - name: "401 Unauthorized", - statusCode: http.StatusUnauthorized, - responseBody: `{"error":"invalid_client"}`, - expectedError: "OAuth error \"invalid_client\" (status 401)", + name: "401 Unauthorized", + statusCode: http.StatusUnauthorized, + responseBody: `{"error":"invalid_client"}`, + expectedErrorCode: "invalid_client", }, { - name: "403 Forbidden", - statusCode: http.StatusForbidden, - responseBody: `{"error":"access_denied"}`, - expectedError: "OAuth error \"access_denied\" (status 403)", + name: "403 Forbidden", + statusCode: http.StatusForbidden, + responseBody: `{"error":"access_denied"}`, + expectedErrorCode: "access_denied", }, { - name: "500 Internal Server Error", - statusCode: http.StatusInternalServerError, - responseBody: `{"error":"server_error"}`, - expectedError: "OAuth error \"server_error\" (status 500)", + name: "500 Internal Server Error", + statusCode: http.StatusInternalServerError, + responseBody: `{"error":"server_error"}`, + expectedErrorCode: "server_error", }, { - name: "503 Service Unavailable", - statusCode: http.StatusServiceUnavailable, - responseBody: "Service temporarily unavailable", - expectedError: "token exchange failed with status 503", + name: "503 Service Unavailable", + statusCode: http.StatusServiceUnavailable, + responseBody: "Service temporarily unavailable", + // Non-JSON body: ErrorCode stays empty, body cleared. }, } @@ -414,7 +373,7 @@ func TestExchangeToken_HTTPErrorResponses(t *testing.T) { w.WriteHeader(tt.statusCode) _, _ = w.Write([]byte(tt.responseBody)) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", @@ -432,7 +391,16 @@ func TestExchangeToken_HTTPErrorResponses(t *testing.T) { require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), tt.expectedError) + + var retrieveErr *oauth2.RetrieveError + require.ErrorAs(t, err, &retrieveErr) + require.NotNil(t, retrieveErr.Response) + assert.Equal(t, tt.statusCode, retrieveErr.Response.StatusCode) + assert.Equal(t, tt.expectedErrorCode, retrieveErr.ErrorCode) + assert.Equal(t, tt.expectedDescription, retrieveErr.ErrorDescription) + // Regression: exchangeToken must scrub RetrieveError.Body so raw + // upstream content cannot leak into error strings. + assert.Nil(t, retrieveErr.Body) }) } } @@ -472,7 +440,7 @@ func TestExchangeToken_MalformedJSON(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(tt.responseBody)) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -484,7 +452,7 @@ func TestExchangeToken_MalformedJSON(t *testing.T) { require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "failed to parse token exchange response") + assert.Contains(t, err.Error(), "oauth: cannot parse token response") }) } } @@ -496,7 +464,7 @@ func TestExchangeToken_MissingRequiredFields(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("should not reach server") })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ // Missing SubjectToken @@ -526,12 +494,11 @@ func TestExchangeToken_DefaultValues(t *testing.T) { assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", r.Form.Get("subject_token_type")) assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", r.Form.Get("requested_token_type")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -561,12 +528,11 @@ func TestExchangeToken_OptionalFields(t *testing.T) { assert.Equal(t, "actor-token-value", r.Form.Get("actor_token")) assert.Equal(t, "urn:ietf:params:oauth:token-type:jwt", r.Form.Get("actor_token_type")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -602,12 +568,11 @@ func TestExchangeToken_ActorTokenWithoutType(t *testing.T) { assert.Equal(t, "actor-token-value", r.Form.Get("actor_token")) assert.Empty(t, r.Form.Get("actor_token_type")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -639,7 +604,9 @@ func TestExchangeToken_InvalidURL(t *testing.T) { require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "failed to create token exchange request") + // Request construction now goes through oauth.NewFormRequest and the + // tokenexchange call site wraps with its own prefix. + assert.Contains(t, err.Error(), "tokenexchange: build request") } // TestExchangeToken_NetworkError tests error handling for network failures. @@ -657,7 +624,8 @@ func TestExchangeToken_NetworkError(t *testing.T) { require.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "token exchange request failed") + // oauth.DoTokenRequest wraps transport-level failures with this prefix. + assert.Contains(t, err.Error(), "oauth: token request failed") } // TestExchangeToken_ResponseSizeLimit tests that large responses are properly limited. @@ -675,7 +643,7 @@ func TestExchangeToken_ResponseSizeLimit(t *testing.T) { w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -687,9 +655,10 @@ func TestExchangeToken_ResponseSizeLimit(t *testing.T) { require.Error(t, err) assert.Nil(t, resp) - // The io.LimitReader allows reading up to 1MB, then truncates the response - // This causes a JSON parsing error rather than a read error - assert.Contains(t, err.Error(), "failed to parse token exchange response") + // oauth.DoTokenRequest uses io.LimitReader to cap the body at 1 MiB, then + // truncates further bytes. That produces a JSON parse failure rather than + // a read error, surfaced as "oauth: cannot parse token response". + assert.Contains(t, err.Error(), "oauth: cannot parse token response") } // TestExchangeToken_NoCredentialLeakage tests that credentials are not leaked in error messages. @@ -731,7 +700,7 @@ func TestExchangeToken_NoCredentialLeakage(t *testing.T) { t.Parallel() server := tt.setupServer() - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: tt.subjectToken, @@ -768,12 +737,11 @@ func TestExchangeToken_FormEncoding(t *testing.T) { // Verify that special characters are properly decoded assert.Equal(t, specialChars, r.Form.Get("subject_token")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: specialChars, @@ -797,16 +765,15 @@ func TestExchangeToken_ContentLength(t *testing.T) { assert.NotEmpty(t, contentLength) // Read body and verify it matches Content-Length - body, err := io.ReadAll(r.Body) + reqBody, err := io.ReadAll(r.Body) require.NoError(t, err) - assert.Equal(t, contentLength, fmt.Sprintf("%d", len(body))) + assert.Equal(t, contentLength, fmt.Sprintf("%d", len(reqBody))) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -885,12 +852,12 @@ func TestSubjectTokenProvider_Variants(t *testing.T) { // Create server within subtest to avoid race conditions with parallel execution server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := newResponse().withAccessToken("exchanged-token").build() + body := oauthtest.NewResponse().WithAccessToken("exchanged-token").Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) config := &ExchangeConfig{ TokenURL: server.URL, @@ -934,12 +901,11 @@ func TestExchangeToken_EmptyClientCredentials(t *testing.T) { assert.Empty(t, r.Form.Get("client_id")) assert.Empty(t, r.Form.Get("client_secret")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -972,12 +938,11 @@ func TestExchangeToken_OnlyClientID(t *testing.T) { assert.Empty(t, r.Form.Get("client_id")) assert.Empty(t, r.Form.Get("client_secret")) - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -999,17 +964,17 @@ func TestExchangeToken_ResponseFields(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := newResponse(). - withAccessToken("access-token-value"). - withScope("openid profile email"). - withRefreshToken("refresh-token-value"). - withExpiry(7200). - build() + body := oauthtest.NewResponse(). + WithAccessToken("access-token-value"). + WithScope("openid profile email"). + WithRefreshToken("refresh-token-value"). + WithExpiresIn(7200). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -1020,12 +985,14 @@ func TestExchangeToken_ResponseFields(t *testing.T) { resp, err := exchangeToken(ctx, server.URL, request, auth, nil) require.NoError(t, err) - assert.Equal(t, "access-token-value", resp.AccessToken) + assert.Equal(t, "access-token-value", resp.Token.AccessToken) assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", resp.IssuedTokenType) - assert.Equal(t, "Bearer", resp.TokenType) - assert.Equal(t, 7200, resp.ExpiresIn) + assert.Equal(t, "Bearer", resp.Token.TokenType) + // expires_in is folded into Token.Expiry by oauth.ParseTokenResponse. + assert.False(t, resp.Token.Expiry.IsZero()) + assert.WithinDuration(t, time.Now().Add(7200*time.Second), resp.Token.Expiry, 5*time.Second) assert.Equal(t, "openid profile email", resp.Scope) - assert.Equal(t, "refresh-token-value", resp.RefreshToken) + assert.Equal(t, "refresh-token-value", resp.Token.RefreshToken) } // TestExchangeToken_MinimalResponse tests response with only required fields. @@ -1035,13 +1002,13 @@ func TestExchangeToken_MinimalResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Minimal valid response according to RFC 8693 // All three fields (access_token, token_type, issued_token_type) are required - resp := newResponse().withAccessToken("access-token-value").build() + body := oauthtest.NewResponse().WithAccessToken("access-token-value").Build() // ExpiresIn, Scope, RefreshToken are optional w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -1052,12 +1019,13 @@ func TestExchangeToken_MinimalResponse(t *testing.T) { resp, err := exchangeToken(ctx, server.URL, request, auth, nil) require.NoError(t, err) - assert.Equal(t, "access-token-value", resp.AccessToken) - assert.Equal(t, "Bearer", resp.TokenType) - assert.Equal(t, tokenTypeAccessToken, resp.IssuedTokenType) - assert.Equal(t, 0, resp.ExpiresIn) + assert.Equal(t, "access-token-value", resp.Token.AccessToken) + assert.Equal(t, "Bearer", resp.Token.TokenType) + assert.Equal(t, oauthproto.TokenTypeAccessToken, resp.IssuedTokenType) + // Absent expires_in leaves Token.Expiry as the zero value. + assert.True(t, resp.Token.Expiry.IsZero()) assert.Empty(t, resp.Scope) - assert.Empty(t, resp.RefreshToken) + assert.Empty(t, resp.Token.RefreshToken) } // TestExchangeToken_ScopeArray tests that scope array is properly converted to space-separated string. @@ -1105,12 +1073,11 @@ func TestExchangeToken_ScopeArray(t *testing.T) { assert.Equal(t, tt.expectedScope, r.Form.Get("scope")) } - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -1228,31 +1195,6 @@ func TestExchangeRequest_StructFields(t *testing.T) { assert.Equal(t, "subject-token-type", req.SubjectTokenType) } -// TestResponse_JSONTags tests that response struct has correct JSON tags. -func TestResponse_JSONTags(t *testing.T) { - t.Parallel() - - jsonData := `{ - "access_token": "test-access-token", - "issued_token_type": "test-issued-token-type", - "token_type": "test-token-type", - "expires_in": 3600, - "scope": "test-scope", - "refresh_token": "test-refresh-token" - }` - - var resp response - err := json.Unmarshal([]byte(jsonData), &resp) - - require.NoError(t, err) - assert.Equal(t, "test-access-token", resp.AccessToken) - assert.Equal(t, "test-issued-token-type", resp.IssuedTokenType) - assert.Equal(t, "test-token-type", resp.TokenType) - assert.Equal(t, 3600, resp.ExpiresIn) - assert.Equal(t, "test-scope", resp.Scope) - assert.Equal(t, "test-refresh-token", resp.RefreshToken) -} - // TestClientAuthentication_Fields tests clientAuthentication struct fields. func TestClientAuthentication_Fields(t *testing.T) { t.Parallel() @@ -1312,12 +1254,11 @@ func TestExchangeToken_URLValues(t *testing.T) { // Store received form values receivedValues = r.Form - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", @@ -1377,12 +1318,11 @@ func TestExchangeToken_BasicAuthURLEncoding(t *testing.T) { assert.Equal(t, url.QueryEscape(specialClientID), username, "ClientID should be URL-encoded") assert.Equal(t, url.QueryEscape(specialClientSecret), password, "ClientSecret should be URL-encoded") - resp := newResponse().build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(oauthtest.NewResponse().Build()) })) - defer server.Close() + t.Cleanup(server.Close) request := &exchangeRequest{ SubjectToken: "test-token", @@ -1408,17 +1348,17 @@ func TestExchangeConfig_Validate_SubjectTokenType(t *testing.T) { }{ { name: "valid access_token", - subjectTokenType: tokenTypeAccessToken, + subjectTokenType: oauthproto.TokenTypeAccessToken, wantErr: false, }, { name: "valid id_token", - subjectTokenType: tokenTypeIDToken, + subjectTokenType: oauthproto.TokenTypeIDToken, wantErr: false, }, { name: "valid jwt", - subjectTokenType: tokenTypeJWT, + subjectTokenType: oauthproto.TokenTypeJWT, wantErr: false, }, { @@ -1456,3 +1396,187 @@ func TestExchangeConfig_Validate_SubjectTokenType(t *testing.T) { }) } } + +// TestExchangeConfig_Validate_NormalizesSubjectTokenType pins the +// mutation behavior of Validate(): the short-form token type (e.g. +// "access_token") is written back onto the config as the full RFC 8693 +// URN. pkg/vmcp/auth/strategies/tokenexchange.go and pkg/runner/ +// config_builder.go read this field post-Validate and depend on the +// normalization. +func TestExchangeConfig_Validate_NormalizesSubjectTokenType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "short access_token", input: "access_token", want: oauthproto.TokenTypeAccessToken}, + {name: "short id_token", input: "id_token", want: oauthproto.TokenTypeIDToken}, + {name: "short jwt", input: "jwt", want: oauthproto.TokenTypeJWT}, + {name: "already-normalized URN passes through unchanged", input: oauthproto.TokenTypeAccessToken, want: oauthproto.TokenTypeAccessToken}, + {name: "empty stays empty", input: "", want: ""}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + c := &ExchangeConfig{ + TokenURL: "https://example.com/token", + SubjectTokenProvider: func() (string, error) { return "tok", nil }, + SubjectTokenType: tc.input, + } + require.NoError(t, c.Validate()) + assert.Equal(t, tc.want, c.SubjectTokenType) + }) + } +} + +// TestTokenSource_Token_RequestedTokenTypeAndResource verifies that +// ExchangeConfig.RequestedTokenType and ExchangeConfig.Resource propagate +// through tokenSource.Token() onto the token-exchange form body. +func TestTokenSource_Token_RequestedTokenTypeAndResource(t *testing.T) { + t.Parallel() + tests := []struct { + name string + requestedTokenType string + resource string + wantRequestedTokenType string + wantResource string + }{ + { + name: "defaults when unset", + requestedTokenType: "", + resource: "", + wantRequestedTokenType: oauthproto.TokenTypeAccessToken, + wantResource: "", + }, + { + name: "override requested_token_type", + requestedTokenType: oauthproto.TokenTypeJWT, + resource: "", + wantRequestedTokenType: oauthproto.TokenTypeJWT, + wantResource: "", + }, + { + name: "resource forwarded on the wire", + requestedTokenType: "", + resource: "https://mcp.example.com/api", + wantRequestedTokenType: oauthproto.TokenTypeAccessToken, + wantResource: "https://mcp.example.com/api", + }, + { + name: "both set", + requestedTokenType: oauthproto.TokenTypeJWT, + resource: "https://mcp.example.com/api", + wantRequestedTokenType: oauthproto.TokenTypeJWT, + wantResource: "https://mcp.example.com/api", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + require.NoError(t, err) + + assert.Equal(t, tt.wantRequestedTokenType, r.Form.Get("requested_token_type")) + assert.Equal(t, tt.wantResource, r.Form.Get("resource")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(oauthtest.NewResponse().Build()) + })) + t.Cleanup(server.Close) + + config := &ExchangeConfig{ + TokenURL: server.URL, + ClientID: "test-client", + SubjectTokenProvider: func() (string, error) { + return testSubjectToken, nil + }, + RequestedTokenType: tt.requestedTokenType, + Resource: tt.resource, + } + + ctx := context.Background() + ts := config.TokenSource(ctx) + _, err := ts.Token() + require.NoError(t, err) + }) + } +} + +func TestExchangeConfig_Validate_Resource(t *testing.T) { + t.Parallel() + tests := []struct { + name string + resource string + wantErr bool + wantErrText string + }{ + { + name: "empty resource", + resource: "", + wantErr: false, + }, + { + name: "absolute https URI", + resource: "https://mcp.example.com/api", + wantErr: false, + }, + { + name: "absolute urn", + resource: "urn:example:resource", + wantErr: false, + }, + { + name: "relative path", + resource: "/api", + wantErr: true, + wantErrText: "absolute URI", + }, + { + name: "scheme-less host", + resource: "mcp.example.com/api", + wantErr: true, + wantErrText: "absolute URI", + }, + { + name: "URI with fragment", + resource: "https://api.example.com#v2", + wantErr: true, + wantErrText: "fragment", + }, + { + name: "malformed URI", + resource: "http://[::1", + wantErr: true, + wantErrText: "invalid Resource URI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config := &ExchangeConfig{ + TokenURL: "https://sts.example.com/token", + SubjectTokenProvider: func() (string, error) { + return testSubjectToken, nil + }, + Resource: tt.resource, + } + + err := config.Validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrText) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/auth/tokenexchange/middleware.go b/pkg/oauthproto/tokenexchange/middleware.go similarity index 93% rename from pkg/auth/tokenexchange/middleware.go rename to pkg/oauthproto/tokenexchange/middleware.go index 22422bb58f..a9b7f437d5 100644 --- a/pkg/auth/tokenexchange/middleware.go +++ b/pkg/oauthproto/tokenexchange/middleware.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -35,7 +36,7 @@ const ( const ( // EnvClientSecret is the environment variable name for the OAuth client secret // This corresponds to the "client_secret" field in the token exchange configuration - //nolint:gosec // G101: This is an environment variable name, not a credential + //nolint:gosec // G101: this is an environment variable name, not a credential value EnvClientSecret = "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET" ) @@ -62,7 +63,7 @@ type Config struct { ClientID string `json:"client_id"` // ClientSecret is the OAuth 2.0 client secret - ClientSecret string `json:"client_secret"` //nolint:gosec // G117: field legitimately holds sensitive data + ClientSecret string `json:"client_secret"` // Audience is the target audience for the exchanged token Audience string `json:"audience"` @@ -71,8 +72,8 @@ type Config struct { Scopes []string `json:"scopes,omitempty"` // SubjectTokenType specifies the type of the subject token being exchanged. - // Common values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT. - // If empty, defaults to tokenTypeAccessToken. + // Common values: oauthproto.TokenTypeAccessToken (default), oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT. + // If empty, defaults to oauthproto.TokenTypeAccessToken. SubjectTokenType string `json:"subject_token_type,omitempty"` // HeaderStrategy determines how to inject the token @@ -194,8 +195,8 @@ func CreateMiddlewareFromHeader(config Config) (types.MiddlewareFunction, error) // This is the recommended approach for external authentication flows (OAuth/OIDC). // // The middleware will automatically select the appropriate token based on config.SubjectTokenType: -// - tokenTypeAccessToken: Uses token.AccessToken -// - tokenTypeIDToken or tokenTypeJWT: Uses token.Extra("id_token") +// - oauthproto.TokenTypeAccessToken: Uses token.AccessToken +// - oauthproto.TokenTypeIDToken or oauthproto.TokenTypeJWT: Uses token.Extra("id_token") // // This moves the token selection logic into the middleware where it belongs, // keeping the command layer focused on configuration. @@ -209,11 +210,11 @@ func CreateMiddlewareFromTokenSource( // Validate SubjectTokenType early to catch configuration errors at startup if config.SubjectTokenType != "" && - config.SubjectTokenType != tokenTypeAccessToken && - config.SubjectTokenType != tokenTypeIDToken && - config.SubjectTokenType != tokenTypeJWT { + config.SubjectTokenType != oauthproto.TokenTypeAccessToken && + config.SubjectTokenType != oauthproto.TokenTypeIDToken && + config.SubjectTokenType != oauthproto.TokenTypeJWT { return nil, fmt.Errorf("invalid SubjectTokenType: %s (must be one of: %s, %s, %s)", - config.SubjectTokenType, tokenTypeAccessToken, tokenTypeIDToken, tokenTypeJWT) + config.SubjectTokenType, oauthproto.TokenTypeAccessToken, oauthproto.TokenTypeIDToken, oauthproto.TokenTypeJWT) } // Create a SubjectTokenProvider that handles token selection based on config @@ -225,7 +226,7 @@ func CreateMiddlewareFromTokenSource( // Select appropriate token based on configured type switch config.SubjectTokenType { - case tokenTypeIDToken: + case oauthproto.TokenTypeIDToken: // Extract ID token from Extra field (standard OIDC approach) idToken, ok := token.Extra("id_token").(string) if !ok || idToken == "" { @@ -234,7 +235,7 @@ func CreateMiddlewareFromTokenSource( } return idToken, nil - case "", tokenTypeAccessToken: + case "", oauthproto.TokenTypeAccessToken: // Use access token (default) if token.AccessToken == "" { slog.Error("Access token not available") @@ -332,7 +333,6 @@ func createTokenExchangeMiddleware( // Log some claim information for debugging if sub, exists := claims["sub"]; exists { - //nolint:gosec // G706: subject claim is from validated JWT slog.Debug("Performing token exchange for subject", "subject", sub) } diff --git a/pkg/auth/tokenexchange/middleware_test.go b/pkg/oauthproto/tokenexchange/middleware_test.go similarity index 97% rename from pkg/auth/tokenexchange/middleware_test.go rename to pkg/oauthproto/tokenexchange/middleware_test.go index bf9b5125e5..adbfd6e405 100644 --- a/pkg/auth/tokenexchange/middleware_test.go +++ b/pkg/oauthproto/tokenexchange/middleware_test.go @@ -17,6 +17,7 @@ import ( "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/oauthproto/oauthtest" "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/transport/types/mocks" ) @@ -271,15 +272,13 @@ func TestCreateTokenExchangeMiddleware_Success(t *testing.T) { receivedScopes = r.Form.Get("scope") } - resp := response{ - AccessToken: "exchanged-token", - TokenType: "Bearer", - IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", - ExpiresIn: 3600, - } + body := oauthtest.NewResponse(). + WithAccessToken("exchanged-token"). + WithExpiresIn(3600). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) defer exchangeServer.Close() @@ -447,14 +446,12 @@ func TestCreateTokenExchangeMiddleware_Failures(t *testing.T) { { name: "invalid injection config", serverResponse: func(w http.ResponseWriter, _ *http.Request) { - resp := response{ - AccessToken: "exchanged-token", - TokenType: "Bearer", - IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", - } + body := oauthtest.NewResponse(). + WithAccessToken("exchanged-token"). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) }, headerStrategy: HeaderStrategyCustom, customHeaderName: "", // Missing header name causes injection failure @@ -684,15 +681,13 @@ func TestCreateTokenExchangeMiddleware_EnvironmentVariable(t *testing.T) { receivedClientSecret = password } - resp := response{ - AccessToken: "exchanged-token", - TokenType: "Bearer", - IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", - ExpiresIn: 3600, - } + body := oauthtest.NewResponse(). + WithAccessToken("exchanged-token"). + WithExpiresIn(3600). + Build() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write(body) })) defer exchangeServer.Close() diff --git a/pkg/oauthproto/useragent.go b/pkg/oauthproto/useragent.go new file mode 100644 index 0000000000..4223703d72 --- /dev/null +++ b/pkg/oauthproto/useragent.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "net/http" + "time" +) + +// httpClientTimeout matches the timeout already used by NonCachingRefresher's +// http.Client and is appropriate for OAuth token endpoints, which respond +// quickly under healthy conditions. +const httpClientTimeout = 30 * time.Second + +// UserAgentTransport is an http.RoundTripper that adds the ToolHive User-Agent +// header to outbound requests when no User-Agent is already set. +// +// It is intended for HTTP clients passed to golang.org/x/oauth2 via the +// oauth2.HTTPClient context value. Without it, the library falls back to +// http.DefaultClient and Go's default Go-http-client/2.0 User-Agent, which +// upstream servers and WAFs cannot attribute to ToolHive. +type UserAgentTransport struct { + // Base is the underlying RoundTripper. If nil, http.DefaultTransport is used. + Base http.RoundTripper +} + +// RoundTrip implements http.RoundTripper. It clones the request before +// mutating headers, per the RoundTripper contract, and sets User-Agent only +// when the request has no User-Agent set so that callers layering another +// transport can override the value. +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + clonedReq := req.Clone(req.Context()) + if clonedReq.Header == nil { + clonedReq.Header = make(http.Header) + } + if clonedReq.Header.Get("User-Agent") == "" { + clonedReq.Header.Set("User-Agent", UserAgent) + } + return base.RoundTrip(clonedReq) +} + +// NewHTTPClient returns an *http.Client whose transport sets the ToolHive +// User-Agent on every outbound request and that has a sensible timeout for +// OAuth token endpoint calls. Use it for HTTP clients passed to +// golang.org/x/oauth2 via the oauth2.HTTPClient context value. +func NewHTTPClient() *http.Client { + return &http.Client{ + Transport: &UserAgentTransport{}, + Timeout: httpClientTimeout, + } +} diff --git a/pkg/oauthproto/useragent_test.go b/pkg/oauthproto/useragent_test.go new file mode 100644 index 0000000000..126af0cecb --- /dev/null +++ b/pkg/oauthproto/useragent_test.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oauthproto + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingRoundTripper captures the User-Agent header from every request it +// sees and returns a 200 response. +type recordingRoundTripper struct { + userAgents []string +} + +func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.userAgents = append(r.userAgents, req.Header.Get("User-Agent")) + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Header: make(http.Header), + Request: req, + }, nil +} + +func TestUserAgentTransport_RoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + incomingUserAgent string + wantUserAgent string + }{ + { + name: "sets User-Agent when missing", + incomingUserAgent: "", + wantUserAgent: UserAgent, + }, + { + name: "preserves caller-provided User-Agent", + incomingUserAgent: "custom-client/2.5", + wantUserAgent: "custom-client/2.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + recorder := &recordingRoundTripper{} + transport := &UserAgentTransport{Base: recorder} + + req, err := http.NewRequest(http.MethodGet, "https://example.test/", nil) + require.NoError(t, err) + if tt.incomingUserAgent != "" { + req.Header.Set("User-Agent", tt.incomingUserAgent) + } + originalIncomingUA := req.Header.Get("User-Agent") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, resp) + + require.Len(t, recorder.userAgents, 1) + assert.Equal(t, tt.wantUserAgent, recorder.userAgents[0]) + + // The RoundTripper contract requires that RoundTrip not mutate + // the caller's *http.Request. Verify the User-Agent the caller + // set (or didn't set) is unchanged on the original request. + assert.Equal(t, originalIncomingUA, req.Header.Get("User-Agent"), "caller's User-Agent was mutated") + }) + } +} + +// TestUserAgentTransport_NilBase verifies that a UserAgentTransport with no +// Base falls back to http.DefaultTransport. We exercise this by sending a real +// request through an httptest.Server (not just a recorder, since the recorder +// would short-circuit the Base lookup). +func TestUserAgentTransport_NilBase(t *testing.T) { + t.Parallel() + + var got string + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + })) + t.Cleanup(server.Close) + + client := &http.Client{Transport: &UserAgentTransport{}} + resp, err := client.Get(server.URL) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, UserAgent, got) +} diff --git a/pkg/oidc/clientconfig.go b/pkg/oidc/clientconfig.go new file mode 100644 index 0000000000..2da794eb51 --- /dev/null +++ b/pkg/oidc/clientconfig.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "strings" + "time" +) + +const ( + // DefaultScopes are the default OAuth scopes requested during login. + DefaultScopes = "openid offline_access" +) + +// ClientConfig holds the OIDC provider settings and cached token state shared +// by both registry OAuth and LLM gateway authentication flows. Token values +// are never stored here — only references and expiry metadata. +// +// Both pkg/config.RegistryOAuthConfig and pkg/llm.OIDCConfig are type aliases +// for this type, so validation logic and new fields stay in sync across both +// authentication flows. +type ClientConfig struct { + Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` + Audience string `yaml:"audience,omitempty" json:"audience,omitempty"` + CallbackPort int `yaml:"callback_port,omitempty" json:"callback_port,omitempty"` + + // CachedRefreshTokenRef is the secrets-provider key under which the refresh + // token is stored (never the token value itself). + CachedRefreshTokenRef string `yaml:"cached_refresh_token_ref,omitempty" json:"cached_refresh_token_ref,omitempty"` + // CachedTokenExpiry is the expiry of the most recently cached access token, + // used to surface helpful messages when the token is about to expire. + CachedTokenExpiry time.Time `yaml:"cached_token_expiry,omitempty" json:"cached_token_expiry,omitempty"` +} + +// EffectiveScopes returns the configured OIDC scopes, or the default scopes +// (openid, offline_access) if none are set. +func (c *ClientConfig) EffectiveScopes() []string { + if len(c.Scopes) > 0 { + return c.Scopes + } + return strings.Fields(DefaultScopes) +} diff --git a/pkg/oidc/doc.go b/pkg/oidc/doc.go new file mode 100644 index 0000000000..965699efdb --- /dev/null +++ b/pkg/oidc/doc.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package oidc provides shared OIDC client configuration types used across +// ToolHive's registry and LLM gateway authentication flows. +package oidc diff --git a/pkg/ratelimit/internal/bucket/bucket.go b/pkg/ratelimit/internal/bucket/bucket.go index 28903bcdcd..d68a1709c8 100644 --- a/pkg/ratelimit/internal/bucket/bucket.go +++ b/pkg/ratelimit/internal/bucket/bucket.go @@ -90,7 +90,7 @@ type TokenBucket struct { } // New creates a TokenBucket. The Redis key is derived from namespace, server -// name, and suffix (e.g., "global" or "global:tool:search"). +// name, and suffix (e.g., "shared" or "shared:tool:search"). func New(namespace, serverName, suffix string, maxTokens int32, refillPeriod time.Duration) *TokenBucket { refillSec := refillPeriod.Seconds() return &TokenBucket{ diff --git a/pkg/ratelimit/limiter_test.go b/pkg/ratelimit/limiter_test.go index a007191d3c..147e22cd7e 100644 --- a/pkg/ratelimit/limiter_test.go +++ b/pkg/ratelimit/limiter_test.go @@ -70,7 +70,7 @@ func TestNewLimiter_ZeroDuration(t *testing.T) { assert.Contains(t, err.Error(), "refillPeriod must be positive") } -func TestLimiter_ServerGlobalExhausted(t *testing.T) { +func TestLimiter_ServerSharedExhausted(t *testing.T) { t.Parallel() client, _ := newTestClient(t) ctx := t.Context() @@ -93,6 +93,35 @@ func TestLimiter_ServerGlobalExhausted(t *testing.T) { assert.Greater(t, d.RetryAfter, time.Duration(0)) } +func TestLimiter_SharedUsesRedisKeys(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + ctx := t.Context() + + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + Shared: &v1beta1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + }, + }, + } + l, err := NewLimiter(client, "ns", "srv", crd) + require.NoError(t, err) + + d, err := l.Allow(ctx, "search", "") + require.NoError(t, err) + require.True(t, d.Allowed) + + serverKey := "thv:rl:{ns:srv}:shared" + toolKey := "thv:rl:{ns:srv}:shared:tool:search" + + exists, err := client.Exists(ctx, serverKey, toolKey).Result() + require.NoError(t, err) + assert.Equal(t, int64(2), exists) +} + func TestLimiter_PerToolIsolation(t *testing.T) { t.Parallel() client, _ := newTestClient(t) diff --git a/pkg/ratelimit/types/doc.go b/pkg/ratelimit/types/doc.go new file mode 100644 index 0000000000..699d0fbbcb --- /dev/null +++ b/pkg/ratelimit/types/doc.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package types defines shared rate limit configuration types. +// +// +kubebuilder:object:generate=true +// +groupName=toolhive.stacklok.dev +// +versionName=ratelimit +package types diff --git a/pkg/ratelimit/types/types.go b/pkg/ratelimit/types/types.go new file mode 100644 index 0000000000..f5ddc1a065 --- /dev/null +++ b/pkg/ratelimit/types/types.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// RateLimitConfig defines rate limiting configuration for an MCP server. +// At least one of shared, perUser, or tools must be configured. +// +// +kubebuilder:validation:XValidation:rule="has(self.shared) || has(self.perUser) || (has(self.tools) && size(self.tools) > 0)",message="at least one of shared, perUser, or tools must be configured" +// +gendoc +// +//nolint:lll // kubebuilder marker exceeds line length +type RateLimitConfig struct { + // Shared is a token bucket shared across all users for the entire server. + // +optional + Shared *RateLimitBucket `json:"shared,omitempty" yaml:"shared,omitempty"` + + // PerUser is a token bucket applied independently to each authenticated user + // at the server level. Requires authentication to be enabled. + // Each unique userID creates Redis keys that expire after 2x refillPeriod. + // Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. + // +optional + PerUser *RateLimitBucket `json:"perUser,omitempty" yaml:"perUser,omitempty"` + + // Tools defines per-tool rate limit overrides. + // Each entry applies additional rate limits to calls targeting a specific tool name. + // A request must pass both the server-level limit and the per-tool limit. + // +listType=map + // +listMapKey=name + // +optional + Tools []ToolRateLimitConfig `json:"tools,omitempty" yaml:"tools,omitempty"` +} + +// RateLimitBucket defines a token bucket configuration with a maximum capacity +// and a refill period. Used by both shared and per-user rate limits. +// +gendoc +type RateLimitBucket struct { + // MaxTokens is the maximum number of tokens (bucket capacity). + // This is also the burst size: the maximum number of requests that can be served + // instantaneously before the bucket is depleted. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + MaxTokens int32 `json:"maxTokens" yaml:"maxTokens"` + + // RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + // The effective refill rate is maxTokens / refillPeriod tokens per second. + // Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + // +kubebuilder:validation:Required + RefillPeriod metav1.Duration `json:"refillPeriod" yaml:"refillPeriod"` +} + +// ToolRateLimitConfig defines rate limits for a specific tool. +// At least one of shared or perUser must be configured. +// +// +kubebuilder:validation:XValidation:rule="has(self.shared) || has(self.perUser)",message="at least one of shared or perUser must be configured" +// +gendoc +// +//nolint:lll // kubebuilder marker exceeds line length +type ToolRateLimitConfig struct { + // Name is the MCP tool name this limit applies to. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name" yaml:"name"` + + // Shared token bucket for this specific tool. + // +optional + Shared *RateLimitBucket `json:"shared,omitempty" yaml:"shared,omitempty"` + + // PerUser token bucket configuration for this tool. + // +optional + PerUser *RateLimitBucket `json:"perUser,omitempty" yaml:"perUser,omitempty"` +} diff --git a/pkg/ratelimit/types/zz_generated.deepcopy.go b/pkg/ratelimit/types/zz_generated.deepcopy.go new file mode 100644 index 0000000000..dd9bc5ca25 --- /dev/null +++ b/pkg/ratelimit/types/zz_generated.deepcopy.go @@ -0,0 +1,96 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 Stacklok + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package types + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimitBucket) DeepCopyInto(out *RateLimitBucket) { + *out = *in + out.RefillPeriod = in.RefillPeriod +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitBucket. +func (in *RateLimitBucket) DeepCopy() *RateLimitBucket { + if in == nil { + return nil + } + out := new(RateLimitBucket) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimitConfig) DeepCopyInto(out *RateLimitConfig) { + *out = *in + if in.Shared != nil { + in, out := &in.Shared, &out.Shared + *out = new(RateLimitBucket) + **out = **in + } + if in.PerUser != nil { + in, out := &in.PerUser, &out.PerUser + *out = new(RateLimitBucket) + **out = **in + } + if in.Tools != nil { + in, out := &in.Tools, &out.Tools + *out = make([]ToolRateLimitConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitConfig. +func (in *RateLimitConfig) DeepCopy() *RateLimitConfig { + if in == nil { + return nil + } + out := new(RateLimitConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolRateLimitConfig) DeepCopyInto(out *ToolRateLimitConfig) { + *out = *in + if in.Shared != nil { + in, out := &in.Shared, &out.Shared + *out = new(RateLimitBucket) + **out = **in + } + if in.PerUser != nil { + in, out := &in.PerUser, &out.PerUser + *out = new(RateLimitBucket) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRateLimitConfig. +func (in *ToolRateLimitConfig) DeepCopy() *ToolRateLimitConfig { + if in == nil { + return nil + } + out := new(ToolRateLimitConfig) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/registry/auth/auth.go b/pkg/registry/auth/auth.go index ff0d8c0c24..0fa5495114 100644 --- a/pkg/registry/auth/auth.go +++ b/pkg/registry/auth/auth.go @@ -6,10 +6,11 @@ package auth import ( "context" - "crypto/sha256" - "encoding/hex" "errors" + "log/slog" + "time" + "github.com/stacklok/toolhive/pkg/auth/tokensource" "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/secrets" ) @@ -40,18 +41,44 @@ func NewTokenSource( return nil, nil } - return &oauthTokenSource{ - oauthCfg: cfg, - registryURL: registryURL, - secretsProvider: secretsProvider, - interactive: interactive, - }, nil + return tokensource.New(tokensource.Options{ + OIDC: tokensource.OIDCParams{ + Issuer: cfg.Issuer, + ClientID: cfg.ClientID, + Scopes: cfg.Scopes, + Audience: cfg.Audience, + CallbackPort: cfg.CallbackPort, + }, + SecretsProvider: secretsProvider, + Interactive: interactive, + KeyProvider: func() string { + if cfg.CachedRefreshTokenRef != "" { + return cfg.CachedRefreshTokenRef + } + return DeriveSecretKey(registryURL, cfg.Issuer) + }, + ConfigPersister: updateRegistryTokenRef, + FallbackErr: ErrRegistryAuthRequired, + }), nil } // DeriveSecretKey computes the secret key for storing a registry's refresh token. // The key follows the formula: REGISTRY_OAUTH_<8 hex chars> // where the hex is derived from sha256(registryURL + "\x00" + issuer)[:4]. func DeriveSecretKey(registryURL, issuer string) string { - h := sha256.Sum256([]byte(registryURL + "\x00" + issuer)) - return "REGISTRY_OAUTH_" + hex.EncodeToString(h[:4]) + return tokensource.DeriveSecretKey("REGISTRY_OAUTH_", registryURL, issuer) +} + +// updateRegistryTokenRef persists the refresh token key and expiry into the +// global config under RegistryAuth.OAuth. +func updateRegistryTokenRef(key string, expiry time.Time) { + if err := config.UpdateConfig(func(c *config.Config) error { + if c.RegistryAuth.OAuth != nil { + c.RegistryAuth.OAuth.CachedRefreshTokenRef = key + c.RegistryAuth.OAuth.CachedTokenExpiry = expiry + } + return nil + }); err != nil { + slog.Warn("failed to update registry config with token reference", "error", err) + } } diff --git a/pkg/registry/auth/auth_test.go b/pkg/registry/auth/auth_test.go index eee3833f5d..68ade2f1b3 100644 --- a/pkg/registry/auth/auth_test.go +++ b/pkg/registry/auth/auth_test.go @@ -7,23 +7,20 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "errors" - "fmt" - "net/http" - "net/http/httptest" + "strings" "testing" - "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "golang.org/x/oauth2" "github.com/stacklok/toolhive/pkg/config" - "github.com/stacklok/toolhive/pkg/secrets" secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" ) +// ── DeriveSecretKey ─────────────────────────────────────────────────────────── + func TestDeriveSecretKey(t *testing.T) { t.Parallel() @@ -32,31 +29,11 @@ func TestDeriveSecretKey(t *testing.T) { registryURL string issuer string }{ - { - name: "typical registry and issuer", - registryURL: "https://registry.example.com", - issuer: "https://auth.example.com", - }, - { - name: "empty strings", - registryURL: "", - issuer: "", - }, - { - name: "empty issuer", - registryURL: "https://registry.example.com", - issuer: "", - }, - { - name: "empty registry URL", - registryURL: "", - issuer: "https://auth.example.com", - }, - { - name: "localhost registry", - registryURL: "http://localhost:5000", - issuer: "http://localhost:8080", - }, + {"typical", "https://registry.example.com", "https://auth.example.com"}, + {"empty strings", "", ""}, + {"empty issuer", "https://registry.example.com", ""}, + {"empty registry", "", "https://auth.example.com"}, + {"localhost", "http://localhost:5000", "http://localhost:8080"}, } for _, tt := range tests { @@ -65,23 +42,18 @@ func TestDeriveSecretKey(t *testing.T) { key := DeriveSecretKey(tt.registryURL, tt.issuer) - // Must start with the correct prefix - require.True(t, len(key) > len("REGISTRY_OAUTH_"), "key too short") + require.True(t, len(key) > len("REGISTRY_OAUTH_")) require.Equal(t, "REGISTRY_OAUTH_", key[:len("REGISTRY_OAUTH_")]) - // The suffix must be exactly 8 hex characters (4 bytes of sha256) suffix := key[len("REGISTRY_OAUTH_"):] - require.Len(t, suffix, 8, "hex suffix must be exactly 8 characters") + require.Len(t, suffix, 8) - // Verify each character is a valid hex character for _, c := range suffix { require.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), - "suffix character %q is not a lowercase hex digit", c, - ) + "suffix character %q is not a lowercase hex digit", c) } - // Verify the derivation formula: sha256(registryURL + "\x00" + issuer)[:4] h := sha256.Sum256([]byte(tt.registryURL + "\x00" + tt.issuer)) expected := "REGISTRY_OAUTH_" + hex.EncodeToString(h[:4]) require.Equal(t, expected, key) @@ -92,87 +64,66 @@ func TestDeriveSecretKey(t *testing.T) { func TestDeriveSecretKey_Deterministic(t *testing.T) { t.Parallel() - registryURL := "https://registry.example.com" - issuer := "https://auth.example.com" - - key1 := DeriveSecretKey(registryURL, issuer) - key2 := DeriveSecretKey(registryURL, issuer) - - require.Equal(t, key1, key2, "DeriveSecretKey must be deterministic") + key1 := DeriveSecretKey("https://registry.example.com", "https://auth.example.com") + key2 := DeriveSecretKey("https://registry.example.com", "https://auth.example.com") + require.Equal(t, key1, key2) } func TestDeriveSecretKey_UniquePerInputCombination(t *testing.T) { t.Parallel() - combinations := []struct { - registryURL string - issuer string - }{ + combinations := []struct{ url, issuer string }{ {"https://registry-a.example.com", "https://auth.example.com"}, {"https://registry-b.example.com", "https://auth.example.com"}, {"https://registry-a.example.com", "https://auth-other.example.com"}, {"https://registry-b.example.com", "https://auth-other.example.com"}, } - keys := make(map[string]struct{}, len(combinations)) - for _, combo := range combinations { - key := DeriveSecretKey(combo.registryURL, combo.issuer) - _, alreadySeen := keys[key] - require.False(t, alreadySeen, - "DeriveSecretKey produced a duplicate key for registryURL=%q issuer=%q: %q", - combo.registryURL, combo.issuer, key, - ) - keys[key] = struct{}{} + seen := make(map[string]struct{}, len(combinations)) + for _, c := range combinations { + key := DeriveSecretKey(c.url, c.issuer) + _, dup := seen[key] + require.False(t, dup, "duplicate key for url=%q issuer=%q: %q", c.url, c.issuer, key) + seen[key] = struct{}{} } } func TestDeriveSecretKey_NullByteIsolatesSegments(t *testing.T) { t.Parallel() - - // Without the null-byte separator these two pairs would hash identically: - // ("ab", "c") and ("a", "bc") both concatenate to "abc". - // The separator prevents that collision. key1 := DeriveSecretKey("ab", "c") key2 := DeriveSecretKey("a", "bc") - - require.NotEqual(t, key1, key2, - "keys must differ when registry URL and issuer are split differently") + require.NotEqual(t, key1, key2) } +// ── NewTokenSource ──────────────────────────────────────────────────────────── + func TestNewTokenSource(t *testing.T) { t.Parallel() tests := []struct { - name string - cfg *config.RegistryOAuthConfig - wantNil bool - wantErrNil bool + name string + cfg *config.RegistryOAuthConfig + wantNil bool }{ { - name: "nil config returns nil source and nil error", - cfg: nil, - wantNil: true, - wantErrNil: true, + name: "nil config returns nil source", + cfg: nil, + wantNil: true, }, { - name: "non-nil config returns non-nil source", - cfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "my-client-id", - }, - wantNil: false, - wantErrNil: true, + name: "non-nil config returns non-nil source", + cfg: &config.RegistryOAuthConfig{Issuer: "https://auth.example.com", ClientID: "id"}, + wantNil: false, }, { name: "config with scopes and audience returns non-nil source", cfg: &config.RegistryOAuthConfig{ Issuer: "https://auth.example.com", - ClientID: "my-client-id", - Scopes: []string{"openid", "profile"}, + ClientID: "id", + Scopes: []string{"openid"}, Audience: "api://my-api", }, - wantNil: false, - wantErrNil: true, + wantNil: false, }, } @@ -181,13 +132,7 @@ func TestNewTokenSource(t *testing.T) { t.Parallel() src, err := NewTokenSource(tt.cfg, "https://registry.example.com", nil, false) - - if tt.wantErrNil { - require.NoError(t, err) - } else { - require.Error(t, err) - } - + require.NoError(t, err) if tt.wantNil { require.Nil(t, src) } else { @@ -197,396 +142,211 @@ func TestNewTokenSource(t *testing.T) { } } -func TestOAuthTokenSource_Token_NonInteractiveNoCache(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - buildProvider func(ctrl *gomock.Controller) *secretsmocks.MockProvider - }{ - { - name: "non-interactive with no secrets provider returns ErrRegistryAuthRequired", - buildProvider: nil, // nil secrets provider - }, - { - name: "non-interactive with secrets provider error returns ErrRegistryAuthRequired", - buildProvider: func(ctrl *gomock.Controller) *secretsmocks.MockProvider { - mock := secretsmocks.NewMockProvider(ctrl) - mock.EXPECT(). - GetSecret(gomock.Any(), gomock.Any()). - Return("", errors.New("connection refused")) - return mock - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - var provider secrets.Provider - if tt.buildProvider != nil { - provider = tt.buildProvider(ctrl) - } - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: provider, - interactive: false, - } - - _, err := src.Token(context.Background()) +// ── Token – non-interactive, no cache ──────────────────────────────────────── - require.Error(t, err) - require.True(t, errors.Is(err, ErrRegistryAuthRequired), - "expected ErrRegistryAuthRequired, got: %v", err) - }) - } -} - -func TestOAuthTokenSource_RefreshTokenKey(t *testing.T) { +// When no credentials are cached and the caller is non-interactive, +// Token() must return ErrRegistryAuthRequired. +func TestToken_NonInteractive_NoCache_ReturnsErrRegistryAuthRequired(t *testing.T) { t.Parallel() - const registryURL = "https://registry.example.com" - const issuer = "https://auth.example.com" - - tests := []struct { - name string - cachedRefreshTokenRef string - wantKey string - }{ - { - name: "returns CachedRefreshTokenRef when set", - cachedRefreshTokenRef: "my-cached-ref-key", - wantKey: "my-cached-ref-key", - }, - { - name: "falls back to DeriveSecretKey when CachedRefreshTokenRef is empty", - cachedRefreshTokenRef: "", - wantKey: DeriveSecretKey(registryURL, issuer), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: issuer, - ClientID: "test-client", - CachedRefreshTokenRef: tt.cachedRefreshTokenRef, - }, - registryURL: registryURL, - } - - got := src.refreshTokenKey() - require.Equal(t, tt.wantKey, got) - }) - } -} - -// mockOAuth2TokenSource is a test double for oauth2.TokenSource (no-context variant). -type mockOAuth2TokenSource struct { - token *oauth2.Token - err error -} - -func (m *mockOAuth2TokenSource) Token() (*oauth2.Token, error) { - return m.token, m.err -} - -// newOIDCTestServer starts an httptest server that handles the two well-known -// OIDC discovery paths used by CreateOAuthConfigFromOIDC. It returns the server -// and shuts it down automatically when the test completes. -func newOIDCTestServer(t *testing.T) *httptest.Server { - t.Helper() - - var srv *httptest.Server - mux := http.NewServeMux() - - handler := func(w http.ResponseWriter, _ *http.Request) { - issuer := srv.URL - doc := map[string]string{ - "issuer": issuer, - "authorization_endpoint": issuer + "/authorize", - "token_endpoint": issuer + "/token", - "jwks_uri": issuer + "/jwks", - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(doc); err != nil { - http.Error(w, "encode error", http.StatusInternalServerError) - } - } - - // CreateOAuthConfigFromOIDC tries both OIDC and OAuth well-known paths. - mux.HandleFunc("/.well-known/openid-configuration", handler) - mux.HandleFunc("/.well-known/oauth-authorization-server", handler) + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + // Return a not-found-style error so both AT and RT cache lookups miss cleanly. + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", errors.New("not found")).AnyTimes() + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: "https://auth.example.com", ClientID: "c"}, + "https://registry.example.com", mock, false, + ) + require.NoError(t, err) - srv = httptest.NewServer(mux) - t.Cleanup(srv.Close) - return srv + _, tokErr := src.Token(context.Background()) + require.ErrorIs(t, tokErr, ErrRegistryAuthRequired) } -// TestOAuthTokenSource_Token_InMemoryCacheHit verifies that when the in-memory -// token source holds a valid, non-expired token, Token() returns it immediately -// without consulting the secrets provider. -func TestOAuthTokenSource_Token_InMemoryCacheHit(t *testing.T) { +// When the secrets backend returns a non-not-found error (e.g. keyring locked), +// Token() must surface that specific error rather than the generic ErrRegistryAuthRequired. +func TestToken_NonInteractive_BackendError_SurfacesLastErr(t *testing.T) { t.Parallel() - validToken := &oauth2.Token{ - AccessToken: "cached-access-token", - Expiry: time.Now().Add(time.Hour), - TokenType: "Bearer", - } - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: nil, // should never be called - interactive: false, - tokenSource: &mockOAuth2TokenSource{token: validToken}, - } - - got, err := src.Token(context.Background()) + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + backendErr := errors.New("keyring is locked") + mock.EXPECT().GetSecret(gomock.Any(), gomock.Any()). + Return("", backendErr).AnyTimes() + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: "https://auth.example.com", ClientID: "c"}, + "https://registry.example.com", mock, false, + ) require.NoError(t, err) - require.Equal(t, "cached-access-token", got) -} - -// TestOAuthTokenSource_Token_InMemoryCacheExpiredFallsThrough verifies that when -// the in-memory token source returns an expired token (past Expiry), Token() clears -// the cache and falls through to return ErrRegistryAuthRequired in non-interactive mode -// without a secrets provider. -func TestOAuthTokenSource_Token_InMemoryCacheExpiredFallsThrough(t *testing.T) { - t.Parallel() - - expiredToken := &oauth2.Token{ - AccessToken: "expired-token", - Expiry: time.Now().Add(-time.Hour), // already expired - TokenType: "Bearer", - } - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: nil, - interactive: false, - tokenSource: &mockOAuth2TokenSource{token: expiredToken}, - } - - _, err := src.Token(context.Background()) - require.Error(t, err) - require.True(t, errors.Is(err, ErrRegistryAuthRequired), - "expected ErrRegistryAuthRequired, got: %v", err) - // In-memory cache should have been cleared. - require.Nil(t, src.tokenSource) + _, tokErr := src.Token(context.Background()) + require.Error(t, tokErr) + assert.False(t, errors.Is(tokErr, ErrRegistryAuthRequired), + "backend error must surface as lastErr, not the generic ErrRegistryAuthRequired") + assert.ErrorContains(t, tokErr, "keyring is locked") } -// TestOAuthTokenSource_Token_InMemoryCacheErrorFallsThrough verifies that when -// the in-memory token source returns an error, Token() clears the cache and falls -// through to return ErrRegistryAuthRequired in non-interactive mode. -func TestOAuthTokenSource_Token_InMemoryCacheErrorFallsThrough(t *testing.T) { +// Nil secrets provider returns an actionable error, not ErrRegistryAuthRequired. +func TestToken_NilSecretsProvider_ReturnsActionableError(t *testing.T) { t.Parallel() - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: nil, - interactive: false, - tokenSource: &mockOAuth2TokenSource{err: errors.New("token refresh failed")}, - } + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: "https://auth.example.com", ClientID: "c"}, + "https://registry.example.com", nil, false, + ) + require.NoError(t, err) - _, err := src.Token(context.Background()) - require.Error(t, err) - require.True(t, errors.Is(err, ErrRegistryAuthRequired), - "expected ErrRegistryAuthRequired, got: %v", err) - // In-memory cache should have been cleared. - require.Nil(t, src.tokenSource) + _, tokErr := src.Token(context.Background()) + require.Error(t, tokErr) + assert.False(t, errors.Is(tokErr, ErrRegistryAuthRequired)) } -// TestOAuthTokenSource_TryRestoreFromCache_NilProvider verifies that -// tryRestoreFromCache returns an error immediately when no secrets provider is set. -func TestOAuthTokenSource_TryRestoreFromCache_NilProvider(t *testing.T) { +// ── KeyProvider uses CachedRefreshTokenRef when set ─────────────────────────── + +// When CachedRefreshTokenRef is set, Token() must look up that exact key. +func TestToken_UsesCachedRefreshTokenRef(t *testing.T) { t.Parallel() - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + const persistedKey = "my-cached-ref-key" + // Expect the AT cache key and the base key — both derived from persistedKey. + mock.EXPECT(). + GetSecret(gomock.Any(), persistedKey+"_AT"). + Return("", errors.New("not found")) + mock.EXPECT(). + GetSecret(gomock.Any(), persistedKey). + Return("", errors.New("not found")) + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{ + Issuer: "https://auth.example.com", + ClientID: "c", + CachedRefreshTokenRef: persistedKey, }, - registryURL: "https://registry.example.com", - secretsProvider: nil, // genuine nil interface — triggers the nil guard in tryRestoreFromCache - } - - err := src.tryRestoreFromCache(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), "no secrets provider available") + "https://registry.example.com", mock, false, + ) + require.NoError(t, err) + _, _ = src.Token(context.Background()) + // Mock expectations verify the correct key was used. } -// TestOAuthTokenSource_TryRestoreFromCache covers the error paths in tryRestoreFromCache -// that involve the secrets provider returning errors or empty values. -func TestOAuthTokenSource_TryRestoreFromCache(t *testing.T) { +// When CachedRefreshTokenRef is empty, Token() derives the key from URL+issuer. +func TestToken_DerivesKeyWhenNoCachedRef(t *testing.T) { t.Parallel() - tests := []struct { - name string - buildProvider func(ctrl *gomock.Controller) *secretsmocks.MockProvider - wantErrContains string - }{ - { - name: "GetSecret returns error", - buildProvider: func(ctrl *gomock.Controller) *secretsmocks.MockProvider { - mock := secretsmocks.NewMockProvider(ctrl) - mock.EXPECT(). - GetSecret(gomock.Any(), gomock.Any()). - Return("", errors.New("vault unavailable")) - return mock - }, - wantErrContains: "failed to get cached refresh token", - }, - { - name: "GetSecret returns empty string", - buildProvider: func(ctrl *gomock.Controller) *secretsmocks.MockProvider { - mock := secretsmocks.NewMockProvider(ctrl) - mock.EXPECT(). - GetSecret(gomock.Any(), gomock.Any()). - Return("", nil) - return mock - }, - wantErrContains: "no cached refresh token found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - provider := tt.buildProvider(ctrl) - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: provider, - } + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) - err := src.tryRestoreFromCache(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErrContains) - }) - } + const registryURL = "https://registry.example.com" + const issuer = "https://auth.example.com" + derivedKey := DeriveSecretKey(registryURL, issuer) + + mock.EXPECT(). + GetSecret(gomock.Any(), derivedKey+"_AT"). + Return("", errors.New("not found")) + mock.EXPECT(). + GetSecret(gomock.Any(), derivedKey). + Return("", errors.New("not found")) + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: issuer, ClientID: "c"}, + registryURL, mock, false, + ) + require.NoError(t, err) + _, _ = src.Token(context.Background()) } -// TestOAuthTokenSource_TryRestoreFromCache_WithOIDCServer verifies that -// tryRestoreFromCache succeeds when a valid refresh token is found in the secrets -// provider and an OIDC discovery document is available. -func TestOAuthTokenSource_TryRestoreFromCache_WithOIDCServer(t *testing.T) { +// ── Token restores from cached refresh token ────────────────────────────────── + +func TestToken_RefreshTokenCache_UsesPersistedToken(t *testing.T) { t.Parallel() - srv := newOIDCTestServer(t) + srv := newTokenServer(t, "fresh-access-token", "rt-rotated") ctrl := gomock.NewController(t) - mockProvider := secretsmocks.NewMockProvider(ctrl) - mockProvider.EXPECT(). - GetSecret(gomock.Any(), gomock.Any()). - Return("my-refresh-token", nil) - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: srv.URL, - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: mockProvider, - } - - err := src.tryRestoreFromCache(context.Background()) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + // AT cache miss; refresh token present. + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") + } + return "stored-refresh-token", nil + }).AnyTimes() + mock.EXPECT().SetSecret(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: srv.URL, ClientID: "c"}, + "https://registry.example.com", mock, false, + ) require.NoError(t, err) - // We only verify the tokenSource was set; actually exchanging the refresh - // token requires a real /token endpoint and is covered by integration tests. - require.NotNil(t, src.tokenSource, - "tokenSource must be set after successful cache restoration") -} -// TestOAuthTokenSource_CreateTokenPersister covers the createTokenPersister helper. -func TestOAuthTokenSource_CreateTokenPersister(t *testing.T) { - t.Parallel() + tok, tokErr := src.Token(context.Background()) + require.NoError(t, tokErr) + assert.Equal(t, "fresh-access-token", tok) +} - const refreshTokenKey = "REGISTRY_OAUTH_testkey" - const refreshTokenValue = "rt-abc123" +// ── PersistingTokenSource applied in tryRestoreFromCache ───────────────────── - tests := []struct { - name string - setupMock func(mock *secretsmocks.MockProvider) - wantErr bool - wantErrSubstr string - }{ - { - name: "SetSecret succeeds", - setupMock: func(mock *secretsmocks.MockProvider) { - mock.EXPECT(). - SetSecret(gomock.Any(), refreshTokenKey, refreshTokenValue). - Return(nil) - }, - wantErr: false, - }, - { - name: "SetSecret returns error", - setupMock: func(mock *secretsmocks.MockProvider) { - mock.EXPECT(). - SetSecret(gomock.Any(), refreshTokenKey, refreshTokenValue). - Return(fmt.Errorf("storage full")) - }, - wantErr: true, - wantErrSubstr: "failed to persist refresh token", - }, - } +// This is a regression test for the bug where registry's tryRestoreFromCache did +// not wrap the inner source with PersistingTokenSource, meaning rotated refresh +// tokens were never re-persisted after a cache restore (the fix introduced in +// the LLM implementation is now shared with the registry path). +func TestToken_RefreshTokenCache_RotatedTokenPersisted(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + // Build a fake OIDC+token endpoint that returns a rotated refresh token. + var setSecretCalls []string + srv := newTokenServer(t, "new-access-token", "rotated-refresh-token") - ctrl := gomock.NewController(t) - mockProvider := secretsmocks.NewMockProvider(ctrl) - tt.setupMock(mockProvider) - - src := &oauthTokenSource{ - oauthCfg: &config.RegistryOAuthConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - }, - registryURL: "https://registry.example.com", - secretsProvider: mockProvider, + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + mock := secretsmocks.NewMockProvider(ctrl) + + mock.EXPECT(). + GetSecret(gomock.Any(), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key string) (string, error) { + if strings.HasSuffix(key, "_AT") { + return "", errors.New("not found") } + return "old-refresh-token", nil + }).AnyTimes() + mock.EXPECT(). + SetSecret(gomock.Any(), gomock.AssignableToTypeOf(""), gomock.AssignableToTypeOf("")). + DoAndReturn(func(_ context.Context, key, val string) error { + setSecretCalls = append(setSecretCalls, key+"="+val) + return nil + }).AnyTimes() + + src, err := NewTokenSource( + &config.RegistryOAuthConfig{Issuer: srv.URL, ClientID: "c"}, + "https://registry.example.com", mock, false, + ) + require.NoError(t, err) - persister := src.createTokenPersister(refreshTokenKey) - require.NotNil(t, persister) + tok, tokErr := src.Token(context.Background()) + require.NoError(t, tokErr) + assert.Equal(t, "new-access-token", tok) - // Call the persister function — expiry value does not affect SetSecret behaviour. - err := persister(refreshTokenValue, time.Now().Add(time.Hour)) - if tt.wantErr { - require.Error(t, err) - require.Contains(t, err.Error(), tt.wantErrSubstr) - } else { - require.NoError(t, err) - } - }) + // PersistingTokenSource must have written the rotated refresh token. + var persistedRT bool + for _, call := range setSecretCalls { + if strings.Contains(call, "rotated-refresh-token") { + persistedRT = true + } } + assert.True(t, persistedRT, + "rotated refresh token must be re-persisted via PersistingTokenSource; SetSecret calls: %v", setSecretCalls) } diff --git a/pkg/registry/auth/helpers_test.go b/pkg/registry/auth/helpers_test.go new file mode 100644 index 0000000000..91e8fef74d --- /dev/null +++ b/pkg/registry/auth/helpers_test.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// newOIDCTestServer starts an httptest server that handles the well-known OIDC +// discovery endpoints used by CreateOAuthConfigFromOIDC. It shuts down +// automatically when the test completes. +func newOIDCTestServer(t *testing.T) *httptest.Server { + t.Helper() + + var srv *httptest.Server + mux := http.NewServeMux() + + handler := func(w http.ResponseWriter, _ *http.Request) { + doc := map[string]string{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + } + mux.HandleFunc("/.well-known/openid-configuration", handler) + mux.HandleFunc("/.well-known/oauth-authorization-server", handler) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// newTokenServer builds a server that handles OIDC discovery AND a token +// endpoint that returns the given access token and refresh token. +func newTokenServer(t *testing.T, at, rt string) *httptest.Server { + t.Helper() + + var srv *httptest.Server + mux := http.NewServeMux() + + oidcHandler := func(w http.ResponseWriter, _ *http.Request) { + doc := map[string]string{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + } + mux.HandleFunc("/.well-known/openid-configuration", oidcHandler) + mux.HandleFunc("/.well-known/oauth-authorization-server", oidcHandler) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"` + at + `","refresh_token":"` + rt + `","token_type":"Bearer","expires_in":3600}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} diff --git a/pkg/registry/auth/issuer_validation.go b/pkg/registry/auth/issuer_validation.go index 35db4038a0..9fe07e7ec2 100644 --- a/pkg/registry/auth/issuer_validation.go +++ b/pkg/registry/auth/issuer_validation.go @@ -3,26 +3,11 @@ package auth -import ( - "fmt" - "net/url" - - "github.com/stacklok/toolhive/pkg/networking" -) +import "github.com/stacklok/toolhive/pkg/networking" // validateIssuerURL validates that the issuer is a well-formed URL using HTTPS. // HTTP is permitted only for localhost (development). Per OIDC Core Section 3.1.2.1 // and RFC 8414 Section 2, the issuer MUST use the "https" scheme. func validateIssuerURL(rawURL string) error { - u, err := url.Parse(rawURL) - if err != nil { - return fmt.Errorf("invalid issuer URL %q: %w", rawURL, err) - } - if u.Host == "" { - return fmt.Errorf("issuer URL must include a host: %s", rawURL) - } - if u.Scheme != "https" && !networking.IsLocalhost(u.Host) { - return fmt.Errorf("issuer URL must use HTTPS (except localhost for development): %s", rawURL) - } - return nil + return networking.ValidateIssuerURL(rawURL) } diff --git a/pkg/registry/auth/login.go b/pkg/registry/auth/login.go index c842c6c0db..3bb4447c09 100644 --- a/pkg/registry/auth/login.go +++ b/pkg/registry/auth/login.go @@ -38,6 +38,10 @@ type LoginOptions struct { Audience string // Scopes overrides the default OAuth scopes (defaults to ["openid", "offline_access"]). Scopes []string + // AllowPrivateIP permits RegistryURL to resolve to a private IP address. + // Mirrors the `--allow-private-ip` flag on `thv config set-registry`; only + // honored when RegistryURL is supplied (no-op when reusing stored config). + AllowPrivateIP bool } // Login performs an interactive OAuth login against the configured registry. @@ -93,6 +97,14 @@ func Login( } // Logout clears cached OAuth credentials for the configured registry. +// +// Every secret under the registry scope is deleted, not just the refresh-token +// key the current config points at. This is intentional: the token source also +// stores a cached access token under "_AT" (see pkg/auth/tokensource), +// and a registry/issuer change can leave stale entries under derived keys the +// current config no longer points at. A targeted delete would miss both and +// allow the next login to short-circuit through tier 2/3 of the token source +// instead of triggering a fresh browser flow. func Logout(ctx context.Context, configProvider config.Provider, secretsProvider secrets.Provider) error { cfg, err := configProvider.LoadOrCreateConfig() if err != nil { @@ -102,30 +114,13 @@ func Logout(ctx context.Context, configProvider config.Provider, secretsProvider return err } - registryURL := registryURLFromConfig(cfg) - - if ref := cfg.RegistryAuth.OAuth.CachedRefreshTokenRef; ref != "" { - if err := secretsProvider.DeleteSecret(ctx, ref); err != nil && !secrets.IsNotFoundError(err) { - return fmt.Errorf("deleting cached token: %w", err) - } - } - - // Also attempt cleanup using the derived key as a fallback. If - // updateConfigTokenRef failed previously (it only logs a warning), - // the secret may exist under this key even when CachedRefreshTokenRef - // is empty or points to a different reference. - if cfg.RegistryAuth.OAuth.Issuer != "" { - derivedKey := DeriveSecretKey(registryURL, cfg.RegistryAuth.OAuth.Issuer) - if derivedKey != cfg.RegistryAuth.OAuth.CachedRefreshTokenRef { - if err := secretsProvider.DeleteSecret(ctx, derivedKey); err != nil && !secrets.IsNotFoundError(err) { - slog.Debug("failed to delete derived secret key", "error", err) - } - } + if err := deleteAllScopedSecrets(ctx, secretsProvider); err != nil { + return fmt.Errorf("deleting cached tokens: %w", err) } // Clear the persistent registry cache so authenticated data doesn't // remain on disk after logout. - if err := clearRegistryCache(registryURL); err != nil { + if err := clearRegistryCache(registryURLFromConfig(cfg)); err != nil { slog.Debug("failed to clear registry cache", "error", err) } @@ -138,6 +133,29 @@ func Logout(ctx context.Context, configProvider config.Provider, secretsProvider }) } +// deleteAllScopedSecrets removes every secret visible to the given (already +// scope-restricted) provider. Mirrors pkg/llm.DeleteCachedTokens — providers +// that cannot list or delete (e.g. the environment provider) cannot hold cached +// tokens, so the operation is a no-op there. +func deleteAllScopedSecrets(ctx context.Context, provider secrets.Provider) error { + caps := provider.Capabilities() + if !caps.CanList || !caps.CanDelete { + return nil + } + descs, err := provider.ListSecrets(ctx) + if err != nil { + return fmt.Errorf("listing cached tokens: %w", err) + } + if len(descs) == 0 { + return nil + } + names := make([]string, len(descs)) + for i, d := range descs { + names[i] = d.Key + } + return provider.DeleteSecrets(ctx, names) +} + // validateOAuthConfig checks that registry OAuth authentication is configured. func validateOAuthConfig(cfg *config.Config) error { if cfg.RegistryAuth.Type != config.RegistryAuthTypeOAuth || cfg.RegistryAuth.OAuth == nil { @@ -196,7 +214,7 @@ func ensureRegistryURL(configProvider config.Provider, opts LoginOptions) error return nil } - registryType, cleanPath := config.DetectRegistryType(opts.RegistryURL, false) + registryType, cleanPath := config.DetectRegistryType(opts.RegistryURL, opts.AllowPrivateIP) // Always clear auth when a registry URL is explicitly provided, so that // tokens are never sent to the wrong server. @@ -209,11 +227,11 @@ func ensureRegistryURL(configProvider config.Provider, opts LoginOptions) error switch registryType { case config.RegistryTypeAPI: - if err := configProvider.SetRegistryAPI(cleanPath, false); err != nil { + if err := configProvider.SetRegistryAPI(cleanPath, opts.AllowPrivateIP); err != nil { return fmt.Errorf("saving registry API URL: %w", err) } case config.RegistryTypeURL: - if err := configProvider.SetRegistryURL(cleanPath, false); err != nil { + if err := configProvider.SetRegistryURL(cleanPath, opts.AllowPrivateIP); err != nil { return fmt.Errorf("saving registry URL: %w", err) } case config.RegistryTypeFile: diff --git a/pkg/registry/auth/login_test.go b/pkg/registry/auth/login_test.go index 75ec1f2cc2..6beb9c3edf 100644 --- a/pkg/registry/auth/login_test.go +++ b/pkg/registry/auth/login_test.go @@ -16,6 +16,7 @@ import ( "github.com/stacklok/toolhive/pkg/auth/remote" "github.com/stacklok/toolhive/pkg/config" configmocks "github.com/stacklok/toolhive/pkg/config/mocks" + "github.com/stacklok/toolhive/pkg/secrets" secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" ) @@ -333,6 +334,19 @@ func TestEnsureRegistryURL(t *testing.T) { }, wantErr: false, }, + { + name: "AllowPrivateIP propagates to SetRegistryURL", + cfg: &config.Config{}, + opts: LoginOptions{ + RegistryURL: "https://registry.example.com/mcp.json", + AllowPrivateIP: true, + }, + setupMock: func(m *configmocks.MockProvider) { + m.EXPECT().UpdateConfig(gomock.Any()).Return(nil) + m.EXPECT().SetRegistryURL(gomock.Any(), true).Return(nil) + }, + wantErr: false, + }, { name: "opts supply file path - clears auth then calls SetRegistryFile", cfg: &config.Config{}, @@ -634,13 +648,23 @@ func TestLogout(t *testing.T) { }) } -// TestLogout_DeletesCachedToken cannot be parallel because it uses t.Setenv. -func TestLogout_DeletesCachedToken(t *testing.T) { +// fullCaps is the set of capabilities a real keyring/encrypted provider exposes: +// every operation is supported, so Logout takes the list-and-delete fast path. +var fullCaps = secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true} + +// TestLogout_DeletesAllScopedSecrets cannot be parallel because it uses t.Setenv. +// +// Verifies that Logout wipes every secret the (already scope-restricted) +// provider can see — both the refresh token and the access-token cache entry +// stored under "_AT" by the token source. The original bug (#5373) was +// that the "_AT" entry survived logout and let the next login short-circuit +// through the access-token cache without a browser flow. +func TestLogout_DeletesAllScopedSecrets(t *testing.T) { tmpDir := resolvedTempDir(t) t.Setenv("XDG_CACHE_HOME", tmpDir) cfg := configWithOAuth() - cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "my-token-ref" + cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "REGISTRY_OAUTH_abc" cfg.RegistryAuth.OAuth.CachedTokenExpiry = time.Now().Add(time.Hour) ctrl := gomock.NewController(t) @@ -648,10 +672,19 @@ func TestLogout_DeletesCachedToken(t *testing.T) { mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) mockSecrets := secretsmocks.NewMockProvider(ctrl) - mockSecrets.EXPECT().DeleteSecret(gomock.Any(), "my-token-ref").Return(nil) - // Derived key fallback: DeriveSecretKey(registryURL, issuer) differs from "my-token-ref". - derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer) - mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil) + mockSecrets.EXPECT().Capabilities().Return(fullCaps) + // A scoped provider strips the "__thv_registry_" prefix from listed keys; + // the bug surfaces when the "_AT" entry is present alongside the refresh token. + mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{ + {Key: "REGISTRY_OAUTH_abc"}, + {Key: "REGISTRY_OAUTH_abc_AT"}, + {Key: "REGISTRY_OAUTH_stale"}, // left over from an earlier issuer/registry config + }, nil) + mockSecrets.EXPECT().DeleteSecrets(gomock.Any(), gomock.InAnyOrder([]string{ + "REGISTRY_OAUTH_abc", + "REGISTRY_OAUTH_abc_AT", + "REGISTRY_OAUTH_stale", + })).Return(nil) mockCfg.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error { require.NoError(t, fn(cfg)) @@ -663,46 +696,91 @@ func TestLogout_DeletesCachedToken(t *testing.T) { require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets)) } -// TestLogout_NoCachedRefSkipsDelete cannot be parallel because it uses t.Setenv. -func TestLogout_NoCachedRefSkipsDelete(t *testing.T) { +// TestLogout_NoSecretsIsNoop cannot be parallel because it uses t.Setenv. +func TestLogout_NoSecretsIsNoop(t *testing.T) { + tmpDir := resolvedTempDir(t) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + cfg := configWithOAuth() + + ctrl := gomock.NewController(t) + mockCfg := configmocks.NewMockProvider(ctrl) + mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) + + mockSecrets := secretsmocks.NewMockProvider(ctrl) + mockSecrets.EXPECT().Capabilities().Return(fullCaps) + mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, nil) + // DeleteSecrets is not expected — the list is empty, nothing to delete. + + mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(nil) + + require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets)) +} + +// TestLogout_ReadOnlyProviderSkipsCleanup cannot be parallel because it uses t.Setenv. +// +// Providers like EnvironmentProvider cannot list or delete; they also cannot +// hold cached tokens, so cleanup is a no-op rather than an error. +func TestLogout_ReadOnlyProviderSkipsCleanup(t *testing.T) { tmpDir := resolvedTempDir(t) t.Setenv("XDG_CACHE_HOME", tmpDir) cfg := configWithOAuth() - cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "" ctrl := gomock.NewController(t) mockCfg := configmocks.NewMockProvider(ctrl) mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) mockSecrets := secretsmocks.NewMockProvider(ctrl) - // No CachedRefreshTokenRef, but derived key fallback fires. - derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer) - mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil) + mockSecrets.EXPECT().Capabilities().Return(secrets.ProviderCapabilities{CanRead: true}) + // ListSecrets / DeleteSecrets are not expected. mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(nil) require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets)) } -// TestLogout_DeleteSecretError cannot be parallel because it uses t.Setenv. -func TestLogout_DeleteSecretError(t *testing.T) { +// TestLogout_ListSecretsError cannot be parallel because it uses t.Setenv. +func TestLogout_ListSecretsError(t *testing.T) { tmpDir := resolvedTempDir(t) t.Setenv("XDG_CACHE_HOME", tmpDir) cfg := configWithOAuth() - cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "token-ref" ctrl := gomock.NewController(t) mockCfg := configmocks.NewMockProvider(ctrl) mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) mockSecrets := secretsmocks.NewMockProvider(ctrl) - mockSecrets.EXPECT().DeleteSecret(gomock.Any(), "token-ref").Return(errors.New("vault locked")) + mockSecrets.EXPECT().Capabilities().Return(fullCaps) + mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, errors.New("vault locked")) + + err := Logout(context.Background(), mockCfg, mockSecrets) + require.Error(t, err) + require.Contains(t, err.Error(), "deleting cached tokens") +} + +// TestLogout_DeleteSecretsError cannot be parallel because it uses t.Setenv. +func TestLogout_DeleteSecretsError(t *testing.T) { + tmpDir := resolvedTempDir(t) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + cfg := configWithOAuth() + + ctrl := gomock.NewController(t) + mockCfg := configmocks.NewMockProvider(ctrl) + mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) + + mockSecrets := secretsmocks.NewMockProvider(ctrl) + mockSecrets.EXPECT().Capabilities().Return(fullCaps) + mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{ + {Key: "REGISTRY_OAUTH_abc"}, + }, nil) + mockSecrets.EXPECT().DeleteSecrets(gomock.Any(), gomock.Any()).Return(errors.New("vault locked")) err := Logout(context.Background(), mockCfg, mockSecrets) require.Error(t, err) - require.Contains(t, err.Error(), "deleting cached token") + require.Contains(t, err.Error(), "deleting cached tokens") } // TestLogout_UpdateConfigError cannot be parallel because it uses t.Setenv. @@ -717,9 +795,8 @@ func TestLogout_UpdateConfigError(t *testing.T) { mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil) mockSecrets := secretsmocks.NewMockProvider(ctrl) - // Derived key fallback fires since CachedRefreshTokenRef is empty. - derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer) - mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil) + mockSecrets.EXPECT().Capabilities().Return(fullCaps) + mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, nil) mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(errors.New("write failed")) diff --git a/pkg/registry/auth/oauth_token_source.go b/pkg/registry/auth/oauth_token_source.go deleted file mode 100644 index d27b3aa7b2..0000000000 --- a/pkg/registry/auth/oauth_token_source.go +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "golang.org/x/oauth2" - - "github.com/stacklok/toolhive/pkg/auth/oauth" - "github.com/stacklok/toolhive/pkg/auth/remote" - "github.com/stacklok/toolhive/pkg/config" - "github.com/stacklok/toolhive/pkg/secrets" -) - -// oauthTokenSource implements TokenSource using an OIDC browser-based flow. -type oauthTokenSource struct { - oauthCfg *config.RegistryOAuthConfig - registryURL string - secretsProvider secrets.Provider - interactive bool - mu sync.Mutex - tokenSource oauth2.TokenSource -} - -// Token returns a valid access token string, handling refresh and browser flow as needed. -func (o *oauthTokenSource) Token(ctx context.Context) (string, error) { - o.mu.Lock() - defer o.mu.Unlock() - - // Try cached token source first (auto-refreshes) - if o.tokenSource != nil { - token, err := o.tokenSource.Token() - if err == nil && token.Valid() { - return token.AccessToken, nil - } - // Token source failed or expired, try to restore or re-authenticate - o.tokenSource = nil - } - - // Try to restore from secrets manager - if err := o.tryRestoreFromCache(ctx); err == nil && o.tokenSource != nil { - token, err := o.tokenSource.Token() - if err == nil && token.Valid() { - return token.AccessToken, nil - } - o.tokenSource = nil - } - - // In non-interactive mode, return error instead of triggering browser flow - if !o.interactive { - return "", ErrRegistryAuthRequired - } - - // Trigger browser-based OAuth flow - if err := o.performOAuthFlow(ctx); err != nil { - return "", fmt.Errorf("oauth flow failed: %w", err) - } - - token, err := o.tokenSource.Token() - if err != nil { - return "", fmt.Errorf("failed to get token after oauth flow: %w", err) - } - - return token.AccessToken, nil -} - -// tryRestoreFromCache attempts to restore token source from cached refresh token. -func (o *oauthTokenSource) tryRestoreFromCache(ctx context.Context) error { - if o.secretsProvider == nil { - return fmt.Errorf("no secrets provider available") - } - - refreshTokenKey := o.refreshTokenKey() - - refreshToken, err := o.secretsProvider.GetSecret(ctx, refreshTokenKey) - if err != nil { - return fmt.Errorf("failed to get cached refresh token: %w", err) - } - if refreshToken == "" { - return fmt.Errorf("no cached refresh token found") - } - - oauth2Cfg, err := o.buildOAuth2Config(ctx) - if err != nil { - return fmt.Errorf("failed to create oauth2 config: %w", err) - } - - o.tokenSource = remote.CreateTokenSourceFromCached(oauth2Cfg, refreshToken, o.oauthCfg.CachedTokenExpiry, "") - return nil -} - -// performOAuthFlow executes the browser-based OAuth flow and persists the result. -func (o *oauthTokenSource) performOAuthFlow(ctx context.Context) error { - oauthCfg, err := o.buildOAuthFlowConfig(ctx) - if err != nil { - return fmt.Errorf("failed to create oauth config: %w", err) - } - - flow, err := oauth.NewFlow(oauthCfg) - if err != nil { - return fmt.Errorf("failed to create oauth flow: %w", err) - } - - tokenResult, err := flow.Start(ctx, false) - if err != nil { - return fmt.Errorf("oauth flow start failed: %w", err) - } - - baseTokenSource := flow.TokenSource() - - // Wrap with persisting token source if secrets provider available - if o.secretsProvider == nil { - slog.Debug("No secrets provider available, refresh token will not be persisted") - } else { - refreshTokenKey := o.refreshTokenKey() - baseTokenSource = remote.NewPersistingTokenSource( - baseTokenSource, - o.createTokenPersister(refreshTokenKey), - ) - - // Persist initial refresh token - if tokenResult.RefreshToken != "" { - if err := o.secretsProvider.SetSecret(ctx, refreshTokenKey, tokenResult.RefreshToken); err != nil { - slog.Warn("Failed to persist initial refresh token", "error", err) - } else { - slog.Debug("Persisted initial refresh token", "key", refreshTokenKey) - } - } else { - slog.Debug("OAuth provider did not return a refresh token, token will not be persisted") - } - - // Update config with token ref - o.updateConfigTokenRef(refreshTokenKey, tokenResult.Expiry) - } - - o.tokenSource = baseTokenSource - return nil -} - -// buildOAuthFlowConfig creates an oauth.Config for the browser-based flow via OIDC discovery. -// PKCE is always enabled (S256) per OAuth 2.1 requirements for public clients. -func (o *oauthTokenSource) buildOAuthFlowConfig(ctx context.Context) (*oauth.Config, error) { - callbackPort := o.oauthCfg.CallbackPort - if callbackPort == 0 { - callbackPort = remote.DefaultCallbackPort - } - - scopes := ensureOfflineAccess(o.oauthCfg.Scopes) - - return oauth.CreateOAuthConfigFromOIDC( - ctx, - o.oauthCfg.Issuer, - o.oauthCfg.ClientID, - "", // Public client — no client secret (PKCE is used instead) - scopes, - true, // Always use PKCE (S256) - callbackPort, - o.oauthCfg.Audience, - ) -} - -// ensureOfflineAccess returns scopes with "offline_access" included. -// This scope is required for the provider to return a refresh token. -func ensureOfflineAccess(scopes []string) []string { - for _, s := range scopes { - if s == "offline_access" { - return scopes - } - } - return append(scopes[:len(scopes):len(scopes)], "offline_access") -} - -// buildOAuth2Config creates an oauth2.Config for token refresh via OIDC discovery. -func (o *oauthTokenSource) buildOAuth2Config(ctx context.Context) (*oauth2.Config, error) { - oauthCfg, err := o.buildOAuthFlowConfig(ctx) - if err != nil { - return nil, err - } - - return &oauth2.Config{ - ClientID: oauthCfg.ClientID, - ClientSecret: oauthCfg.ClientSecret, - Scopes: oauthCfg.Scopes, - Endpoint: oauth2.Endpoint{ - AuthURL: oauthCfg.AuthURL, - TokenURL: oauthCfg.TokenURL, - AuthStyle: oauth2.AuthStyleInParams, - }, - }, nil -} - -// createTokenPersister returns a remote.TokenPersister function that stores -// refresh tokens in the secrets manager. -func (o *oauthTokenSource) createTokenPersister(refreshTokenKey string) remote.TokenPersister { - return func(refreshToken string, expiry time.Time) error { - // The TokenPersister signature does not accept a context, and this callback - // is invoked asynchronously during token refresh, so we use Background. - ctx := context.Background() - if err := o.secretsProvider.SetSecret(ctx, refreshTokenKey, refreshToken); err != nil { - return fmt.Errorf("failed to persist refresh token: %w", err) - } - o.updateConfigTokenRef(refreshTokenKey, expiry) - return nil - } -} - -// updateConfigTokenRef updates the config with the refresh token reference and expiry. -func (*oauthTokenSource) updateConfigTokenRef(refreshTokenKey string, expiry time.Time) { - if err := config.UpdateConfig(func(cfg *config.Config) error { - if cfg.RegistryAuth.OAuth != nil { - cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = refreshTokenKey - cfg.RegistryAuth.OAuth.CachedTokenExpiry = expiry - } - return nil - }); err != nil { - slog.Warn("Failed to update config with token reference", "error", err) - } -} - -// refreshTokenKey returns the key used to store the refresh token in the secrets manager. -// Uses the RFC-specified derivation formula if no cached reference exists. -func (o *oauthTokenSource) refreshTokenKey() string { - if o.oauthCfg.CachedRefreshTokenRef != "" { - return o.oauthCfg.CachedRefreshTokenRef - } - return DeriveSecretKey(o.registryURL, o.oauthCfg.Issuer) -} diff --git a/pkg/registry/convert.go b/pkg/registry/convert.go new file mode 100644 index 0000000000..9db9ebed16 --- /dev/null +++ b/pkg/registry/convert.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/stacklok/toolhive-core/registry/converters" + types "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry/legacyhint" +) + +// ErrAlreadyUpstream indicates the input was already in upstream MCP registry +// format, so no conversion was performed. +var ErrAlreadyUpstream = errors.New("input is already in upstream format") + +// ConvertJSON converts a legacy ToolHive registry JSON document into the +// upstream MCP registry format. ToolHive-specific fields are carried through to +// the publisher-provided extension block on each server. The output is +// validated against the upstream registry schema before being returned, so +// callers writing to disk get either a schema-compliant file or an error. +// +// Returns ErrAlreadyUpstream if the input is already in the upstream format. +func ConvertJSON(input []byte) ([]byte, error) { + if legacyhint.IsUpstream(input) { + return nil, ErrAlreadyUpstream + } + + reg := &types.Registry{} + if err := json.Unmarshal(input, reg); err != nil { + return nil, fmt.Errorf("failed to parse legacy registry data: %w", err) + } + + upstream, err := converters.NewUpstreamRegistryFromToolhiveRegistry(reg) + if err != nil { + return nil, fmt.Errorf("failed to convert to upstream format: %w", err) + } + + out, err := json.MarshalIndent(upstream, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal upstream registry: %w", err) + } + + if err := types.ValidateUpstreamRegistryBytes(out); err != nil { + return nil, fmt.Errorf("converted output does not match the upstream registry schema: %w", err) + } + return out, nil +} diff --git a/pkg/registry/convert_test.go b/pkg/registry/convert_test.go new file mode 100644 index 0000000000..6ed75215fb --- /dev/null +++ b/pkg/registry/convert_test.go @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "testing" + + v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + catalog "github.com/stacklok/toolhive-catalog/pkg/catalog/toolhive" + "github.com/stacklok/toolhive-core/registry/converters" + types "github.com/stacklok/toolhive-core/registry/types" +) + +const legacyContainerJSON = `{ + "version": "1.0.0", + "last_updated": "2026-01-15T10:00:00Z", + "servers": { + "filesystem": { + "description": "A filesystem MCP server", + "tier": "Official", + "status": "active", + "transport": "stdio", + "image": "ghcr.io/example/filesystem:v1.0.0", + "tools": ["read_file", "write_file"], + "tags": ["filesystem", "productivity"], + "metadata": { + "stars": 42, + "pulls": 1234 + } + } + } +}` + +const legacyRemoteJSON = `{ + "version": "1.0.0", + "last_updated": "2026-01-15T10:00:00Z", + "servers": {}, + "remote_servers": { + "example-api": { + "description": "A remote MCP server", + "tier": "Community", + "status": "active", + "transport": "streamable-http", + "url": "https://api.example.com/mcp", + "tools": ["query"], + "tags": ["api"] + } + } +}` + +const upstreamJSON = `{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2026-01-15T10:00:00Z"}, + "data": {"servers": []} +}` + +func TestConvertJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantAlreadyUpstr bool + wantParseErr bool + wantServers int + assertOut func(t *testing.T, out *types.UpstreamRegistry) + }{ + { + name: "container server in legacy format converts to upstream", + input: legacyContainerJSON, + wantServers: 1, + assertOut: func(t *testing.T, out *types.UpstreamRegistry) { + t.Helper() + assert.Equal(t, "1.0.0", out.Version) + assert.Equal(t, "2026-01-15T10:00:00Z", out.Meta.LastUpdated) + require.Len(t, out.Data.Servers, 1) + assert.Equal(t, "io.github.stacklok/filesystem", out.Data.Servers[0].Name) + assert.Equal(t, "A filesystem MCP server", out.Data.Servers[0].Description) + }, + }, + { + name: "remote server in legacy format converts to upstream", + input: legacyRemoteJSON, + wantServers: 1, + assertOut: func(t *testing.T, out *types.UpstreamRegistry) { + t.Helper() + require.Len(t, out.Data.Servers, 1) + assert.Equal(t, "io.github.stacklok/example-api", out.Data.Servers[0].Name) + require.Len(t, out.Data.Servers[0].Remotes, 1) + assert.Equal(t, "https://api.example.com/mcp", out.Data.Servers[0].Remotes[0].URL) + }, + }, + { + name: "upstream input returns ErrAlreadyUpstream", + input: upstreamJSON, + wantAlreadyUpstr: true, + }, + { + name: "invalid JSON returns error", + input: "not json", + wantParseErr: true, + }, + { + name: "empty input returns error", + input: "", + wantParseErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out, err := ConvertJSON([]byte(tt.input)) + + switch { + case tt.wantAlreadyUpstr: + assert.ErrorIs(t, err, ErrAlreadyUpstream) + assert.Nil(t, out) + return + case tt.wantParseErr: + assert.Error(t, err) + assert.NotErrorIs(t, err, ErrAlreadyUpstream) + assert.Nil(t, out) + return + } + + require.NoError(t, err) + require.NotNil(t, out) + + var parsed types.UpstreamRegistry + require.NoError(t, json.Unmarshal(out, &parsed)) + assert.Len(t, parsed.Data.Servers, tt.wantServers) + if tt.assertOut != nil { + tt.assertOut(t, &parsed) + } + }) + } +} + +// legacyContainerWithExtensionsJSON exercises the fields the converter must +// place under the publisher-provided extension on an upstream server entry. +// If any of these fields are dropped, "the conversion is lossless" is false. +const legacyContainerWithExtensionsJSON = `{ + "version": "1.0.0", + "last_updated": "2026-01-15T10:00:00Z", + "servers": { + "filesystem": { + "description": "A filesystem MCP server", + "tier": "Official", + "status": "active", + "transport": "stdio", + "image": "ghcr.io/example/filesystem:v1.0.0", + "tools": ["read_file", "write_file"], + "tags": ["filesystem", "productivity"], + "args": ["--root", "/data"], + "docker_tags": ["v1.0.0", "latest"], + "target_port": 8080, + "env_vars": [ + {"name": "API_KEY", "description": "auth token", "required": true, "secret": true}, + {"name": "LOG_LEVEL", "description": "log level", "default": "info"} + ], + "metadata": { + "stars": 42, + "last_updated": "2026-01-15T10:00:00Z" + }, + "permissions": { + "network": {"outbound": {"insecure_allow_all": true}} + }, + "custom_metadata": { + "vendor": "example", + "purpose": "testing" + } + } + } +}` + +// publisherExtension extracts the publisher-provided extension block for a +// single server entry. Returns the inner ServerExtensions map keyed by the +// server image/url under "io.github.stacklok". +func publisherExtension(t *testing.T, server v0.ServerJSON) map[string]any { + t.Helper() + require.NotNil(t, server.Meta, "server must carry _meta when ToolHive fields are present") + publisher := server.Meta.PublisherProvided + require.NotNil(t, publisher) + stacklok, ok := publisher[types.ToolHivePublisherNamespace].(map[string]any) + require.True(t, ok, "publisher-provided block must contain %q", types.ToolHivePublisherNamespace) + require.Len(t, stacklok, 1, "expected exactly one inner key under %q", types.ToolHivePublisherNamespace) + for _, v := range stacklok { + inner, ok := v.(map[string]any) + require.True(t, ok, "inner extension entry must be an object") + return inner + } + t.Fatal("unreachable: stacklok block was empty") + return nil +} + +// TestConvertJSON_LosslessExtensions asserts that ToolHive-specific fields on +// a legacy container server entry survive the conversion and land in the +// publisher-provided extension on the upstream server. This is the converter's +// main user-facing claim and the test that backs the "lossless" wording in the +// PR description. +func TestConvertJSON_LosslessExtensions(t *testing.T) { + t.Parallel() + + out, err := ConvertJSON([]byte(legacyContainerWithExtensionsJSON)) + require.NoError(t, err) + + var parsed types.UpstreamRegistry + require.NoError(t, json.Unmarshal(out, &parsed)) + require.Len(t, parsed.Data.Servers, 1) + server := parsed.Data.Servers[0] + + // Top-level upstream fields. + assert.Equal(t, "io.github.stacklok/filesystem", server.Name) + assert.Equal(t, "A filesystem MCP server", server.Description) + require.Len(t, server.Packages, 1) + pkg := server.Packages[0] + assert.Equal(t, "ghcr.io/example/filesystem:v1.0.0", pkg.Identifier) + assert.Equal(t, "stdio", string(pkg.Transport.Type)) + + // env_vars land on the package, not the publisher extension. + require.Len(t, pkg.EnvironmentVariables, 2) + envByName := map[string]string{} + for _, ev := range pkg.EnvironmentVariables { + envByName[ev.Name] = ev.Description + } + assert.Equal(t, "auth token", envByName["API_KEY"]) + assert.Equal(t, "log level", envByName["LOG_LEVEL"]) + + // Everything else lives under publisher-provided extensions. + ext := publisherExtension(t, server) + assert.Equal(t, "Official", ext["tier"]) + assert.Equal(t, "active", ext["status"]) + assert.ElementsMatch(t, []any{"read_file", "write_file"}, ext["tools"]) + assert.ElementsMatch(t, []any{"filesystem", "productivity"}, ext["tags"]) + assert.ElementsMatch(t, []any{"--root", "/data"}, ext["args"]) + assert.ElementsMatch(t, []any{"v1.0.0", "latest"}, ext["docker_tags"]) + + require.Contains(t, ext, "metadata") + meta, ok := ext["metadata"].(map[string]any) + require.True(t, ok, "metadata must be an object") + assert.EqualValues(t, 42, meta["stars"]) + + require.Contains(t, ext, "permissions") + perms, ok := ext["permissions"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, perms["network"], "permissions.network must round-trip") + + require.Contains(t, ext, "custom_metadata") + custom, ok := ext["custom_metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "example", custom["vendor"]) + assert.Equal(t, "testing", custom["purpose"]) +} + +// TestConvertJSON_RemoteServerExtensions asserts that remote server fields land +// where the upstream format expects them — URL on the remote transport entry, +// ToolHive-specific fields on the publisher-provided extension. +func TestConvertJSON_RemoteServerExtensions(t *testing.T) { + t.Parallel() + + const legacy = `{ + "version": "1.0.0", + "last_updated": "2026-01-15T10:00:00Z", + "remote_servers": { + "example-api": { + "description": "A remote MCP server", + "tier": "Community", + "status": "active", + "transport": "streamable-http", + "url": "https://api.example.com/mcp", + "tools": ["query"], + "tags": ["api"], + "oauth_config": { + "issuer": "https://accounts.example.com", + "client_id": "test-client", + "scopes": ["openid", "email"] + } + } + } + }` + + out, err := ConvertJSON([]byte(legacy)) + require.NoError(t, err) + + var parsed types.UpstreamRegistry + require.NoError(t, json.Unmarshal(out, &parsed)) + require.Len(t, parsed.Data.Servers, 1) + server := parsed.Data.Servers[0] + + require.Len(t, server.Remotes, 1) + assert.Equal(t, "https://api.example.com/mcp", server.Remotes[0].URL) + assert.Equal(t, "streamable-http", string(server.Remotes[0].Type)) + + ext := publisherExtension(t, server) + assert.Equal(t, "Community", ext["tier"]) + assert.ElementsMatch(t, []any{"query"}, ext["tools"]) + assert.ElementsMatch(t, []any{"api"}, ext["tags"]) + + require.Contains(t, ext, "oauth_config") + oauth, ok := ext["oauth_config"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://accounts.example.com", oauth["issuer"]) + assert.Equal(t, "test-client", oauth["client_id"]) +} + +// TestConvertJSON_OutputPassesSchemaValidation makes the schema-validation +// invariant explicit: ConvertJSON must never return bytes that fail the +// upstream registry schema. This guards the on-disk file `thv registry convert` +// produces. +func TestConvertJSON_OutputPassesSchemaValidation(t *testing.T) { + t.Parallel() + + out, err := ConvertJSON([]byte(legacyContainerWithExtensionsJSON)) + require.NoError(t, err) + assert.NoError(t, types.ValidateUpstreamRegistryBytes(out), + "converter output must conform to the upstream registry schema") +} + +// TestConvertJSON_RoundTripEmbeddedCatalog runs the embedded upstream catalog +// through the full upstream → toolhive → upstream pipeline and verifies the +// server set is preserved. The legacy types.Registry used as the intermediate +// representation maps each server by canonical name, so collisions there +// would manifest as a server count drop. +func TestConvertJSON_RoundTripEmbeddedCatalog(t *testing.T) { + t.Parallel() + + original, _, err := parseRegistryData(catalog.Upstream()) + require.NoError(t, err) + + roundTripped, err := converters.NewUpstreamRegistryFromToolhiveRegistry(original) + require.NoError(t, err) + + // Server count is preserved across the round trip. + want := len(original.Servers) + len(original.RemoteServers) + assert.Equal(t, want, len(roundTripped.Data.Servers), + "round trip must preserve every server") + + // Spot-check that descriptions survive — a regression here means the + // converter dropped a non-trivial field somewhere. + descriptions := map[string]struct{}{} + for _, s := range roundTripped.Data.Servers { + descriptions[s.Description] = struct{}{} + } + for name, srv := range original.Servers { + if srv.Description == "" { + continue + } + _, ok := descriptions[srv.Description] + assert.True(t, ok, "server %q description was lost in the round trip", name) + } +} diff --git a/pkg/registry/errors.go b/pkg/registry/errors.go index df1003c3be..c1d1eb6ef0 100644 --- a/pkg/registry/errors.go +++ b/pkg/registry/errors.go @@ -6,6 +6,8 @@ package registry import ( "errors" "fmt" + + "github.com/stacklok/toolhive/pkg/registry/legacyhint" ) // ErrServerNotFound indicates a server was not found in the registry. @@ -29,3 +31,31 @@ func (e *UnavailableError) Error() string { func (e *UnavailableError) Unwrap() error { return e.Err } + +// LegacyFormatError indicates the registry source contains data in the legacy +// ToolHive registry format instead of the upstream MCP registry format. +// API handlers translate this into a structured HTTP 502 with a +// "registry_legacy_format" code so desktop clients can surface a targeted +// recovery flow (instead of a generic error screen). +// +// URL is optional and identifies the offending source (remote URL or local +// file path) when known. The Error() message embeds legacyhint.MigrationMessage +// so CLI consumers continue to see the same actionable hint. +type LegacyFormatError struct { + URL string +} + +func (e *LegacyFormatError) Error() string { + if e.URL != "" { + return fmt.Sprintf("registry at %s: %s", e.URL, legacyhint.MigrationMessage) + } + return legacyhint.MigrationMessage +} + +// Is enables errors.Is matching against any *LegacyFormatError sentinel, +// regardless of the URL field. Existing callers using a zero-value sentinel +// (e.g. errLegacyFormat) keep matching when the returned error carries a URL. +func (*LegacyFormatError) Is(target error) bool { + _, ok := target.(*LegacyFormatError) + return ok +} diff --git a/pkg/registry/errors_test.go b/pkg/registry/errors_test.go index 71e4c77794..e1455f5531 100644 --- a/pkg/registry/errors_test.go +++ b/pkg/registry/errors_test.go @@ -66,3 +66,82 @@ func TestUnavailableError_ErrorsAs(t *testing.T) { assert.Equal(t, "https://example.com", target.URL) assert.Equal(t, inner, target.Err) } + +// TestLegacyFormatError_Error covers the two surface forms (with and without +// URL) so CLI users keep seeing the migration hint and remote callers also see +// where the legacy content came from. +func TestLegacyFormatError_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err *LegacyFormatError + expectedPrefix string + }{ + { + name: "with URL", + err: &LegacyFormatError{URL: "https://example.com/registry.json"}, + expectedPrefix: "registry at https://example.com/registry.json: ", + }, + { + name: "without URL", + err: &LegacyFormatError{}, + expectedPrefix: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + msg := tt.err.Error() + if tt.expectedPrefix != "" { + assert.Truef(t, + len(msg) > len(tt.expectedPrefix) && msg[:len(tt.expectedPrefix)] == tt.expectedPrefix, + "expected message to start with %q, got %q", tt.expectedPrefix, msg) + } + // Migration hint must always be present so CLI users get the + // actionable step regardless of whether a URL was attached. + assert.Contains(t, msg, "legacy ToolHive format") + assert.Contains(t, msg, "thv registry convert") + }) + } +} + +// TestLegacyFormatError_ErrorsIs verifies the Is method matches any +// *LegacyFormatError regardless of URL. This keeps errors.Is(err, errLegacyFormat) +// working when the returned error carries a populated URL from a remote source. +func TestLegacyFormatError_ErrorsIs(t *testing.T) { + t.Parallel() + + withURL := &LegacyFormatError{URL: "https://example.com/registry.json"} + sentinel := &LegacyFormatError{} + + assert.True(t, errors.Is(withURL, sentinel), + "errors.Is(withURL, sentinel) should match via Is method") + assert.True(t, errors.Is(sentinel, withURL), + "errors.Is(sentinel, withURL) should match via Is method") + + // Wrapped via fmt.Errorf should still match through Unwrap chain. + wrapped := fmt.Errorf("custom registry at %s is not reachable: %w", withURL.URL, withURL) + assert.True(t, errors.Is(wrapped, sentinel), + "errors.Is should follow the Unwrap chain to find a *LegacyFormatError") + + // A different error type must not match. + other := &UnavailableError{URL: "https://example.com", Err: fmt.Errorf("boom")} + assert.False(t, errors.Is(other, sentinel), + "errors.Is must not cross-match *UnavailableError as legacy") +} + +// TestLegacyFormatError_ErrorsAs verifies the typed extraction used by the +// API layer to surface a structured 502 response with the source URL. +func TestLegacyFormatError_ErrorsAs(t *testing.T) { + t.Parallel() + + original := &LegacyFormatError{URL: "https://example.com/registry.json"} + wrapped := fmt.Errorf("custom registry at %s is not reachable: %w", original.URL, original) + + var target *LegacyFormatError + require.True(t, errors.As(wrapped, &target), + "errors.As must extract *LegacyFormatError from the Unwrap chain") + assert.Equal(t, "https://example.com/registry.json", target.URL) +} diff --git a/pkg/registry/factory_test.go b/pkg/registry/factory_test.go index c42492d30f..fbbe665cf2 100644 --- a/pkg/registry/factory_test.go +++ b/pkg/registry/factory_test.go @@ -4,7 +4,6 @@ package registry import ( - "encoding/json" "os" "path/filepath" "testing" @@ -26,35 +25,34 @@ func resetGlobalState(t *testing.T) { }) } -// writeTempRegistryJSON writes a legacy-format registry JSON file to dir and -// returns its path. serverName is used as the only server key. +// writeTempRegistryJSON writes an upstream-format registry JSON file to dir and +// returns its path. serverName is used as the upstream server name. func writeTempRegistryJSON(t *testing.T, dir, serverName string) string { t.Helper() - type serverEntry struct { - Image string `json:"image"` - Description string `json:"description"` - } - type registryFile struct { - Version string `json:"version"` - LastUpdated string `json:"last_updated"` - Servers map[string]serverEntry `json:"servers"` - } - - data, err := json.Marshal(registryFile{ - Version: "1.0.0", - LastUpdated: "2025-01-01T00:00:00Z", - Servers: map[string]serverEntry{ - serverName: { - Image: "enterprise/server:latest", - Description: "Enterprise test server", - }, - }, - }) - require.NoError(t, err) + body := `{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": { + "servers": [ + { + "name": "` + serverName + `", + "description": "Enterprise test server", + "packages": [ + { + "registryType": "oci", + "identifier": "enterprise/server:latest", + "transport": {"type": "stdio"} + } + ] + } + ] + } + }` registryPath := filepath.Join(dir, "registry.json") - require.NoError(t, os.WriteFile(registryPath, data, 0600)) + require.NoError(t, os.WriteFile(registryPath, []byte(body), 0600)) return registryPath } diff --git a/pkg/registry/legacyhint/legacyhint.go b/pkg/registry/legacyhint/legacyhint.go new file mode 100644 index 0000000000..003337245c --- /dev/null +++ b/pkg/registry/legacyhint/legacyhint.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package legacyhint detects the deprecated ToolHive registry format and +// supplies a single migration message used by every parser/validator entry +// point. Lives in its own leaf package so pkg/registry and pkg/config can both +// import it without creating an import cycle (pkg/registry imports pkg/config). +package legacyhint + +import "encoding/json" + +// MigrationMessage is the user-facing text returned when a legacy ToolHive +// registry file is detected. Kept identical across the runtime parser, +// set-registry-file, set-registry-url, and remote provider validation paths. +const MigrationMessage = "registry file appears to be in the legacy ToolHive format; " + + "run `thv registry convert --in --in-place` to migrate to the upstream MCP format" + +// Looks reports whether the JSON document has top-level "servers", +// "remote_servers", or "groups" — the markers of the legacy ToolHive registry +// layout. The upstream format wraps these under a top-level "data" object, so a +// match here means the input is legacy (or close enough that emitting the +// migration hint is more useful than a generic decode error). +// +// Used to short-circuit with a migration hint instead of the misleading +// "no servers" error that Go's JSON decoder produces when legacy fields are +// silently dropped during unmarshal into UpstreamRegistry. +func Looks(data []byte) bool { + var probe struct { + Servers json.RawMessage `json:"servers"` + RemoteServers json.RawMessage `json:"remote_servers"` + Groups json.RawMessage `json:"groups"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + return len(probe.Servers) > 0 || len(probe.RemoteServers) > 0 || len(probe.Groups) > 0 +} + +// IsUpstream reports whether the JSON document appears to use the upstream +// registry format. The discriminator is a top-level "data" object — only the +// upstream format wraps servers inside it. The "$schema" key alone is not +// sufficient because the legacy format also includes one. +func IsUpstream(data []byte) bool { + var probe struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + return len(probe.Data) > 0 && probe.Data[0] == '{' +} diff --git a/pkg/registry/legacyhint/legacyhint_test.go b/pkg/registry/legacyhint/legacyhint_test.go new file mode 100644 index 0000000000..7a1f31476b --- /dev/null +++ b/pkg/registry/legacyhint/legacyhint_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package legacyhint + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const legacyJSON = `{ + "version": "1.0.0", + "servers": {"example": {"image": "example/srv:latest"}} +}` + +const upstreamJSON = `{ + "version": "1.0.0", + "data": {"servers": []} +}` + +func TestIsUpstream(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want bool + }{ + {name: "upstream format with data wrapper", in: upstreamJSON, want: true}, + {name: "legacy format without data wrapper", in: legacyJSON, want: false}, + {name: "empty input", in: "", want: false}, + {name: "invalid JSON", in: "not json", want: false}, + {name: "data field is array, not object", in: `{"data": []}`, want: false}, + {name: "data field is null", in: `{"data": null}`, want: false}, + {name: "no data field", in: `{"version": "1.0"}`, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsUpstream([]byte(tt.in))) + }) + } +} + +func TestLooks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want bool + }{ + {name: "top-level servers", in: legacyJSON, want: true}, + {name: "top-level remote_servers", in: `{"remote_servers": {"r": {}}}`, want: true}, + {name: "top-level groups", in: `{"groups": [{"name": "g"}]}`, want: true}, + {name: "upstream wraps under data", in: upstreamJSON, want: false}, + {name: "empty object", in: `{}`, want: false}, + {name: "malformed JSON returns false", in: "not json", want: false}, + {name: "empty input returns false", in: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, Looks([]byte(tt.in))) + }) + } +} diff --git a/pkg/registry/provider_local.go b/pkg/registry/provider_local.go index 9693506f77..734292f595 100644 --- a/pkg/registry/provider_local.go +++ b/pkg/registry/provider_local.go @@ -4,9 +4,7 @@ package registry import ( - "encoding/json" "fmt" - "log/slog" "os" "strings" "sync" @@ -43,38 +41,23 @@ func NewLocalRegistryProvider(filePath ...string) *LocalRegistryProvider { // GetRegistry returns the registry data from file path or embedded data func (p *LocalRegistryProvider) GetRegistry() (*types.Registry, error) { - var registry *types.Registry - + var data []byte if p.filePath != "" { - // Read from local file — auto-detect format - data, err := os.ReadFile(p.filePath) + fileData, err := os.ReadFile(p.filePath) if err != nil { return nil, fmt.Errorf("failed to read local registry file %s: %w", p.filePath, err) } - - var skills []types.Skill - var isLegacy bool - registry, skills, isLegacy, err = parseRegistryAutoDetect(data) - if err != nil { - return nil, err - } - p.setSkills(skills) - if isLegacy { - slog.Warn("Registry file uses legacy format; please migrate to the upstream MCP format. "+ - "Legacy format support will be removed in a future release.", - "file", p.filePath) - } + data = fileData } else { - // Embedded catalog — always upstream format - var err error - var skills []types.Skill - registry, skills, err = parseUpstreamRegistry(catalog.Upstream()) - if err != nil { - return nil, fmt.Errorf("failed to parse embedded upstream registry: %w", err) - } - p.setSkills(skills) + data = catalog.Upstream() } + registry, skills, err := parseRegistryData(data) + if err != nil { + return nil, err + } + p.setSkills(skills) + // Set name field on each server based on map key for name, server := range registry.Servers { server.Name = name @@ -156,12 +139,3 @@ func (p *LocalRegistryProvider) SearchSkills(query string) ([]types.Skill, error } return results, nil } - -// parseRegistryData parses JSON data into a Registry struct -func parseRegistryData(data []byte) (*types.Registry, error) { - registry := &types.Registry{} - if err := json.Unmarshal(data, registry); err != nil { - return nil, fmt.Errorf("failed to parse registry data: %w", err) - } - return registry, nil -} diff --git a/pkg/registry/provider_remote.go b/pkg/registry/provider_remote.go index 30688bdbf8..beedf0837b 100644 --- a/pkg/registry/provider_remote.go +++ b/pkg/registry/provider_remote.go @@ -15,6 +15,7 @@ import ( types "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/registry/legacyhint" ) // RemoteRegistryProvider provides registry data from a remote HTTP endpoint @@ -80,29 +81,17 @@ func (p *RemoteRegistryProvider) validateConnectivity() error { return fmt.Errorf("failed to read registry response: %w", err) } - // Try upstream format first, fall back to legacy - if isUpstreamFormat(data) { - var upstream types.UpstreamRegistry - if err := json.Unmarshal(data, &upstream); err != nil { - return fmt.Errorf("registry returned invalid upstream JSON from %s: %w", p.registryURL, err) - } - if len(upstream.Data.Servers) == 0 && len(upstream.Data.Groups) == 0 { - return fmt.Errorf("registry at %s returned upstream format with no servers or groups", p.registryURL) - } - return nil + if legacyhint.Looks(data) { + return &LegacyFormatError{URL: p.registryURL} } - registry := &types.Registry{} - if err := json.Unmarshal(data, registry); err != nil { - return fmt.Errorf("registry returned invalid JSON from %s: %w", p.registryURL, err) + var upstream types.UpstreamRegistry + if err := json.Unmarshal(data, &upstream); err != nil { + return fmt.Errorf("registry returned invalid upstream JSON from %s: %w", p.registryURL, err) } - - // Validate the registry has at least the required structure - if registry.Servers == nil && registry.RemoteServers == nil && registry.Groups == nil { - return fmt.Errorf("registry at %s returned invalid structure: "+ - "missing servers, remote_servers, and groups fields", p.registryURL) + if len(upstream.Data.Servers) == 0 && len(upstream.Data.Skills) == 0 { + return fmt.Errorf("registry at %s returned upstream format with no servers or skills", p.registryURL) } - return nil } @@ -140,16 +129,11 @@ func (p *RemoteRegistryProvider) GetRegistry() (*types.Registry, error) { return nil, fmt.Errorf("failed to read registry data from response body: %w", err) } - registry, skills, isLegacy, err := parseRegistryAutoDetect(data) + registry, skills, err := parseRegistryData(data) if err != nil { return nil, fmt.Errorf("failed to parse registry data from %s: %w", p.registryURL, err) } p.setSkills(skills) - if isLegacy { - slog.Warn("Remote registry uses legacy format; please migrate to the upstream MCP format. "+ - "Legacy format support will be removed in a future release.", - "url", p.registryURL) - } // Set name field on each server based on map key for name, server := range registry.Servers { diff --git a/pkg/registry/provider_test.go b/pkg/registry/provider_test.go index 3911b879b7..df5e9bebce 100644 --- a/pkg/registry/provider_test.go +++ b/pkg/registry/provider_test.go @@ -177,6 +177,27 @@ func TestRemoteRegistryProvider_CreationError(t *testing.T) { func TestRemoteRegistryProvider_ValidateConnectivity(t *testing.T) { t.Parallel() + const upstreamWithServer = `{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": { + "servers": [ + { + "name": "io.example.test-server", + "description": "Test server", + "packages": [ + { + "registryType": "oci", + "identifier": "example/test-server:latest", + "transport": {"type": "stdio"} + } + ] + } + ] + } + }` + tests := []struct { name string responseBody string @@ -185,17 +206,8 @@ func TestRemoteRegistryProvider_ValidateConnectivity(t *testing.T) { errorContains string }{ { - name: "valid registry JSON", - responseBody: `{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "servers": { - "test-server": { - "image": "test/image:latest", - "description": "Test server" - } - } - }`, + name: "valid upstream registry", + responseBody: upstreamWithServer, responseStatus: 200, expectError: false, }, @@ -204,44 +216,21 @@ func TestRemoteRegistryProvider_ValidateConnectivity(t *testing.T) { responseBody: `{"not valid json`, responseStatus: 200, expectError: true, - errorContains: "invalid JSON", + errorContains: "invalid upstream JSON", }, { - name: "valid JSON but not registry structure", - responseBody: `{"some": "other", "data": "here"}`, + name: "upstream format with empty servers and skills", + responseBody: `{"$schema": "x", "version": "1.0", "meta": {}, "data": {"servers": []}}`, responseStatus: 200, expectError: true, - errorContains: "invalid structure", - }, - { - name: "registry with only remote servers", - responseBody: `{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "remote_servers": { - "test-remote": { - "url": "https://example.com", - "description": "Test remote server" - } - } - }`, - responseStatus: 200, - expectError: false, + errorContains: "no servers or skills", }, { - name: "registry with groups", - responseBody: `{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "groups": [ - { - "name": "test-group", - "servers": {} - } - ] - }`, + name: "legacy format surfaces migration hint", + responseBody: `{"version": "1.0.0", "servers": {"x": {"image": "x:latest"}}}`, responseStatus: 200, - expectError: false, + expectError: true, + errorContains: "legacy ToolHive format", }, { name: "non-200 status code", @@ -277,72 +266,6 @@ func TestRemoteRegistryProvider_ValidateConnectivity(t *testing.T) { } } -func TestLocalRegistryProviderWithLocalFile(t *testing.T) { - t.Parallel() - - // Create a temporary registry file - tempDir := t.TempDir() - registryFile := filepath.Join(tempDir, "test_registry.json") - - // Write test registry data - testRegistry := `{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "servers": { - "test-server": { - "image": "test/image:latest", - "description": "Test server", - "tier": "community", - "status": "active", - "transport": "stdio", - "permissions": { - "allow_local_resource_access": false, - "allow_internet_access": false - }, - "tools": ["test-tool"], - "env_vars": [], - "args": [] - } - } - }` - - err := os.WriteFile(registryFile, []byte(testRegistry), 0644) - if err != nil { - t.Fatalf("Failed to write test registry file: %v", err) - } - - // Test with local file path - provider := NewLocalRegistryProvider(registryFile) - - // Test GetRegistry - registry, err := provider.GetRegistry() - if err != nil { - t.Fatalf("GetRegistry() error = %v", err) - } - - if registry == nil { - t.Fatal("GetRegistry() returned nil registry") - return - } - - if len(registry.Servers) != 1 { - t.Errorf("Expected 1 server, got %d", len(registry.Servers)) - } - - server, exists := registry.Servers["test-server"] - if !exists { - t.Error("Expected test-server to exist in registry") - } - - if server.Name != "test-server" { - t.Errorf("Expected server name 'test-server', got '%s'", server.Name) - } - - if server.Image != "test/image:latest" { - t.Errorf("Expected image 'test/image:latest', got '%s'", server.Image) - } -} - func TestLocalRegistryProviderWithUpstreamFormatFile(t *testing.T) { t.Parallel() @@ -387,39 +310,6 @@ func TestLocalRegistryProviderWithUpstreamFormatFile(t *testing.T) { assert.NotEmpty(t, registry.Servers, "Should have at least one container server") } -func TestLocalRegistryProviderLegacyFormatFallback(t *testing.T) { - t.Parallel() - - // Create a legacy-format registry file - tempDir := t.TempDir() - registryFile := filepath.Join(tempDir, "legacy_registry.json") - - testRegistry := `{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "servers": { - "test-server": { - "image": "test/image:latest", - "description": "Test server" - } - } - }` - - err := os.WriteFile(registryFile, []byte(testRegistry), 0644) - require.NoError(t, err) - - provider := NewLocalRegistryProvider(registryFile) - - registry, err := provider.GetRegistry() - require.NoError(t, err) - require.NotNil(t, registry) - - assert.Len(t, registry.Servers, 1) - server, exists := registry.Servers["test-server"] - assert.True(t, exists) - assert.Equal(t, "test/image:latest", server.Image) -} - func TestRemoteRegistryProvider_UpstreamFormat(t *testing.T) { t.Parallel() @@ -742,68 +632,38 @@ func TestListServers(t *testing.T) { } } -func TestParseRegistryData(t *testing.T) { +func TestLocalRegistryProvider_FileReadError(t *testing.T) { t.Parallel() - tests := []struct { - name string - data []byte - expectError bool - }{ - { - name: "valid registry data", - data: []byte(`{ - "version": "1.0.0", - "last_updated": "2023-01-01T00:00:00Z", - "servers": { - "test-server": { - "image": "test/image:latest", - "description": "Test server" - } - } - }`), - expectError: false, - }, - { - name: "invalid JSON", - data: []byte(`invalid json`), - expectError: true, - }, - { - name: "empty data", - data: []byte(``), - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + // Test with non-existent file path + provider := NewLocalRegistryProvider("/non/existent/path/registry.json") - registry, err := parseRegistryData(tt.data) + registry, err := provider.GetRegistry() - if tt.expectError { - assert.Error(t, err) - assert.Nil(t, registry) - } else { - assert.NoError(t, err) - assert.NotNil(t, registry) - } - }) - } + assert.Error(t, err) + assert.Nil(t, registry) + assert.Contains(t, err.Error(), "failed to read local registry file") } -func TestLocalRegistryProvider_FileReadError(t *testing.T) { +// TestLocalRegistryProvider_LegacyFileReturnsMigrationHint covers the upgrade +// scenario: a user with a legacy --local-registry-path on disk should get a +// clear migration hint, not an empty registry. +func TestLocalRegistryProvider_LegacyFileReturnsMigrationHint(t *testing.T) { t.Parallel() - // Test with non-existent file path - provider := NewLocalRegistryProvider("/non/existent/path/registry.json") + dir := t.TempDir() + path := filepath.Join(dir, "registry.json") + require.NoError(t, os.WriteFile(path, []byte(`{ + "version": "1.0.0", + "servers": {"test": {"image": "test:latest"}} + }`), 0o600)) + provider := NewLocalRegistryProvider(path) registry, err := provider.GetRegistry() - assert.Error(t, err) + require.Error(t, err) assert.Nil(t, registry) - assert.Contains(t, err.Error(), "failed to read local registry file") + assert.ErrorIs(t, err, errLegacyFormat) } // createTestServer creates a test HTTP server that returns the specified response diff --git a/pkg/registry/schema_validation_test.go b/pkg/registry/schema_validation_test.go index 119b1d4aef..c6e7b8376e 100644 --- a/pkg/registry/schema_validation_test.go +++ b/pkg/registry/schema_validation_test.go @@ -27,7 +27,7 @@ func TestEmbeddedRegistrySchemaValidation(t *testing.T) { func TestValidateEmbeddedRegistryCanLoadData(t *testing.T) { t.Parallel() - registry, skills, err := parseUpstreamRegistry(catalog.Upstream()) + registry, skills, err := parseRegistryData(catalog.Upstream()) require.NoError(t, err, "Embedded upstream registry should parse successfully") // Verify basic structure @@ -40,123 +40,110 @@ func TestValidateEmbeddedRegistryCanLoadData(t *testing.T) { _ = skills } -func TestIsUpstreamFormat(t *testing.T) { +// TestUpstreamRegistryParsing verifies that parseRegistryData correctly converts +// the embedded upstream catalog data. +func TestUpstreamRegistryParsing(t *testing.T) { + t.Parallel() + + registry, _, err := parseRegistryData(catalog.Upstream()) + require.NoError(t, err) + + // Verify servers have names set (from conversion) + for _, server := range registry.Servers { + assert.NotEmpty(t, server.Name, "Server should have a name") + assert.NotEmpty(t, server.Image, "Container server should have an image") + } + for _, server := range registry.RemoteServers { + assert.NotEmpty(t, server.Name, "Remote server should have a name") + } +} + +// TestParseRegistryData_LegacyFormatDetection verifies that legacy ToolHive +// registry files are rejected with errLegacyFormat instead of silently +// producing an empty UpstreamRegistry. Without this check, Go's JSON decoder +// drops the legacy top-level "servers"/"remote_servers"/"groups" fields and +// the caller ends up with an empty registry and no actionable error. +func TestParseRegistryData_LegacyFormatDetection(t *testing.T) { t.Parallel() tests := []struct { - name string - input string - expected bool + name string + input string + wantLegacy bool }{ { - name: "upstream format with data object", - input: `{"$schema": "https://example.com/schema.json", "data": {"servers": []}}`, - expected: true, - }, - { - name: "upstream format data only, no schema", - input: `{"data": {"servers": []}}`, - expected: true, - }, - { - name: "legacy format with schema but no data object", - input: `{"$schema": "https://example.com/legacy.json", "version": "1.0", "servers": {}}`, - expected: false, - }, - { - name: "legacy format no schema", - input: `{"version": "1.0", "servers": {"osv": {}}}`, - expected: false, + name: "legacy with top-level servers is rejected", + input: `{ + "version": "1.0.0", + "servers": {"test": {"image": "test:latest"}} + }`, + wantLegacy: true, }, { - name: "data is a string not object", - input: `{"data": "not an object"}`, - expected: false, + name: "legacy with top-level remote_servers is rejected", + input: `{ + "version": "1.0.0", + "remote_servers": {"test": {"url": "https://example.com"}} + }`, + wantLegacy: true, }, { - name: "data is an array not object", - input: `{"data": [1, 2, 3]}`, - expected: false, + name: "legacy with top-level groups is rejected", + input: `{ + "version": "1.0.0", + "groups": [{"name": "g", "servers": {}}] + }`, + wantLegacy: true, }, { - name: "data is null", - input: `{"data": null}`, - expected: false, + name: "legacy with $schema header is still detected", + input: `{ + "$schema": "https://example.com/legacy.json", + "version": "1.0.0", + "servers": {"test": {"image": "test:latest"}} + }`, + wantLegacy: true, }, { - name: "empty JSON object", - input: `{}`, - expected: false, + name: "upstream format passes through", + input: `{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": {"servers": []} + }`, + wantLegacy: false, }, { - name: "invalid JSON", - input: `not json`, - expected: false, + name: "empty object is not classified as legacy", + input: `{}`, + wantLegacy: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.expected, isUpstreamFormat([]byte(tt.input))) + _, _, err := parseRegistryData([]byte(tt.input)) + if tt.wantLegacy { + require.Error(t, err) + assert.ErrorIs(t, err, errLegacyFormat) + assert.Contains(t, err.Error(), "thv registry convert") + return + } + assert.NotErrorIs(t, err, errLegacyFormat) }) } } -func TestParseRegistryAutoDetect(t *testing.T) { - t.Parallel() - - t.Run("upstream format returns isLegacy=false", func(t *testing.T) { - t.Parallel() - input := `{ - "$schema": "https://example.com/schema.json", - "version": "1.0.0", - "meta": {"last_updated": "2025-01-01T00:00:00Z"}, - "data": {"servers": []} - }` - _, _, isLegacy, err := parseRegistryAutoDetect([]byte(input)) - require.NoError(t, err) - assert.False(t, isLegacy) - }) - - t.Run("legacy format returns isLegacy=true", func(t *testing.T) { - t.Parallel() - input := `{ - "version": "1.0.0", - "servers": {"test": {"image": "test:latest"}} - }` - _, _, isLegacy, err := parseRegistryAutoDetect([]byte(input)) - require.NoError(t, err) - assert.True(t, isLegacy) - }) - - t.Run("legacy format with schema returns isLegacy=true", func(t *testing.T) { - t.Parallel() - input := `{ - "$schema": "https://example.com/legacy.json", - "version": "1.0.0", - "servers": {"test": {"image": "test:latest"}} - }` - _, _, isLegacy, err := parseRegistryAutoDetect([]byte(input)) - require.NoError(t, err) - assert.True(t, isLegacy) - }) -} - -// TestUpstreamRegistryParsing verifies that parseUpstreamRegistry correctly converts -// the embedded upstream catalog data. -func TestUpstreamRegistryParsing(t *testing.T) { +// TestParseRegistryData_MalformedJSON ensures input that's neither upstream +// nor legacy and isn't valid JSON returns the unmarshal error path rather +// than a misleading legacy-format hint. +func TestParseRegistryData_MalformedJSON(t *testing.T) { t.Parallel() - registry, _, err := parseUpstreamRegistry(catalog.Upstream()) - require.NoError(t, err) - - // Verify servers have names set (from conversion) - for _, server := range registry.Servers { - assert.NotEmpty(t, server.Name, "Server should have a name") - assert.NotEmpty(t, server.Image, "Container server should have an image") - } - for _, server := range registry.RemoteServers { - assert.NotEmpty(t, server.Name, "Remote server should have a name") - } + _, _, err := parseRegistryData([]byte("not json")) + require.Error(t, err) + assert.NotErrorIs(t, err, errLegacyFormat) + assert.Contains(t, err.Error(), "failed to parse registry data") } diff --git a/pkg/registry/service_test.go b/pkg/registry/service_test.go index 910a963d70..d972f6fb0f 100644 --- a/pkg/registry/service_test.go +++ b/pkg/registry/service_test.go @@ -36,13 +36,10 @@ func TestConfigurator_SetRegistryFromInput(t *testing.T) { t.Helper() tmpFile := filepath.Join(t.TempDir(), "test-registry.json") content := []byte(`{ + "$schema": "https://example.com/schema.json", "version": "0.1", - "servers": { - "test": { - "command": ["test"], - "args": [] - } - } + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": {"servers": [{"name": "io.example.test"}]} }`) require.NoError(t, os.WriteFile(tmpFile, content, 0600)) return tmpFile @@ -115,13 +112,10 @@ func TestConfigurator_UnsetRegistry(t *testing.T) { // Create a valid registry file content := []byte(`{ + "$schema": "https://example.com/schema.json", "version": "0.1", - "servers": { - "test": { - "command": ["test"], - "args": [] - } - } + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": {"servers": [{"name": "io.example.test"}]} }`) require.NoError(t, os.WriteFile(tmpFile, content, 0600)) @@ -168,13 +162,10 @@ func TestConfigurator_GetRegistryInfo(t *testing.T) { t.Helper() tmpFile := filepath.Join(t.TempDir(), "test-registry.json") content := []byte(`{ + "$schema": "https://example.com/schema.json", "version": "0.1", - "servers": { - "test": { - "command": ["test"], - "args": [] - } - } + "meta": {"last_updated": "2025-01-01T00:00:00Z"}, + "data": {"servers": [{"name": "io.example.test"}]} }`) require.NoError(t, os.WriteFile(tmpFile, content, 0600)) _, err := service.SetRegistryFromInput(tmpFile, false) diff --git a/pkg/registry/upstream_parser.go b/pkg/registry/upstream_parser.go index d8d41ede64..c48d8afe22 100644 --- a/pkg/registry/upstream_parser.go +++ b/pkg/registry/upstream_parser.go @@ -10,14 +10,34 @@ import ( v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" types "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry/legacyhint" ) -// parseUpstreamRegistry parses raw JSON in the upstream registry format and -// converts it into a legacy types.Registry plus any embedded skills. -func parseUpstreamRegistry(data []byte) (*types.Registry, []types.Skill, error) { +// errLegacyFormat is returned when the input looks like the legacy ToolHive +// registry format. Without this check, Go's JSON decoder silently produces an +// empty UpstreamRegistry (the legacy top-level "servers" field does not match +// upstream's "data.servers" path), leaving the caller with an empty registry +// and no actionable error. +// +// It's an instance of *LegacyFormatError so the API layer can detect it via +// errors.As and emit a structured "registry_legacy_format" response. The Is +// method on LegacyFormatError keeps errors.Is(err, errLegacyFormat) working +// even when the returned error carries a populated URL. +var errLegacyFormat = &LegacyFormatError{} + +// parseRegistryData parses raw JSON in the upstream MCP registry format and +// converts it into the internal types.Registry plus any embedded skills. +// +// Returns errLegacyFormat if the input looks like the legacy ToolHive registry +// format. +func parseRegistryData(data []byte) (*types.Registry, []types.Skill, error) { + if !legacyhint.IsUpstream(data) && legacyhint.Looks(data) { + return nil, nil, errLegacyFormat + } + var upstream types.UpstreamRegistry if err := json.Unmarshal(data, &upstream); err != nil { - return nil, nil, fmt.Errorf("failed to parse upstream registry data: %w", err) + return nil, nil, fmt.Errorf("failed to parse registry data: %w", err) } // ConvertServersToMetadata expects []*v0.ServerJSON, but UpstreamData.Servers @@ -29,10 +49,9 @@ func parseUpstreamRegistry(data []byte) (*types.Registry, []types.Skill, error) serverMetadata, err := ConvertServersToMetadata(serverPtrs) if err != nil { - return nil, nil, fmt.Errorf("failed to convert upstream servers to metadata: %w", err) + return nil, nil, fmt.Errorf("failed to convert servers to metadata: %w", err) } - // Build the legacy Registry, separating container and remote servers. registry := &types.Registry{ Version: upstream.Version, LastUpdated: upstream.Meta.LastUpdated, @@ -55,47 +74,3 @@ func parseUpstreamRegistry(data []byte) (*types.Registry, []types.Skill, error) return registry, upstream.Data.Skills, nil } - -// upstreamFormatProbe is a minimal struct used to detect whether JSON data is -// in the upstream registry format without fully unmarshalling it. -type upstreamFormatProbe struct { - Schema string `json:"$schema"` - Data json.RawMessage `json:"data"` -} - -// isUpstreamFormat returns true when the raw JSON appears to be in the upstream -// registry format. The key discriminator is the "data" wrapper object — only -// the upstream format wraps servers inside a "data" object. The "$schema" key -// alone is not sufficient because the legacy format also includes one. -// NOTE: keep in sync with isUpstreamRegistryFormat in pkg/config/registry.go -// (duplicated to avoid a circular import). -func isUpstreamFormat(data []byte) bool { - var probe upstreamFormatProbe - if err := json.Unmarshal(data, &probe); err != nil { - return false - } - // The "data" wrapper object is unique to the upstream format. - return len(probe.Data) > 0 && probe.Data[0] == '{' -} - -// parseRegistryAutoDetect attempts to parse the given JSON data by first -// checking whether it uses the upstream format. If so it delegates to -// parseUpstreamRegistry; otherwise it falls back to the legacy parser -// (parseRegistryData). The returned isLegacy flag indicates which path was -// taken. Skills are only returned for the upstream format. -func parseRegistryAutoDetect(data []byte) (*types.Registry, []types.Skill, bool, error) { - if isUpstreamFormat(data) { - reg, skills, err := parseUpstreamRegistry(data) - if err != nil { - return nil, nil, false, fmt.Errorf("upstream format detected but parsing failed: %w", err) - } - return reg, skills, false, nil - } - - // Legacy format — no skills. - reg, err := parseRegistryData(data) - if err != nil { - return nil, nil, true, fmt.Errorf("failed to parse legacy registry data: %w", err) - } - return reg, nil, true, nil -} diff --git a/pkg/runner/config.go b/pkg/runner/config.go index eaeb3a4877..610370d919 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -18,7 +18,6 @@ import ( "github.com/stacklok/toolhive/pkg/auth/awssts" "github.com/stacklok/toolhive/pkg/auth/remote" authsecrets "github.com/stacklok/toolhive/pkg/auth/secrets" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" "github.com/stacklok/toolhive/pkg/auth/upstreamswap" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authz" @@ -29,6 +28,7 @@ import ( "github.com/stacklok/toolhive/pkg/ignore" "github.com/stacklok/toolhive/pkg/labels" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/state" "github.com/stacklok/toolhive/pkg/telemetry" @@ -49,6 +49,12 @@ type RunConfig struct { // SchemaVersion is the version of the RunConfig schema SchemaVersion string `json:"schema_version" yaml:"schema_version"` + // MCPServerGeneration is the K8s .metadata.generation of the MCPServer CR that rendered + // this RunConfig. The Kubernetes runtime uses it as a monotonic version to prevent stale + // rolling-update pods from overwriting a newer RunConfig's StatefulSet apply. Zero value + // means unversioned (backward-compat with older operators, or non-operator callers). + MCPServerGeneration int64 `json:"mcpserver_generation,omitempty" yaml:"mcpserver_generation,omitempty"` + // Image is the Docker image to run Image string `json:"image" yaml:"image"` @@ -197,6 +203,14 @@ type RunConfig struct { // Applies to both remote URLs and local container workloads. Stateless bool `json:"stateless,omitempty" yaml:"stateless,omitempty"` + // SessionTTL is the inactivity timeout for proxy sessions, expressed as a Go + // duration string (e.g. "30m", "2h", "168h"). Empty uses the transport + // default (2h). Negative durations and values that fail time.ParseDuration + // are rejected at runtime. + // String (not time.Duration) keeps the wire format unit-explicit: a + // time.Duration field serializes as nanoseconds in JSON. + SessionTTL string `json:"session_ttl,omitempty" yaml:"session_ttl,omitempty" example:"2h"` + // ProxyMode is the effective HTTP protocol the proxy uses. // For stdio transports, this is the configured mode (sse or streamable-http). // For direct transports (sse/streamable-http), this matches the transport type. diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index 13113f4a35..97d9ebd5b5 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -14,6 +14,7 @@ import ( "path/filepath" "slices" "strings" + "time" "github.com/stacklok/toolhive-core/permissions" regtypes "github.com/stacklok/toolhive-core/registry/types" @@ -22,7 +23,6 @@ import ( "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" "github.com/stacklok/toolhive/pkg/auth/remote" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authz" @@ -32,6 +32,7 @@ import ( "github.com/stacklok/toolhive/pkg/ignore" "github.com/stacklok/toolhive/pkg/labels" "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport" @@ -88,6 +89,14 @@ func WithImage(image string) RunConfigBuilderOption { } } +// WithMCPServerGeneration sets the MCPServer generation as the monotonic version stamp. +func WithMCPServerGeneration(gen int64) RunConfigBuilderOption { + return func(b *runConfigBuilder) error { + b.config.MCPServerGeneration = gen + return nil + } +} + // WithRuntimeConfig sets the runtime configuration (base images and packages) func WithRuntimeConfig(runtimeConfig *templates.RuntimeConfig) RunConfigBuilderOption { return func(b *runConfigBuilder) error { @@ -172,14 +181,6 @@ func WithName(name string) RunConfigBuilderOption { } } -// WithMiddlewareConfig sets the middleware configuration -func WithMiddlewareConfig(middlewareConfig []types.MiddlewareConfig) RunConfigBuilderOption { - return func(b *runConfigBuilder) error { - b.config.MiddlewareConfigs = middlewareConfig - return nil - } -} - // WithCmdArgs sets the command arguments func WithCmdArgs(args []string) RunConfigBuilderOption { return func(b *runConfigBuilder) error { @@ -354,6 +355,27 @@ func WithEndpointPrefix(prefix string) RunConfigBuilderOption { } } +// WithSessionTTL sets the inactivity timeout for proxy sessions. +// Zero is valid and means "use the transport default" (2h). +// Negative values return an error. +// +// The value is stored as a Go duration string on RunConfig so it survives a +// JSON/YAML round-trip in the runconfig API contract; a time.Duration field +// would serialize as nanoseconds. +func WithSessionTTL(ttl time.Duration) RunConfigBuilderOption { + return func(b *runConfigBuilder) error { + if ttl < 0 { + return fmt.Errorf("session-ttl must be non-negative, got %s", ttl) + } + if ttl == 0 { + b.config.SessionTTL = "" + return nil + } + b.config.SessionTTL = ttl.String() + return nil + } +} + // WithNetworkMode sets the network mode for the container. // The network mode will be applied to the permission profile after it is loaded. func WithNetworkMode(networkMode string) RunConfigBuilderOption { diff --git a/pkg/runner/config_builder_test.go b/pkg/runner/config_builder_test.go index af885a219f..5d7aabcc36 100644 --- a/pkg/runner/config_builder_test.go +++ b/pkg/runner/config_builder_test.go @@ -17,12 +17,12 @@ import ( "github.com/stacklok/toolhive-core/permissions" regtypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authserver/server/registration" appconfig "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/webhook" ) @@ -1489,6 +1489,57 @@ func TestWithRegistryServerName(t *testing.T) { } } +func TestWithSessionTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ttl time.Duration + expectErr bool + expectedTTL string + }{ + { + name: "zero is accepted and serialized as empty (use transport default)", + ttl: 0, + expectErr: false, + expectedTTL: "", + }, + { + name: "positive duration is stored as Go duration string", + ttl: 45 * time.Minute, + expectErr: false, + expectedTTL: "45m0s", + }, + { + name: "large positive duration is stored as Go duration string", + ttl: 24 * time.Hour, + expectErr: false, + expectedTTL: "24h0m0s", + }, + { + name: "negative duration returns an error", + ttl: -1 * time.Second, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + builder := &runConfigBuilder{config: NewRunConfig()} + err := WithSessionTTL(tt.ttl)(builder) + + if tt.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedTTL, builder.config.SessionTTL) + }) + } +} + func TestResolveRegistryServerName(t *testing.T) { t.Parallel() diff --git a/pkg/runner/config_test.go b/pkg/runner/config_test.go index bc67906f42..ad66252c9a 100644 --- a/pkg/runner/config_test.go +++ b/pkg/runner/config_test.go @@ -2532,3 +2532,52 @@ func TestRunConfig_SessionRedis(t *testing.T) { assert.Equal(t, "redis:6379", got.ScalingConfig.SessionRedis.Address) }) } + +func TestRunConfig_MCPServerGenerationJSONRoundTrip(t *testing.T) { + t.Parallel() + + t.Run("preserves non-zero value", func(t *testing.T) { + t.Parallel() + cfg := NewRunConfig() + cfg.Name = "generation-server" + cfg.MCPServerGeneration = 42 + + var buf bytes.Buffer + require.NoError(t, cfg.WriteJSON(&buf)) + + got, err := ReadJSON(&buf) + require.NoError(t, err) + assert.Equal(t, int64(42), got.MCPServerGeneration, + "MCPServerGeneration not preserved: got %d, want 42", got.MCPServerGeneration) + }) + + t.Run("missing field decodes as zero", func(t *testing.T) { + t.Parallel() + minimalJSON := `{"schema_version":"v0.1.0","image":"img","name":"n","transport":"stdio","host":"127.0.0.1","port":8080,"permission_profile":null}` //nolint:lll + + got, err := ReadJSON(strings.NewReader(minimalJSON)) + require.NoError(t, err) + assert.Equal(t, int64(0), got.MCPServerGeneration, + "MCPServerGeneration should be zero when missing, got %d", got.MCPServerGeneration) + }) + + t.Run("omitempty omits zero value", func(t *testing.T) { + t.Parallel() + // With the int64 type and `omitempty`, a zero MCPServerGeneration must not + // appear in the marshaled JSON. This is the key property that makes ConfigMap + // checksums deterministic across reconciles for unversioned (CLI) callers. + cfg := NewRunConfig() + cfg.Name = "zero-generation" + + var buf bytes.Buffer + require.NoError(t, cfg.WriteJSON(&buf)) + assert.False(t, bytes.Contains(buf.Bytes(), []byte("mcpserver_generation")), + "zero MCPServerGeneration should be omitted from JSON output; got:\n%s", buf.String()) + + // Round-trip: absent field decodes back to zero. + got, err := ReadJSON(&buf) + require.NoError(t, err) + assert.Equal(t, int64(0), got.MCPServerGeneration, + "decoded missing field should be zero, got %d", got.MCPServerGeneration) + }) +} diff --git a/pkg/runner/middleware.go b/pkg/runner/middleware.go index be9dd33506..068d35a455 100644 --- a/pkg/runner/middleware.go +++ b/pkg/runner/middleware.go @@ -9,13 +9,14 @@ import ( "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" + "github.com/stacklok/toolhive/pkg/auth/obo" "github.com/stacklok/toolhive/pkg/auth/upstreamswap" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" cfg "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" "github.com/stacklok/toolhive/pkg/ratelimit" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/telemetry" @@ -33,6 +34,7 @@ func GetSupportedMiddlewareFactories() map[string]types.MiddlewareFactory { tokenexchange.MiddlewareType: tokenexchange.CreateMiddleware, upstreamswap.MiddlewareType: upstreamswap.CreateMiddleware, awssts.MiddlewareType: awssts.CreateMiddleware, + obo.MiddlewareType: obo.CreateMiddleware, mcp.ParserMiddlewareType: mcp.CreateParserMiddleware, mcp.ToolFilterMiddlewareType: mcp.CreateToolFilterMiddleware, mcp.ToolCallFilterMiddlewareType: mcp.CreateToolCallFilterMiddleware, diff --git a/pkg/runner/middleware_test.go b/pkg/runner/middleware_test.go index ee890dc52a..19ec497c6a 100644 --- a/pkg/runner/middleware_test.go +++ b/pkg/runner/middleware_test.go @@ -18,6 +18,7 @@ import ( "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" + "github.com/stacklok/toolhive/pkg/auth/obo" "github.com/stacklok/toolhive/pkg/auth/upstreamswap" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authz" @@ -217,12 +218,48 @@ func TestGetSupportedMiddlewareFactories(t *testing.T) { headerfwd.HeaderForwardMiddlewareName, upstreamswap.MiddlewareType, awssts.MiddlewareType, + obo.MiddlewareType, } { _, ok := factories[key] assert.True(t, ok, "factory map should contain %q", key) } } +// TestGetSupportedMiddlewareFactories_OBODispatchesToCurrentFactory locks the +// contract that obo.CreateMiddleware is a stable redirector: the literal map +// in GetSupportedMiddlewareFactories captures CreateMiddleware once, but each +// invocation reads obo's currentFactory under the package-level RWMutex, so +// registrations that happen AFTER the map is built still take effect. The +// register-then-call ordering exercises the production hot path; the +// call-after-register ordering exercises hot-reload / re-registration. +// +//nolint:paralleltest // Mutates package-level obo currentFactory; must not race other tests. +func TestGetSupportedMiddlewareFactories_OBODispatchesToCurrentFactory(t *testing.T) { + // Build the factory map first so we capture the redirector before any + // out-of-test registration happens. + factories := GetSupportedMiddlewareFactories() + factory, ok := factories[obo.MiddlewareType] + require.True(t, ok, "obo factory should be present in the map") + + sentinel := []byte("custom-obo-factory") + var observed []byte + replacement := func(cfg *types.MiddlewareConfig, _ types.MiddlewareRunner) error { + // Capture the marker the caller passes to prove which factory ran. + observed = cfg.Parameters + return nil + } + + // Register AFTER the map was built. Because CreateMiddleware is a stable + // redirector, dispatching through the map must still hit the replacement. + obo.RegisterFactory(replacement) + t.Cleanup(func() { obo.RegisterFactory(obo.DefaultFactory) }) + + cfg := &types.MiddlewareConfig{Type: obo.MiddlewareType, Parameters: sentinel} + require.NoError(t, factory(cfg, nil)) + assert.Equal(t, sentinel, observed, + "obo.CreateMiddleware must redirect through the current factory, including after the runner map is built") +} + func TestWithHeaderForwardSecretsBuilderOption(t *testing.T) { t.Parallel() diff --git a/pkg/runner/permissions.go b/pkg/runner/permissions.go index a97f5b0eda..d524699938 100644 --- a/pkg/runner/permissions.go +++ b/pkg/runner/permissions.go @@ -4,52 +4,16 @@ package runner import ( - "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" - - "github.com/stacklok/toolhive-core/permissions" ) // This was moved from the CLI to allow it to be shared with the lifecycle manager. // It will likely be moved elsewhere in a future PR. -// CreatePermissionProfileFile creates a temporary file with the permission profile -func CreatePermissionProfileFile(serverName string, permProfile *permissions.Profile) (string, error) { - tempFile, err := os.CreateTemp("", fmt.Sprintf("toolhive-%s-permissions-*.json", serverName)) - if err != nil { - return "", fmt.Errorf("failed to create temporary file: %w", err) - } - defer func() { - if err := tempFile.Close(); err != nil { - // Non-fatal: temp file cleanup failure - slog.Warn("Failed to close temp file", "error", err) - } - }() - - // Get the temporary file path - permProfilePath := tempFile.Name() - - // Serialize the permission profile to JSON - permProfileJSON, err := json.Marshal(permProfile) - if err != nil { - return "", fmt.Errorf("failed to serialize permission profile: %w", err) - } - - // Write the permission profile to the temporary file - if _, err := tempFile.Write(permProfileJSON); err != nil { - return "", fmt.Errorf("failed to write permission profile to file: %w", err) - } - - //nolint:gosec // G706: path is a temp file created by us - slog.Debug("Wrote permission profile to temporary file", "path", permProfilePath) - - return permProfilePath, nil -} - // CleanupTempPermissionProfile removes a temporary permission profile file if it was created by toolhive func CleanupTempPermissionProfile(permissionProfilePath string) error { if permissionProfilePath == "" { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index a72c5bc842..ef152c378e 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -17,6 +17,7 @@ import ( "golang.org/x/oauth2" + tcredis "github.com/stacklok/toolhive-core/redis" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/remote" authsecrets "github.com/stacklok/toolhive/pkg/auth/secrets" @@ -120,6 +121,7 @@ func NewRunner(runConfig *RunConfig, statusManager statuses.StatusManager) *Runn func newTransportSessionStorage( ctx context.Context, scalingConfig *ScalingConfig, + ttl time.Duration, ) (transportsession.Storage, error) { if scalingConfig == nil || scalingConfig.SessionRedis == nil { return nil, nil @@ -130,15 +132,16 @@ func newTransportSessionStorage( keyPrefix = defaultProxySessionKeyPrefix } - redisCfg := transportsession.RedisConfig{ - Addr: scalingConfig.SessionRedis.Address, - Username: os.Getenv(vmcpconfig.RedisUsernameEnvVar), - Password: os.Getenv(vmcpconfig.RedisPasswordEnvVar), - DB: int(scalingConfig.SessionRedis.DB), - KeyPrefix: keyPrefix, + // toolhive-core's client config has no username source of its own; the + // operator injects the ACL username from sessionStorage.usernameRef. + redisCfg := tcredis.Config{ + Addr: scalingConfig.SessionRedis.Address, + Username: os.Getenv(vmcpconfig.RedisUsernameEnvVar), + Password: os.Getenv(vmcpconfig.RedisPasswordEnvVar), + DB: int(scalingConfig.SessionRedis.DB), } - storage, err := transportsession.NewRedisStorage(ctx, redisCfg, transportsession.ResolveSessionTTLFromEnv()) + storage, err := transportsession.NewRedisStorage(ctx, redisCfg, keyPrefix, ttl) if err != nil { return nil, fmt.Errorf("creating redis session storage: %w", err) } @@ -198,6 +201,31 @@ func (c *RunConfig) GetPort() int { // //nolint:gocyclo // This function is complex but manageable func (r *Runner) Run(ctx context.Context) error { + // Resolve session TTL once so both the transport proxy and Redis storage use + // the same effective value, rather than each applying their own zero-fallback + // independently. SessionTTL is stored as a Go duration string so the + // runconfig wire format does not depend on nanosecond integers. + // Proxy tuning knobs are declared as workload env vars (MCPServer spec.env) + // but are read by this process, so mirror them before the transport and the + // session store resolve their configuration. + applyProxyProcessEnv(r.Config.EnvVars) + + // DocPlanner fork: without an explicit session_ttl, fall back to the + // TOOLHIVE_PROXY_SESSION_TTL env override before the upstream default. + effectiveSessionTTL := transportsession.ResolveSessionTTLFromEnv() + if r.Config.SessionTTL != "" { + parsed, err := time.ParseDuration(r.Config.SessionTTL) + if err != nil { + return fmt.Errorf("invalid session_ttl %q: %w", r.Config.SessionTTL, err) + } + if parsed < 0 { + return fmt.Errorf("session_ttl must be non-negative, got %s", parsed) + } + if parsed > 0 { + effectiveSessionTTL = parsed + } + } + // Create transport with runtime transportConfig := types.Config{ Type: r.Config.Transport, @@ -209,6 +237,7 @@ func (r *Runner) Run(ctx context.Context) error { Debug: r.Config.Debug, TrustProxyHeaders: r.Config.TrustProxyHeaders, EndpointPrefix: r.Config.EndpointPrefix, + SessionTTL: effectiveSessionTTL, } // Set proxy mode for stdio transport @@ -373,6 +402,7 @@ func (r *Runner) Run(ctx context.Context) error { r.Config.TargetHost, r.Config.Publish, scalingConfig, + r.Config.MCPServerGeneration, ) if err != nil { return fmt.Errorf("failed to set up workload: %w", err) @@ -386,15 +416,10 @@ func (r *Runner) Run(ctx context.Context) error { } } - // Proxy tuning knobs are declared as workload env vars (MCPServer spec.env) - // but are read by this process, so mirror them before the transport and the - // session store resolve their configuration. - applyProxyProcessEnv(r.Config.EnvVars) - // When Redis session storage is configured, create a Redis-backed session // store so sessions are shared across proxy replicas instead of being pod-local. if r.Config.ScalingConfig != nil && r.Config.ScalingConfig.SessionRedis != nil { - storage, err := newTransportSessionStorage(ctx, r.Config.ScalingConfig) + storage, err := newTransportSessionStorage(ctx, r.Config.ScalingConfig, effectiveSessionTTL) if err != nil { return fmt.Errorf("failed to create Redis session storage: %w", err) } @@ -423,7 +448,25 @@ func (r *Runner) Run(ctx context.Context) error { r.monitoringCtx, r.monitoringCancel = context.WithCancel(ctx) // Create adapter to bridge statuses.StatusManager to auth.StatusUpdater adapter := &statusManagerAdapter{sm: r.statusManager} - r.authenticatedTokenSource = auth.NewMonitoredTokenSource(r.monitoringCtx, tokenSource, r.Config.BaseName, adapter) + + // Capture the upstream issuer and resolved client_id so the DCR + // remediation warning emitted by isTransientNetworkError on a + // permanent 4xx (indicating a stale RFC 7591 registration) + // carries enough context for an operator to identify which + // upstream AS + client_id to re-register. Precedence (cached + // CIMD > cached DCR > static) lives next to + // resolveClientCredentials in pkg/auth/remote so both call + // sites stay in sync. + upstream, clientID := r.Config.RemoteAuthConfig.LogContext() + + r.authenticatedTokenSource = auth.NewMonitoredTokenSource( + r.monitoringCtx, + tokenSource, + r.Config.BaseName, + upstream, + clientID, + adapter, + ) tokenSource = r.authenticatedTokenSource r.authenticatedTokenSource.StartBackgroundMonitoring() } diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go index 2a1abcc148..b5404f6c16 100644 --- a/pkg/runner/runner_test.go +++ b/pkg/runner/runner_test.go @@ -350,7 +350,7 @@ func TestNewTransportSessionStorage(t *testing.T) { t.Run("returns nil when scaling config is absent", func(t *testing.T) { t.Parallel() - storage, err := newTransportSessionStorage(context.Background(), nil) + storage, err := newTransportSessionStorage(context.Background(), nil, transportsession.DefaultSessionTTL) require.NoError(t, err) assert.Nil(t, storage) }) @@ -358,7 +358,7 @@ func TestNewTransportSessionStorage(t *testing.T) { t.Run("returns nil when redis session storage is not configured", func(t *testing.T) { t.Parallel() - storage, err := newTransportSessionStorage(context.Background(), &ScalingConfig{}) + storage, err := newTransportSessionStorage(context.Background(), &ScalingConfig{}, transportsession.DefaultSessionTTL) require.NoError(t, err) assert.Nil(t, storage) }) @@ -372,7 +372,7 @@ func TestNewTransportSessionStorage(t *testing.T) { SessionRedis: &SessionRedisConfig{ Address: mr.Addr(), }, - }) + }, transportsession.DefaultSessionTTL) require.NoError(t, err) t.Cleanup(func() { _ = storage.Close() @@ -390,7 +390,7 @@ func TestNewTransportSessionStorage(t *testing.T) { Address: "localhost:6379", KeyPrefix: "missing-trailing-colon", }, - }) + }, transportsession.DefaultSessionTTL) require.Error(t, err) assert.Nil(t, storage) assert.Contains(t, err.Error(), "creating redis session storage") @@ -409,7 +409,7 @@ func TestNewTransportSessionStorage_UsesRedisCredentials(t *testing.T) { Address: mr.Addr(), KeyPrefix: "test:mcp:", }, - }) + }, transportsession.DefaultSessionTTL) require.NoError(t, err) t.Cleanup(func() { _ = storage.Close() diff --git a/pkg/runner/telemetry_config.go b/pkg/runner/telemetry_config.go new file mode 100644 index 0000000000..cc7728b94d --- /dev/null +++ b/pkg/runner/telemetry_config.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "log/slog" + "strings" + + appconfig "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/telemetry" +) + +// otelHeadersEnvVar is the standard OpenTelemetry environment variable carrying +// OTLP export headers as a comma-separated list (e.g. "key1=val1,key2=val2"). +// A config source may deliver headers through this variable in the app config's +// EnvVars instead of as header flags. +const otelHeadersEnvVar = "OTEL_EXPORTER_OTLP_HEADERS" + +// BuildTelemetryConfigFromAppConfig builds a *telemetry.Config from an +// application OpenTelemetry config (read from ~/.config/toolhive/config.yaml +// or supplied by a config source). +// +// Both the "thv run" CLI and the "POST /api/v1/workloads" API path call this +// so workloads created via either surface inherit the same telemetry +// settings. Export headers come from two sources: the caller's header flags +// (the CLI surfaces "--otel-headers"; the API passes none) and an +// OTEL_EXPORTER_OTLP_HEADERS entry in the config's EnvVars. Flags take +// precedence. customAttributes has no equivalent in the application config +// and is passed through by the caller. +// +// Returns nil when no telemetry should be enabled: either no endpoint and no +// Prometheus metrics path, or both tracing and metrics disabled with no +// Prometheus path. +// +// CLI-style defaults are applied: TracingEnabled, MetricsEnabled, and +// UseLegacyAttributes default to true when unset in the config so that +// configuring just an endpoint produces a working setup. +func BuildTelemetryConfigFromAppConfig( + otel appconfig.OpenTelemetryConfig, + serviceName string, + headers []string, + customAttributes string, +) *telemetry.Config { + if otel.Endpoint == "" && !otel.EnablePrometheusMetricsPath { + return nil + } + + tracingEnabled := true + if otel.TracingEnabled != nil { + tracingEnabled = *otel.TracingEnabled + } + metricsEnabled := true + if otel.MetricsEnabled != nil { + metricsEnabled = *otel.MetricsEnabled + } + useLegacyAttributes := true + if otel.UseLegacyAttributes != nil { + useLegacyAttributes = *otel.UseLegacyAttributes + } + + if !tracingEnabled && !metricsEnabled && !otel.EnablePrometheusMetricsPath { + return nil + } + + parsedHeaders := mergeTelemetryHeaders(headers, otel.EnvVars) + + var processedEnvVars []string + for _, entry := range otel.EnvVars { + for ev := range strings.SplitSeq(entry, ",") { + trimmed := strings.TrimSpace(ev) + if trimmed != "" { + processedEnvVars = append(processedEnvVars, trimmed) + } + } + } + + customAttrs, err := telemetry.ParseCustomAttributes(customAttributes) + if err != nil { + slog.Warn("Failed to parse custom attributes", "error", err) + customAttrs = nil + } + + cfg := &telemetry.Config{ + Endpoint: otel.Endpoint, + ServiceName: serviceName, + ServiceVersion: "", // resolved at runtime in NewProvider() + TracingEnabled: tracingEnabled, + MetricsEnabled: metricsEnabled, + Headers: parsedHeaders, + Insecure: otel.Insecure, + EnablePrometheusMetricsPath: otel.EnablePrometheusMetricsPath, + EnvironmentVariables: processedEnvVars, + CustomAttributes: customAttrs, + UseLegacyAttributes: useLegacyAttributes, + } + cfg.SetSamplingRateFromFloat(otel.SamplingRate) + return cfg +} + +// mergeTelemetryHeaders builds the OTLP export header map from two sources: +// an OTEL_EXPORTER_OTLP_HEADERS entry in the config's EnvVars (used when the +// caller passes no header flags, such as the API path) and caller-supplied +// header flags ("key=value", from the CLI's --otel-headers). Env headers are +// applied first so flags override them on conflict. Values are split on the +// first '=' only, so a value containing '=' (e.g. base64 padding) or spaces +// (e.g. "Basic ") is preserved intact. +func mergeTelemetryHeaders(flagHeaders, envVars []string) map[string]string { + headers := make(map[string]string) + for _, entry := range envVars { + name, value, ok := strings.Cut(entry, "=") + if !ok || name != otelHeadersEnvVar { + continue + } + for pair := range strings.SplitSeq(value, ",") { + if key, val, ok := strings.Cut(strings.TrimSpace(pair), "="); ok && key != "" { + headers[key] = val + } + } + } + + for _, h := range flagHeaders { + if key, val, ok := strings.Cut(h, "="); ok { + headers[key] = val + } + } + return headers +} diff --git a/pkg/runner/telemetry_config_test.go b/pkg/runner/telemetry_config_test.go new file mode 100644 index 0000000000..97e3b1bbea --- /dev/null +++ b/pkg/runner/telemetry_config_test.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + appconfig "github.com/stacklok/toolhive/pkg/config" +) + +func TestBuildTelemetryConfigFromAppConfig(t *testing.T) { + t.Parallel() + + boolPtr := func(b bool) *bool { return &b } + + tests := []struct { + name string + otel appconfig.OpenTelemetryConfig + serviceName string + headers []string + customAttributes string + wantNil bool + }{ + { + name: "empty config returns nil", + otel: appconfig.OpenTelemetryConfig{}, + wantNil: true, + }, + { + name: "endpoint without enabled signals still returns config (defaults on)", + otel: appconfig.OpenTelemetryConfig{ + Endpoint: "http://collector:4318", + }, + serviceName: "thv-osv", + wantNil: false, + }, + { + name: "tracing and metrics both explicitly disabled with no prometheus path returns nil", + otel: appconfig.OpenTelemetryConfig{ + Endpoint: "http://collector:4318", + TracingEnabled: boolPtr(false), + MetricsEnabled: boolPtr(false), + }, + wantNil: true, + }, + { + name: "prometheus path alone enables config", + otel: appconfig.OpenTelemetryConfig{ + EnablePrometheusMetricsPath: true, + TracingEnabled: boolPtr(false), + MetricsEnabled: boolPtr(false), + }, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := BuildTelemetryConfigFromAppConfig(tt.otel, tt.serviceName, tt.headers, tt.customAttributes) + + if tt.wantNil { + assert.Nil(t, cfg, "expected nil telemetry config") + return + } + require.NotNil(t, cfg, "expected non-nil telemetry config") + }) + } +} + +// TestBuildTelemetryConfigFromAppConfig_AppliesAllFields verifies that every +// field on OpenTelemetryConfig is actually copied to the resulting +// telemetry.Config, so the API path can't silently drop one. +func TestBuildTelemetryConfigFromAppConfig_AppliesAllFields(t *testing.T) { + t.Parallel() + + boolPtr := func(b bool) *bool { return &b } + + otel := appconfig.OpenTelemetryConfig{ + Endpoint: "http://collector:4318", + SamplingRate: 0.25, + EnvVars: []string{"FOO,BAR", " BAZ "}, + MetricsEnabled: boolPtr(true), + TracingEnabled: boolPtr(true), + Insecure: true, + EnablePrometheusMetricsPath: true, + UseLegacyAttributes: boolPtr(false), + } + + cfg := BuildTelemetryConfigFromAppConfig(otel, "thv-osv", []string{"x=1"}, "") + require.NotNil(t, cfg) + + assert.Equal(t, "http://collector:4318", cfg.Endpoint) + assert.Equal(t, "thv-osv", cfg.ServiceName) + assert.True(t, cfg.TracingEnabled) + assert.True(t, cfg.MetricsEnabled) + assert.True(t, cfg.Insecure) + assert.True(t, cfg.EnablePrometheusMetricsPath) + assert.False(t, cfg.UseLegacyAttributes) + assert.Equal(t, []string{"FOO", "BAR", "BAZ"}, cfg.EnvironmentVariables) + assert.Equal(t, map[string]string{"x": "1"}, cfg.Headers) + // Sampling rate is stored as a string; just verify it was set from the float. + assert.NotEmpty(t, cfg.SamplingRate) +} + +// TestBuildTelemetryConfigFromAppConfig_OTLPHeadersFromEnvVars verifies that an +// OTEL_EXPORTER_OTLP_HEADERS entry in the app config's EnvVars is parsed into +// cfg.Headers, which is what the OTLP exporter actually sends (via WithHeaders). +// This is the path taken when headers are configured rather than passed as +// flags (e.g. the API call site passes no header flags). Without this, the +// headers are silently dropped and an auth-gated collector rejects every +// export (401). +func TestBuildTelemetryConfigFromAppConfig_OTLPHeadersFromEnvVars(t *testing.T) { + t.Parallel() + + t.Run("single header with scheme and base64 padding is preserved", func(t *testing.T) { + t.Parallel() + otel := appconfig.OpenTelemetryConfig{ + Endpoint: "https://otel.example.com", + EnvVars: []string{"OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic dG9rZW46cHc="}, + } + cfg := BuildTelemetryConfigFromAppConfig(otel, "thv-osv", nil, "") + require.NotNil(t, cfg) + // The value contains a space and '=' padding; both must survive parsing. + assert.Equal(t, "Basic dG9rZW46cHc=", cfg.Headers["Authorization"]) + }) + + t.Run("multiple comma-separated headers are all parsed", func(t *testing.T) { + t.Parallel() + otel := appconfig.OpenTelemetryConfig{ + Endpoint: "https://otel.example.com", + EnvVars: []string{"OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic abc,x-tenant-id=acme"}, + } + cfg := BuildTelemetryConfigFromAppConfig(otel, "thv-osv", nil, "") + require.NotNil(t, cfg) + assert.Equal(t, "Basic abc", cfg.Headers["Authorization"]) + assert.Equal(t, "acme", cfg.Headers["x-tenant-id"]) + }) + + t.Run("explicit header flag wins, other env headers still applied", func(t *testing.T) { + t.Parallel() + otel := appconfig.OpenTelemetryConfig{ + Endpoint: "https://otel.example.com", + EnvVars: []string{"OTEL_EXPORTER_OTLP_HEADERS=Authorization=fromenv,x-env=prod"}, + } + cfg := BuildTelemetryConfigFromAppConfig(otel, "thv-osv", []string{"Authorization=fromflag"}, "") + require.NotNil(t, cfg) + assert.Equal(t, "fromflag", cfg.Headers["Authorization"], "explicit --otel-headers flag must take precedence") + assert.Equal(t, "prod", cfg.Headers["x-env"], "env-provided headers other than the overridden one must still apply") + }) +} + +// TestBuildTelemetryConfigFromAppConfig_DefaultsForNilBools verifies the CLI-style +// defaults: when bool fields are not set in the config, tracing/metrics/legacy +// attributes default to true. This is what makes "just configure an endpoint" +// produce a working setup. +func TestBuildTelemetryConfigFromAppConfig_DefaultsForNilBools(t *testing.T) { + t.Parallel() + + cfg := BuildTelemetryConfigFromAppConfig( + appconfig.OpenTelemetryConfig{Endpoint: "http://collector:4318"}, + "thv-osv", + nil, + "", + ) + require.NotNil(t, cfg) + assert.True(t, cfg.TracingEnabled, "TracingEnabled must default to true when not set in config") + assert.True(t, cfg.MetricsEnabled, "MetricsEnabled must default to true when not set in config") + assert.True(t, cfg.UseLegacyAttributes, "UseLegacyAttributes must default to true when not set in config") +} diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 4cbbdc0046..a23d458c84 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -54,6 +54,7 @@ func Setup( targetHost string, publishedPorts []string, scalingConfig *rt.ScalingConfig, + runConfigMCPServerGeneration int64, ) (*SetupResult, error) { // Add transport-specific environment variables env, ok := transportEnvMap[transportType] @@ -99,6 +100,7 @@ func Setup( } containerOptions.ScalingConfig = scalingConfig containerOptions.AllowDockerGateway = allowDockerGateway + containerOptions.RunConfigMCPServerGeneration = runConfigMCPServerGeneration if transportType == types.TransportTypeStdio { containerOptions.AttachStdio = true diff --git a/pkg/secrets/scoped.go b/pkg/secrets/scoped.go index b3dc539cff..57bedc04f9 100644 --- a/pkg/secrets/scoped.go +++ b/pkg/secrets/scoped.go @@ -21,8 +21,8 @@ import ( // ends and the name begins. // // All constants declared in this package (ScopeRegistry, ScopeWorkloads, -// ScopeAuth) satisfy these invariants. Custom scopes introduced in the future -// must be validated against them. +// ScopeAuth, ScopeLLM) satisfy these invariants. Custom scopes introduced in +// the future must be validated against them. type SecretScope string const ( @@ -42,6 +42,9 @@ const ( // ScopeAuth is reserved for enterprise CLI/Desktop login tokens. ScopeAuth SecretScope = "auth" + + // ScopeLLM is the scope for LLM gateway OIDC refresh tokens. + ScopeLLM SecretScope = "llm" ) // ErrReservedKeyName is returned when a user command attempts to manage a diff --git a/pkg/secrets/scoped_test.go b/pkg/secrets/scoped_test.go index 794a049b9a..f7e01ffaef 100644 --- a/pkg/secrets/scoped_test.go +++ b/pkg/secrets/scoped_test.go @@ -802,6 +802,7 @@ func TestSecretScopeInvariants(t *testing.T) { secrets.ScopeRegistry, secrets.ScopeWorkloads, secrets.ScopeAuth, + secrets.ScopeLLM, } for _, scope := range scopes { diff --git a/pkg/server/discovery/discovery.go b/pkg/server/discovery/discovery.go index 04790e19ca..b00ecddfc2 100644 --- a/pkg/server/discovery/discovery.go +++ b/pkg/server/discovery/discovery.go @@ -64,12 +64,6 @@ func WriteServerInfo(info *ServerInfo) error { return writeServerInfoTo(defaultDiscoveryDir(), info) } -// ReadServerInfo reads and parses the server discovery file. -// Returns os.ErrNotExist if the file does not exist. -func ReadServerInfo() (*ServerInfo, error) { - return readServerInfoFrom(defaultDiscoveryDir()) -} - // RemoveServerInfo removes the server discovery file. // It is a no-op if the file does not exist. func RemoveServerInfo() error { diff --git a/pkg/server/discovery/discovery_test.go b/pkg/server/discovery/discovery_test.go index 1ca39a0ada..fadf0ec46a 100644 --- a/pkg/server/discovery/discovery_test.go +++ b/pkg/server/discovery/discovery_test.go @@ -54,6 +54,31 @@ func TestWriteReadServerInfo_UnixSocket(t *testing.T) { assert.Equal(t, info.Nonce, got.Nonce) } +// TestWriteReadServerInfo_NamedPipe pins the producer/consumer contract for +// npipe:// discovery URLs end to end. The individual pieces (socketURL emit, +// HTTPClientForURL dispatch, ParseNamedPipeURL parse) are covered in their +// own tests; this test asserts that an npipe URL survives the discovery +// file's JSON serialization round-trip without being mangled. +func TestWriteReadServerInfo_NamedPipe(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + info := &ServerInfo{ + URL: "npipe://thv-api", + PID: 99999, + Nonce: "test-nonce-pipe", + StartedAt: time.Date(2026, 5, 7, 14, 0, 0, 0, time.UTC), + } + + require.NoError(t, writeServerInfoTo(dir, info)) + + got, err := readServerInfoFrom(dir) + require.NoError(t, err) + assert.Equal(t, info.URL, got.URL) + assert.Equal(t, info.PID, got.PID) + assert.Equal(t, info.Nonce, got.Nonce) +} + func TestReadServerInfo_NotFound(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/pkg/server/discovery/health.go b/pkg/server/discovery/health.go index cf80cb718d..91c3cf1730 100644 --- a/pkg/server/discovery/health.go +++ b/pkg/server/discovery/health.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "path/filepath" + "regexp" "strings" "time" ) @@ -23,8 +24,37 @@ const ( NonceHeader = "X-Toolhive-Nonce" ) +// NamedPipePrefix is the Windows named-pipe namespace prefix. The discovery +// dialer uses it to reconstruct addresses for winio.DialPipeContext, and the +// API listener (pkg/api) imports it as the canonical definition so the two +// sides cannot drift. +const NamedPipePrefix = `\\.\pipe\` + +// maxNamedPipeNameLen is the Windows limit on the part of a pipe path after +// \\.\pipe\: the underlying CreateNamedPipeW lpName cannot exceed 256 chars +// total, leaving 247 for the name once the prefix is accounted for. +const maxNamedPipeNameLen = 247 + +// namedPipeNamePattern is the positive charset accepted for pipe names. The +// Windows pipe namespace technically allows more, but restricting to this +// subset keeps round-tripping through net/url predictable and rules out +// shell-meaningful characters that should never appear in our addresses. +var namedPipeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// reservedNamedPipeNames is the set of legacy Windows device names that +// CreateNamedPipeW will not accept as a pipe name. The check is on the +// already-lowercased hostname. +var reservedNamedPipeNames = map[string]struct{}{ + "con": {}, "nul": {}, "prn": {}, "aux": {}, + "com1": {}, "com2": {}, "com3": {}, "com4": {}, "com5": {}, + "com6": {}, "com7": {}, "com8": {}, "com9": {}, + "lpt1": {}, "lpt2": {}, "lpt3": {}, "lpt4": {}, "lpt5": {}, + "lpt6": {}, "lpt7": {}, "lpt8": {}, "lpt9": {}, +} + // CheckHealth verifies that a server at the given URL is healthy and optionally -// matches the expected nonce. It supports http:// and unix:// URL schemes. +// matches the expected nonce. It supports http://, unix://, and npipe:// URL +// schemes (npipe:// only resolves on Windows). func CheckHealth(ctx context.Context, serverURL string, expectedNonce string) error { client, requestURL, err := buildHealthClient(serverURL) if err != nil { @@ -76,12 +106,20 @@ func buildHealthClient(serverURL string) (*http.Client, string, error) { // HTTPClientForURL returns an HTTP client configured for the given server URL // and the base URL to use for requests. For unix:// URLs it creates a client // with a Unix socket transport and returns "http://localhost" as the base URL. -// For http:// URLs it validates the host is a loopback address and returns a -// default client. The returned client has no timeout set; callers should apply -// their own timeout via context or client.Timeout. +// For npipe:// URLs (Windows only) it dials the named pipe via the platform +// dialNamedPipe helper. For http:// URLs it validates the host is a loopback +// address and returns a default client. The returned client has no timeout +// set; callers should apply their own timeout via context or client.Timeout. +// +// Scheme matching is case-insensitive because url.Parse lowercases the scheme +// during parsing, so npipe://x and NPIPE://x route through the same arm. func HTTPClientForURL(serverURL string) (*http.Client, string, error) { - switch { - case strings.HasPrefix(serverURL, "unix://"): + u, err := url.Parse(serverURL) + if err != nil { + return nil, "", fmt.Errorf("invalid server URL: %w", err) + } + switch u.Scheme { + case "unix": socketPath, err := ParseUnixSocketPath(serverURL) if err != nil { return nil, "", err @@ -95,14 +133,28 @@ func HTTPClientForURL(serverURL string) (*http.Client, string, error) { } return client, "http://localhost", nil - case strings.HasPrefix(serverURL, "http://"): + case "npipe": + pipePath, err := ParseNamedPipeURL(serverURL) + if err != nil { + return nil, "", err + } + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialNamedPipe(ctx, pipePath) + }, + }, + } + return client, "http://localhost", nil + + case "http": if err := ValidateLoopbackURL(serverURL); err != nil { return nil, "", err } return &http.Client{}, serverURL, nil default: - return nil, "", fmt.Errorf("unsupported URL scheme: %s", serverURL) + return nil, "", fmt.Errorf("unsupported URL scheme: %s", u.Scheme) } } @@ -125,13 +177,36 @@ func ValidateLoopbackURL(rawURL string) error { } // ParseUnixSocketPath extracts and validates the socket path from a unix:// URL. +// The expected form is unix:///; URLs with a non-empty +// authority (unix://host/path) are rejected because the path component would +// be ambiguous. On Windows, unix:///C:%5Cpath%5Cthv.sock round-trips back to +// C:\path\thv.sock by stripping the synthetic leading slash that net/url +// inserts in front of the drive letter. func ParseUnixSocketPath(rawURL string) (string, error) { - path := strings.TrimPrefix(rawURL, "unix://") + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid unix socket URL: %w", err) + } + if u.Scheme != "unix" { + return "", fmt.Errorf("unix socket URL must start with unix://: %s", rawURL) + } + if u.Host != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { + return "", fmt.Errorf("unix socket path must be absolute (use unix:///): %s", rawURL) + } + path := u.Path if path == "" { return "", fmt.Errorf("empty unix socket path") } - // Check for traversal before Clean resolves it away + // On Windows AF_UNIX paths, the listener emits unix:///C:%5Cpath%5C..., which + // url.Parse returns as Path="/C:\path\..." with a synthetic leading slash. + // Strip it before any further validation so filepath.IsAbs sees the actual + // drive-letter form. + if len(path) >= 3 && path[0] == '/' && isASCIILetter(path[1]) && path[2] == ':' { + path = path[1:] + } + + // Check for traversal before Clean resolves it away. if strings.Contains(path, "..") { return "", fmt.Errorf("unix socket path must not contain '..': %s", path) } @@ -144,3 +219,45 @@ func ParseUnixSocketPath(rawURL string) (string, error) { return path, nil } + +// ParseNamedPipeURL extracts and validates the pipe name from an npipe:// URL +// and returns the full Windows pipe path (e.g. \\.\pipe\thv-api). The URL +// shape is strict: npipe:// with no path, query, fragment, userinfo, or +// port. The name itself must match a conservative charset, fit within the +// 247-char limit imposed by CreateNamedPipeW after the prefix, and must not +// be one of the legacy reserved Windows device names (CON, NUL, COM1, etc.). +// The returned path always uses the lowercase form of the name because the +// pipe namespace is case-insensitive at the kernel layer. +func ParseNamedPipeURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid named pipe URL: %w", err) + } + if u.Scheme != "npipe" { + return "", fmt.Errorf("named pipe URL must start with npipe://: %s", rawURL) + } + if u.Path != "" || u.Opaque != "" || u.RawQuery != "" || + u.Fragment != "" || u.User != nil || u.Port() != "" { + return "", fmt.Errorf("named pipe URL must be of the form npipe://: %s", rawURL) + } + name := strings.ToLower(u.Hostname()) + if name == "" { + return "", fmt.Errorf("empty named pipe name") + } + if len(name) > maxNamedPipeNameLen { + return "", fmt.Errorf("named pipe name exceeds %d characters: %d", maxNamedPipeNameLen, len(name)) + } + if !namedPipeNamePattern.MatchString(name) { + return "", fmt.Errorf("named pipe name has invalid characters (allowed: A-Z, a-z, 0-9, ., _, -): %s", name) + } + if _, reserved := reservedNamedPipeNames[name]; reserved { + return "", fmt.Errorf("named pipe name is a reserved Windows device name: %s", name) + } + return NamedPipePrefix + name, nil +} + +// isASCIILetter reports whether b is an ASCII letter [A-Za-z]. Used by +// ParseUnixSocketPath to detect Windows drive-letter prefixes after url.Parse. +func isASCIILetter(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} diff --git a/pkg/server/discovery/health_test.go b/pkg/server/discovery/health_test.go index 4a18169103..9dbc07b965 100644 --- a/pkg/server/discovery/health_test.go +++ b/pkg/server/discovery/health_test.go @@ -5,11 +5,14 @@ package discovery import ( "context" + "fmt" "net" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -44,6 +47,75 @@ func TestParseUnixSocketPath_Empty(t *testing.T) { assert.Contains(t, err.Error(), "empty") } +func TestParseNamedPipeURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + expect string + wantErr bool + errSubstr string + }{ + {name: "valid", raw: "npipe://thv-api", expect: `\\.\pipe\thv-api`}, + {name: "valid with hyphen and digits", raw: "npipe://thv-api-1", expect: `\\.\pipe\thv-api-1`}, + {name: "valid with dot dot in name", raw: "npipe://my..api", expect: `\\.\pipe\my..api`}, + {name: "mixed-case scheme normalized", raw: "NPIPE://thv-api", expect: `\\.\pipe\thv-api`}, + {name: "mixed-case name normalized", raw: "npipe://Thv-API", expect: `\\.\pipe\thv-api`}, + {name: "missing scheme", raw: "thv-api", wantErr: true, errSubstr: "must start with npipe://"}, + {name: "wrong scheme", raw: "unix://thv-api", wantErr: true, errSubstr: "must start with npipe://"}, + {name: "empty name", raw: "npipe://", wantErr: true, errSubstr: "empty"}, + {name: "forward slash rejected", raw: "npipe://thv/api", wantErr: true, errSubstr: "form npipe://"}, + {name: "backslash rejected by url.Parse", raw: `npipe://thv\api`, wantErr: true, errSubstr: "invalid"}, + {name: "with port rejected", raw: "npipe://thv-api:1234", wantErr: true, errSubstr: "form npipe://"}, + {name: "with userinfo rejected", raw: "npipe://user:pass@thv-api", wantErr: true, errSubstr: "form npipe://"}, + {name: "with query rejected", raw: "npipe://thv-api?x=1", wantErr: true, errSubstr: "form npipe://"}, + {name: "with fragment rejected", raw: "npipe://thv-api#x", wantErr: true, errSubstr: "form npipe://"}, + {name: "invalid charset rejected", raw: "npipe://thv$api", wantErr: true, errSubstr: "invalid characters"}, + {name: "reserved name CON rejected", raw: "npipe://CON", wantErr: true, errSubstr: "reserved Windows device name"}, + {name: "reserved name com1 rejected", raw: "npipe://com1", wantErr: true, errSubstr: "reserved Windows device name"}, + { + name: "name exceeds length cap", + raw: "npipe://" + strings.Repeat("a", maxNamedPipeNameLen+1), + wantErr: true, + errSubstr: fmt.Sprintf("exceeds %d characters", maxNamedPipeNameLen), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseNamedPipeURL(tt.raw) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errSubstr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expect, got) + }) + } +} + +// TestHTTPClientForURL_SchemeDispatchCaseInsensitive pins that the dispatcher +// in HTTPClientForURL routes UNIX:// the same as unix:// because url.Parse +// lowercases the scheme. Without the migration this case fell through to the +// default "unsupported URL scheme" arm. +func TestHTTPClientForURL_SchemeDispatchCaseInsensitive(t *testing.T) { + t.Parallel() + _, baseURL, err := HTTPClientForURL("UNIX:///tmp/thv.sock") + require.NoError(t, err) + assert.Equal(t, "http://localhost", baseURL) +} + +func TestCheckHealth_NamedPipe_Unsupported_OnNonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-Windows guard test") + } + t.Parallel() + err := CheckHealth(context.Background(), "npipe://thv-api", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "Windows") +} + func TestCheckHealth_TCP_Success(t *testing.T) { t.Parallel() expectedNonce := "test-nonce-123" diff --git a/pkg/server/discovery/health_windows_test.go b/pkg/server/discovery/health_windows_test.go new file mode 100644 index 0000000000..552fe18208 --- /dev/null +++ b/pkg/server/discovery/health_windows_test.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package discovery + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pipeNameSeq disambiguates concurrent test pipes so parallel runs don't +// collide on the global pipe namespace. +var pipeNameSeq atomic.Uint64 + +func TestCheckHealth_NamedPipe_Success(t *testing.T) { + t.Parallel() + + pipeName := fmt.Sprintf("thv-test-%d", pipeNameSeq.Add(1)) + pipePath := `\\.\pipe\` + pipeName + + listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + expectedNonce := "pipe-nonce" + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(NonceHeader, expectedNonce) + w.WriteHeader(http.StatusNoContent) + }) + srv := &http.Server{Handler: mux} //nolint:gosec // test server, ReadHeaderTimeout not relevant + go func() { _ = srv.Serve(listener) }() + t.Cleanup(func() { _ = srv.Close() }) + + err = CheckHealth(context.Background(), "npipe://"+pipeName, expectedNonce) + require.NoError(t, err) +} + +func TestCheckHealth_NamedPipe_NotFound(t *testing.T) { + t.Parallel() + err := CheckHealth(context.Background(), "npipe://nonexistent-pipe-thv-test", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "health check failed") +} + +// TestCheckHealth_NamedPipe_HungServerCancelsOnContext pins that a peer which +// accepts the connection but never responds does not wedge CheckHealth: when +// the caller's context expires the dial / read returns and CheckHealth surfaces +// a wrapped error. This is the discovery StateUnhealthy path; without this +// guarantee a hung peer would block the previous-instance probe forever. +func TestCheckHealth_NamedPipe_HungServerCancelsOnContext(t *testing.T) { + t.Parallel() + + pipeName := fmt.Sprintf("thv-test-hung-%d", pipeNameSeq.Add(1)) + pipePath := `\\.\pipe\` + pipeName + + listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + // Collect accepted conns under a mutex so the background goroutine never + // touches t after the test body returns. t.Cleanup must be registered + // from the test goroutine, not from the accept loop, otherwise a late + // Accept could race with test teardown ("Log in goroutine after Test + // has completed"). + var ( + connsMu sync.Mutex + conns []net.Conn + ) + t.Cleanup(func() { + connsMu.Lock() + defer connsMu.Unlock() + for _, c := range conns { + _ = c.Close() + } + }) + + // Drain accepts so the dial succeeds, but never write anything back. The + // goroutine exits when the listener is closed via t.Cleanup above. + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + // Hold the connection open without responding so CheckHealth's + // HTTP read blocks until the context deadline fires. + connsMu.Lock() + conns = append(conns, conn) + connsMu.Unlock() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + + start := time.Now() + err = CheckHealth(ctx, "npipe://"+pipeName, "") + elapsed := time.Since(start) + + require.Error(t, err) + // The CheckHealth call must return promptly after the context expires + // rather than blocking on the hung peer indefinitely. healthTimeout is + // 5 s, so anything within ~2 s of the 250 ms ctx is the context path. + assert.Less(t, elapsed, 2*time.Second, "CheckHealth wedged on a hung peer") +} diff --git a/pkg/server/discovery/pipe_other.go b/pkg/server/discovery/pipe_other.go new file mode 100644 index 0000000000..b16b046cac --- /dev/null +++ b/pkg/server/discovery/pipe_other.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package discovery + +import ( + "context" + "fmt" + "net" +) + +// dialNamedPipe is a no-op stub on non-Windows platforms. The npipe:// scheme +// is reachable here only via a misconfigured discovery file or a hand-crafted +// URL; surface a clear error rather than fail with a confusing dial syscall +// result. +func dialNamedPipe(_ context.Context, name string) (net.Conn, error) { + return nil, fmt.Errorf("named pipes are only supported on Windows: %s", name) +} diff --git a/pkg/server/discovery/pipe_windows.go b/pkg/server/discovery/pipe_windows.go new file mode 100644 index 0000000000..bf08e8f6c6 --- /dev/null +++ b/pkg/server/discovery/pipe_windows.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package discovery + +import ( + "context" + "net" + + "github.com/Microsoft/go-winio" +) + +// dialNamedPipe opens a connection to the Windows named pipe at name. The +// caller is expected to have validated name via ParseNamedPipeURL. +func dialNamedPipe(ctx context.Context, name string) (net.Conn, error) { + return winio.DialPipeContext(ctx, name) +} diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 504f4c4e9c..4c5c69a606 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -83,18 +83,26 @@ func NewClient(baseURL string, opts ...Option) *Client { // // The context is used for the server discovery health check; it is not stored. func NewDefaultClient(ctx context.Context, opts ...Option) *Client { - return newDefaultClientWithEnv(ctx, &env.OSReader{}, opts...) + return newDefaultClientWithEnv(ctx, &env.OSReader{}, resolveViaDiscovery, opts...) } -// newDefaultClientWithEnv is the testable core of NewDefaultClient. -func newDefaultClientWithEnv(ctx context.Context, envReader env.Reader, opts ...Option) *Client { +// discoverFunc resolves a running server's base URL and any transport options +// (e.g. a Unix socket client). It returns an empty base URL when no running +// server is found. resolveViaDiscovery is the production implementation; tests +// inject a stub so the discovery step does not read real local state. +type discoverFunc func(ctx context.Context) (string, []Option) + +// newDefaultClientWithEnv is the testable core of NewDefaultClient. The +// envReader and discover dependencies are injected so each resolution step +// can be exercised in isolation. +func newDefaultClientWithEnv(ctx context.Context, envReader env.Reader, discover discoverFunc, opts ...Option) *Client { // 1. Explicit env var override always wins. if base := envReader.Getenv(envAPIURL); base != "" { return NewClient(base, opts...) } // 2. Try server discovery. - if base, httpOpts := resolveViaDiscovery(ctx); base != "" { + if base, httpOpts := discover(ctx); base != "" { // Discovery opts go first so caller-supplied opts can override them // (e.g. a caller-provided WithTimeout replaces the discovery default). merged := make([]Option, 0, len(httpOpts)+len(opts)) diff --git a/pkg/skills/client/client_test.go b/pkg/skills/client/client_test.go index cf4be5e8e6..db747aed8b 100644 --- a/pkg/skills/client/client_test.go +++ b/pkg/skills/client/client_test.go @@ -4,6 +4,7 @@ package client import ( + "context" "encoding/json" "errors" "io" @@ -636,13 +637,27 @@ func TestConnectionError(t *testing.T) { func TestNewDefaultClient(t *testing.T) { t.Parallel() + // noDiscovery stubs server discovery to report no running server, isolating + // the test from any real local server (e.g. a running Desktop app). + noDiscovery := func(context.Context) (string, []Option) { return "", nil } + + // failDiscovery fails the test if discovery is consulted, asserting that an + // earlier resolution step short-circuited. + failDiscovery := func(t *testing.T) discoverFunc { + t.Helper() + return func(context.Context) (string, []Option) { + t.Error("discovery should not be called when TOOLHIVE_API_URL is set") + return "", nil + } + } + t.Run("falls back to default URL when env is empty", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mockEnv := envmocks.NewMockReader(ctrl) mockEnv.EXPECT().Getenv(envAPIURL).Return("") - c := newDefaultClientWithEnv(t.Context(), mockEnv) + c := newDefaultClientWithEnv(t.Context(), mockEnv, noDiscovery) assert.Equal(t, defaultBaseURL, c.baseURL) }) @@ -652,17 +667,30 @@ func TestNewDefaultClient(t *testing.T) { mockEnv := envmocks.NewMockReader(ctrl) mockEnv.EXPECT().Getenv(envAPIURL).Return("http://localhost:9999") - c := newDefaultClientWithEnv(t.Context(), mockEnv) + c := newDefaultClientWithEnv(t.Context(), mockEnv, failDiscovery(t)) assert.Equal(t, "http://localhost:9999", c.baseURL) }) + t.Run("uses discovered server when env is empty", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(envAPIURL).Return("") + + discover := func(context.Context) (string, []Option) { + return "http://127.0.0.1:54321", nil + } + c := newDefaultClientWithEnv(t.Context(), mockEnv, discover) + assert.Equal(t, "http://127.0.0.1:54321", c.baseURL) + }) + t.Run("applies options", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mockEnv := envmocks.NewMockReader(ctrl) mockEnv.EXPECT().Getenv(envAPIURL).Return("") - c := newDefaultClientWithEnv(t.Context(), mockEnv, WithTimeout(5*time.Second)) + c := newDefaultClientWithEnv(t.Context(), mockEnv, noDiscovery, WithTimeout(5*time.Second)) assert.Equal(t, 5*time.Second, c.httpClient.Timeout) }) } diff --git a/pkg/skills/gitresolver/resolver.go b/pkg/skills/gitresolver/resolver.go index 85328be412..deb1d58f7c 100644 --- a/pkg/skills/gitresolver/resolver.go +++ b/pkg/skills/gitresolver/resolver.go @@ -14,6 +14,8 @@ import ( "regexp" "time" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stacklok/toolhive/pkg/git" "github.com/stacklok/toolhive/pkg/skills" ) @@ -151,9 +153,9 @@ func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*Reso return nil, fmt.Errorf("invalid skill name in SKILL.md: %w", err) } - // Collect all files in the skill directory. - // For now, we read SKILL.md as the primary file. Additional files in the - // skill directory are discovered by listing the tree entries. + // Collect all files in the skill directory, recursively walking nested + // subtrees so that companion files in subdirectories (e.g. references/, + // scripts/) are included in the resolved skill bundle. files, err := r.collectFiles(repoInfo, ref.Path) if err != nil { return nil, fmt.Errorf("collecting skill files: %w", err) @@ -166,7 +168,12 @@ func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*Reso }, nil } -// collectFiles reads all files from the given path in the repository. +// collectFiles reads all files from the given path in the repository, +// walking nested subtrees recursively. Returned paths are forward-slash +// relative to basePath. WriteContainedFile creates parent directories and +// guards against path traversal; the in-memory clone is bounded by +// LimitedFs in pkg/git, and the OCI packager re-asserts file count and +// total size limits independently. func (*defaultResolver) collectFiles(repoInfo *git.RepositoryInfo, basePath string) ([]FileEntry, error) { ref, err := repoInfo.Repository.Head() if err != nil { @@ -183,7 +190,6 @@ func (*defaultResolver) collectFiles(repoInfo *git.RepositoryInfo, basePath stri return nil, fmt.Errorf("getting tree: %w", err) } - // Navigate to subdirectory if specified if basePath != "" { tree, err = tree.Tree(basePath) if err != nil { @@ -192,32 +198,22 @@ func (*defaultResolver) collectFiles(repoInfo *git.RepositoryInfo, basePath stri } var files []FileEntry - for _, entry := range tree.Entries { - // Skip directories — we only want files at the top level of the skill dir. - // Nested subdirectories are not part of the skill spec today. - // WriteFiles includes MkdirAll as defense-in-depth for containment safety. - if entry.Mode == 0040000 { - continue - } - - file, fileErr := tree.File(entry.Name) - if fileErr != nil { - return nil, fmt.Errorf("reading file %q: %w", entry.Name, fileErr) - } - - content, contentErr := file.Contents() + err = tree.Files().ForEach(func(f *object.File) error { + content, contentErr := f.Contents() if contentErr != nil { - return nil, fmt.Errorf("reading content of %q: %w", entry.Name, contentErr) + return fmt.Errorf("reading content of %q: %w", f.Name, contentErr) } // All files are capped to 0644 by the writer; set a uniform mode here. - mode := fs.FileMode(0644) - files = append(files, FileEntry{ - Path: entry.Name, + Path: f.Name, Content: []byte(content), - Mode: mode, + Mode: fs.FileMode(0644), }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterating tree: %w", err) } return files, nil diff --git a/pkg/skills/gitresolver/resolver_test.go b/pkg/skills/gitresolver/resolver_test.go index 0dca41b5c6..22771a1464 100644 --- a/pkg/skills/gitresolver/resolver_test.go +++ b/pkg/skills/gitresolver/resolver_test.go @@ -6,6 +6,7 @@ package gitresolver import ( "context" "fmt" + "io/fs" "os" "path/filepath" "testing" @@ -70,6 +71,45 @@ func createTestRepo(t *testing.T, skillPath string, skillMD string) string { return dir } +// nestedFile describes a file to seed into a test repo at a relative path. +type nestedFile struct { + path string + content string +} + +// createNestedTestRepo creates a local git repo containing the given files +// (with arbitrary nested directory structure) and commits them in a single +// commit. It returns the repo directory path. +func createNestedTestRepo(t *testing.T, files []nestedFile) string { + t.Helper() + + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + wt, err := repo.Worktree() + require.NoError(t, err) + + for _, f := range files { + fullPath := filepath.Join(dir, filepath.FromSlash(f.path)) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0755)) + require.NoError(t, os.WriteFile(fullPath, []byte(f.content), 0644)) + } + + _, err = wt.Add(".") + require.NoError(t, err) + + _, err = wt.Commit("Add nested skill", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: "Test Author", + Email: "test@example.com", + }, + }) + require.NoError(t, err) + + return dir +} + // createTaggedTestRepo creates a local git repo with a tag, for testing tag-based clones. func createTaggedTestRepo(t *testing.T, skillMD, tagName string) string { t.Helper() @@ -173,6 +213,83 @@ description: bad name } } +func TestResolver_Resolve_NestedFiles(t *testing.T) { + t.Parallel() + + t.Run("skill in subdirectory with nested files", func(t *testing.T) { + t.Parallel() + + files := []nestedFile{ + {path: "skills/sample/SKILL.md", content: validSkillMD}, + {path: "skills/sample/README.md", content: "# Sample"}, + {path: "skills/sample/references/foo.md", content: "ref-foo"}, + {path: "skills/sample/scripts/run.sh", content: "#!/bin/sh\n"}, + {path: "skills/sample/deep/nested/dir/note.txt", content: "deep"}, + // File outside the skill subtree must NOT be picked up. + {path: "other/unrelated.md", content: "ignore me"}, + } + repoDir := createNestedTestRepo(t, files) + + resolver := NewResolver(WithGitClient(git.NewDefaultGitClient())) + ref := &GitReference{URL: repoDir, Path: "skills/sample"} + + result, err := resolver.Resolve(t.Context(), ref) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-skill", result.SkillConfig.Name) + + got := map[string]string{} + for _, f := range result.Files { + got[f.Path] = string(f.Content) + assert.Equal(t, fs.FileMode(0644), f.Mode, "file %q has unexpected mode", f.Path) + } + + // Forward-slash paths relative to the skill subtree. + assert.Equal(t, validSkillMD, got["SKILL.md"]) + assert.Equal(t, "# Sample", got["README.md"]) + assert.Equal(t, "ref-foo", got["references/foo.md"]) + assert.Equal(t, "#!/bin/sh\n", got["scripts/run.sh"]) + assert.Equal(t, "deep", got["deep/nested/dir/note.txt"]) + + // Files outside the skill subtree must not leak in. + _, hasUnrelated := got["unrelated.md"] + assert.False(t, hasUnrelated, "files outside the skill subtree must not be included") + _, hasFullOther := got["other/unrelated.md"] + assert.False(t, hasFullOther, "files outside the skill subtree must not be included") + + assert.Len(t, result.Files, 5, "expected exactly the five files under skills/sample") + }) + + t.Run("skill at repo root with nested files", func(t *testing.T) { + t.Parallel() + + files := []nestedFile{ + {path: "SKILL.md", content: validSkillMD}, + {path: "references/foo.md", content: "ref-foo"}, + {path: "scripts/run.sh", content: "#!/bin/sh\n"}, + } + repoDir := createNestedTestRepo(t, files) + + resolver := NewResolver(WithGitClient(git.NewDefaultGitClient())) + ref := &GitReference{URL: repoDir, Path: ""} + + result, err := resolver.Resolve(t.Context(), ref) + require.NoError(t, err) + require.NotNil(t, result) + + got := map[string]string{} + for _, f := range result.Files { + got[f.Path] = string(f.Content) + assert.Equal(t, fs.FileMode(0644), f.Mode, "file %q has unexpected mode", f.Path) + } + + assert.Equal(t, validSkillMD, got["SKILL.md"]) + assert.Equal(t, "ref-foo", got["references/foo.md"]) + assert.Equal(t, "#!/bin/sh\n", got["scripts/run.sh"]) + assert.Len(t, result.Files, 3) + }) +} + func TestResolver_Resolve_TagRef(t *testing.T) { t.Parallel() diff --git a/pkg/skills/gitresolver/writer_test.go b/pkg/skills/gitresolver/writer_test.go index b41b56301c..63e8be8d4b 100644 --- a/pkg/skills/gitresolver/writer_test.go +++ b/pkg/skills/gitresolver/writer_test.go @@ -98,6 +98,37 @@ func TestWriteFiles(t *testing.T) { assert.Equal(t, []byte("# Skill"), content) }) + t.Run("nested file paths create intermediate directories", func(t *testing.T) { + t.Parallel() + baseDir := resolvedTempDir(t) + targetDir := filepath.Join(baseDir, "my-skill") + + files := []FileEntry{ + {Path: "SKILL.md", Content: []byte("# Skill"), Mode: 0644}, + {Path: "references/foo.md", Content: []byte("ref-foo"), Mode: 0644}, + {Path: "scripts/nested/run.sh", Content: []byte("#!/bin/sh\n"), Mode: 0755}, + {Path: "deep/nested/dir/note.txt", Content: []byte("deep"), Mode: 0644}, + } + require.NoError(t, WriteFiles(files, targetDir, false)) + + for _, f := range files { + full := filepath.Join(targetDir, filepath.FromSlash(f.Path)) + content, readErr := os.ReadFile(full) + require.NoError(t, readErr, "file %q should exist on disk", f.Path) + assert.Equal(t, f.Content, content) + + info, statErr := os.Stat(full) + require.NoError(t, statErr) + mode := info.Mode().Perm() + assert.True(t, mode <= 0644, "file %q has mode %o, expected <= 0644", f.Path, mode) + } + + // Spot-check that an intermediate directory was created. + info, statErr := os.Stat(filepath.Join(targetDir, "scripts", "nested")) + require.NoError(t, statErr) + assert.True(t, info.IsDir(), "intermediate dir scripts/nested should be a directory") + }) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/pkg/skills/skillsvc/build.go b/pkg/skills/skillsvc/build.go index c5a762c58a..eb89da02a1 100644 --- a/pkg/skills/skillsvc/build.go +++ b/pkg/skills/skillsvc/build.go @@ -40,6 +40,18 @@ func (s *service) Build(ctx context.Context, opts skills.BuildOptions) (*skills. } result, err := s.packager.Package(ctx, opts.Path, ociskills.DefaultPackageOptions()) if err != nil { + // User-input failures (missing SKILL.md, bad frontmatter, symlinks, + // size/count limits, unreadable directory) are surfaced as 400 with + // the packager's message intact. Anything else is a real 500. + switch { + case errors.Is(err, ociskills.ErrSkillMDMissing), + errors.Is(err, ociskills.ErrInvalidFrontmatter), + errors.Is(err, ociskills.ErrInvalidSkillDir), + errors.Is(err, ociskills.ErrInvalidSkillFile), + errors.Is(err, ociskills.ErrTooManyFiles), + errors.Is(err, ociskills.ErrSkillTooLarge): + return nil, httperr.WithCode(err, http.StatusBadRequest) + } return nil, fmt.Errorf("packaging skill: %w", err) } @@ -64,7 +76,13 @@ func (s *service) Build(ctx context.Context, opts skills.BuildOptions) (*skills. } if tag != "" { - if tagErr := s.ociStore.Tag(ctx, result.IndexDigest, tag); tagErr != nil { + // Tag the artifact and stamp the local-build marker on the root-index + // descriptor entry so ListBuilds can distinguish this tag from ones + // created by OCI pulls (install, content preview). The marker lives + // at the descriptor level in index.json, not in the manifest blob, + // so it doesn't change the artifact digest and is not carried across + // push (push resolves by digest, which returns a plain descriptor). + if tagErr := tagAsLocalBuild(ctx, s.ociStore, result.IndexDigest, tag); tagErr != nil { return nil, fmt.Errorf("tagging artifact: %w", tagErr) } } @@ -111,6 +129,9 @@ func (s *service) Push(ctx context.Context, opts skills.PushOptions) error { } // ListBuilds returns all locally-built OCI skill artifacts in the local store. +// Tags are filtered by the local-build descriptor annotation (set by Build), +// so artifacts pulled into the store by install or the content API for +// caching do not appear here. func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { if s.ociStore == nil { return nil, httperr.WithCode( @@ -121,11 +142,20 @@ func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { tags, err := s.ociStore.ListTags(ctx) if err != nil { - return nil, fmt.Errorf("listing local OCI builds: %w", err) + return nil, fmt.Errorf("listing OCI tags: %w", err) } builds := make([]skills.LocalBuild, 0, len(tags)) for _, tag := range tags { + local, markerErr := isLocalBuild(ctx, s.ociStore, tag) + if markerErr != nil { + slog.Debug("failed to read local-build marker", "tag", tag, "error", markerErr) + continue + } + if !local { + continue + } + d, resolveErr := s.ociStore.Resolve(ctx, tag) if resolveErr != nil { slog.Debug("failed to resolve tag in local OCI store", "tag", tag, "error", resolveErr) @@ -163,7 +193,8 @@ func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { // DeleteBuild removes a locally-built OCI skill artifact from the local store. // It deletes the tag and, when no other tag shares the same digest, also -// garbage-collects all associated blobs. +// garbage-collects all associated blobs. The local-build descriptor +// annotation disappears from index.json together with the tag. func (s *service) DeleteBuild(ctx context.Context, tag string) error { if s.ociStore == nil { return httperr.WithCode( diff --git a/pkg/skills/skillsvc/build_test.go b/pkg/skills/skillsvc/build_test.go index 04569e8e7a..ae53da68bd 100644 --- a/pkg/skills/skillsvc/build_test.go +++ b/pkg/skills/skillsvc/build_test.go @@ -4,6 +4,7 @@ package skillsvc import ( + "encoding/json" "fmt" "net/http" "os" @@ -11,6 +12,7 @@ import ( "strings" "testing" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -81,12 +83,13 @@ func TestBuild(t *testing.T) { t.Parallel() tests := []struct { - name string - opts skills.BuildOptions - setup func(*gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) - wantCode int - wantRef string - wantErr string + name string + opts skills.BuildOptions + setup func(*gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) + wantCode int + wantRef string + wantErr string + wantErrIs error }{ { name: "nil packager returns 500", @@ -156,6 +159,76 @@ func TestBuild(t *testing.T) { }, wantErr: "packaging skill", }, + { + name: "missing SKILL.md returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("reading skill directory: %w", ociskills.ErrSkillMDMissing)) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + wantErrIs: ociskills.ErrSkillMDMissing, + }, + { + name: "invalid frontmatter returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("parsing frontmatter YAML: %w", ociskills.ErrInvalidFrontmatter)) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + wantErrIs: ociskills.ErrInvalidFrontmatter, + }, + { + name: "empty name in frontmatter returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("skill name is required in SKILL.md frontmatter: %w", ociskills.ErrInvalidFrontmatter)) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + wantErrIs: ociskills.ErrInvalidFrontmatter, + }, + { + name: "symlink in skill dir returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("symlinks not allowed in skill directory: sub/link: %w", ociskills.ErrInvalidSkillFile)) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + wantErrIs: ociskills.ErrInvalidSkillFile, + }, + { + name: "oversized dir returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("skill directory exceeds maximum total size: %w", ociskills.ErrSkillTooLarge)) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + wantErrIs: ociskills.ErrSkillTooLarge, + }, { name: "successful build with explicit tag", opts: skills.BuildOptions{Path: "/some/dir", Tag: "v1.0.0"}, @@ -241,6 +314,9 @@ func TestBuild(t *testing.T) { if tt.wantCode != 0 { require.Error(t, err) assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } return } if tt.wantErr != "" { @@ -446,7 +522,7 @@ func TestListBuilds(t *testing.T) { // Build a real artifact via the packager so extractOCIContent works. d := buildTestArtifact(t, ociStore, "my-skill", "1.2.3") - require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "my-skill")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) artifacts, err := svc.ListBuilds(t.Context()) @@ -465,9 +541,9 @@ func TestListBuilds(t *testing.T) { require.NoError(t, err) d1 := buildTestArtifact(t, ociStore, "skill-a", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d1, "skill-a")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d1, "skill-a")) d2 := buildTestArtifact(t, ociStore, "skill-b", "2.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d2, "skill-b")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d2, "skill-b")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) artifacts, err := svc.ListBuilds(t.Context()) @@ -493,7 +569,7 @@ func TestListBuilds(t *testing.T) { skillIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"dev.toolhive.skills.v1","manifests":[]}` d, putErr := ociStore.PutManifest(t.Context(), []byte(skillIndex)) require.NoError(t, putErr) - require.NoError(t, ociStore.Tag(t.Context(), d, "bare-skill-tag")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "bare-skill-tag")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) artifacts, err := svc.ListBuilds(t.Context()) @@ -513,14 +589,15 @@ func TestListBuilds(t *testing.T) { // Store a valid skill artifact that should be returned. skillDigest := buildTestArtifact(t, ociStore, "real-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), skillDigest, "real-skill")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, skillDigest, "real-skill")) - // Store an index whose ArtifactType is not the skill type — simulates a - // remotely-pulled non-skill OCI artifact sharing the same store. + // Store an index whose ArtifactType is not the skill type. Tagging it + // as a local build simulates a caller that mistakenly flagged a + // non-skill artifact — ListBuilds must still exclude it by type. otherIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.docker.distribution.manifest.v2","manifests":[]}` otherDigest, putErr := ociStore.PutManifest(t.Context(), []byte(otherIndex)) require.NoError(t, putErr) - require.NoError(t, ociStore.Tag(t.Context(), otherDigest, "non-skill-tag")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, otherDigest, "non-skill-tag")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) artifacts, err := svc.ListBuilds(t.Context()) @@ -528,6 +605,62 @@ func TestListBuilds(t *testing.T) { require.Len(t, artifacts, 1) assert.Equal(t, "real-skill", artifacts[0].Tag) }) + + t.Run("pulled tags are hidden from ListBuilds", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + + // Simulate a pull: tag the artifact via the plain ociStore.Tag path, + // which mirrors what Registry.Pull does internally (resolve by digest + // → plain descriptor → no local-build annotation). + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "ghcr.io/org/my-skill:v1.0.0")) + + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Empty(t, artifacts, "pulled tags must not appear in ListBuilds") + }) + + t.Run("only locally-built tags are listed when pull and build coexist", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + + // Pulled artifact: tagged without the local-build marker. + pulled := buildTestArtifact(t, ociStore, "pulled-skill", "9.9.9") + require.NoError(t, ociStore.Tag(t.Context(), pulled, "ghcr.io/org/pulled-skill:v9.9.9")) + + // Locally-built artifact: tagged with the marker. + built := buildTestArtifact(t, ociStore, "built-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, built, "built-skill")) + + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Len(t, artifacts, 1) + assert.Equal(t, "built-skill", artifacts[0].Tag) + }) + + t.Run("pre-feature tags without the marker do not appear", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // Tag via the plain path, as if the artifact had been built before + // this feature existed. The honest gap: ListBuilds hides it until + // the user rebuilds and re-tags. + d := buildTestArtifact(t, ociStore, "legacy-build", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "legacy-build")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Empty(t, artifacts) + }) } func TestDeleteBuild(t *testing.T) { @@ -547,7 +680,7 @@ func TestDeleteBuild(t *testing.T) { require.NoError(t, err) d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "my-skill")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) require.NoError(t, svc.DeleteBuild(t.Context(), "my-skill")) @@ -575,8 +708,8 @@ func TestDeleteBuild(t *testing.T) { require.NoError(t, err) d := buildTestArtifact(t, ociStore, "shared-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d, "tag-a")) - require.NoError(t, ociStore.Tag(t.Context(), d, "tag-b")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "tag-a")) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "tag-b")) svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) require.NoError(t, svc.DeleteBuild(t.Context(), "tag-a")) @@ -587,4 +720,79 @@ func TestDeleteBuild(t *testing.T) { require.Len(t, builds, 1) assert.Equal(t, "tag-b", builds[0].Tag) }) + + t.Run("delete removes local-build marker from index.json", func(t *testing.T) { + t.Parallel() + storeRoot := t.TempDir() + ociStore, err := ociskills.NewStore(storeRoot) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "my-skill")) + + // Sanity: the marker is on the tagged descriptor. + require.True(t, indexContainsTaggedMarker(t, storeRoot, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + require.NoError(t, svc.DeleteBuild(t.Context(), "my-skill")) + + assert.False(t, indexContainsTaggedMarker(t, storeRoot, "my-skill"), + "descriptor carrying the marker must be gone after DeleteBuild") + }) +} + +func TestBuild_StampsLocalBuildAnnotation(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + storeRoot := t.TempDir() + ociStore, err := ociskills.NewStore(storeRoot) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{Name: "my-skill"}, + }, nil) + + svc := New(&storage.NoopSkillStore{}, + WithPackager(p), + WithOCIStore(ociStore), + ) + + _, err = svc.Build(t.Context(), skills.BuildOptions{Path: "/some/dir", Tag: "my-skill"}) + require.NoError(t, err) + + // After a successful Build, the tag must be surfaced by ListBuilds + // because the root-index descriptor carries the local-build marker. + builds, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Len(t, builds, 1) + assert.Equal(t, "my-skill", builds[0].Tag) + + // The marker must land on the descriptor entry in index.json. + assert.True(t, indexContainsTaggedMarker(t, storeRoot, "my-skill"), + "root index.json must carry the local-build annotation for the tag") +} + +// indexContainsTaggedMarker reads the OCI store's root index.json and reports +// whether the descriptor entry tagged `tag` has the local-build annotation. +func indexContainsTaggedMarker(t *testing.T, storeRoot, tag string) bool { + t.Helper() + data, err := os.ReadFile(filepath.Join(storeRoot, "index.json")) + require.NoError(t, err) + var idx ocispec.Index + require.NoError(t, json.Unmarshal(data, &idx)) + for _, m := range idx.Manifests { + if m.Annotations[ocispec.AnnotationRefName] != tag { + continue + } + if m.Annotations[LocalBuildAnnotation] == "true" { + return true + } + } + return false } diff --git a/pkg/skills/skillsvc/content.go b/pkg/skills/skillsvc/content.go index 507cf6a13d..a4aede2f7d 100644 --- a/pkg/skills/skillsvc/content.go +++ b/pkg/skills/skillsvc/content.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "path/filepath" "strings" @@ -57,14 +58,36 @@ func (s *service) GetContent(ctx context.Context, opts skills.ContentOptions) (* return content, nil } - // OCI failed — try resolving via registry name lookup (e.g. "skill-creator" - // or "io.github.stacklok/skill-creator" from the catalog index). Skip for - // refs that are unambiguously OCI (digest, explicit tag, or multi-segment - // path) to avoid a wasted network round-trip. + // OCI failed. The fallback strategy depends on how the caller referenced + // the skill: + // + // - Unambiguous OCI refs (tag/digest/multi-segment path) — try a + // registry lookup *by OCI identifier* to find the same skill's git + // package, so an ephemeral OCI outage can transparently fall back to + // git when the registry catalog has both. + // - Ambiguous refs (plain skill name) — use the existing name-based + // registry resolution which preferred OCI → git. if parsedRef, isOCI, parseErr := parseOCIReference(ref); parseErr == nil && isOCI && isUnambiguousOCIRef(ref, parsedRef) { + if gitURL := s.resolveGitFallbackForOCIRef(parsedRef); gitURL != "" { + slog.Info( + "OCI content fetch failed; falling back to git package declared in registry entry", + "oci_ref", ref, + "git_url", gitURL, + "oci_error", ociErr, + ) + c, gitErr := s.getContentFromGit(ctx, gitURL) + if gitErr == nil { + return c, nil + } + return nil, fmt.Errorf( + "OCI pull failed (%w); registry git fallback also failed: %v", + ociErr, gitErr, + ) + } return nil, ociErr } + resolved, regErr := s.resolveFromRegistry(ref) if regErr != nil { return nil, regErr @@ -165,12 +188,18 @@ func (s *service) getContentFromOCI(ctx context.Context, ref string) (*skills.Sk pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) defer cancel() + // Content-preview pulls intentionally do NOT carry the local-build + // marker: Registry.Pull tags by digest, which returns a plain + // descriptor from the OCI store, so no annotations land on the + // root-index entry. The pulled blobs stay in the OCI store as a + // cache, but the tag is invisible to ListBuilds so remote skills + // browsed via the content API don't pollute the local builds listing. var pullErr error d, pullErr = s.registry.Pull(pullCtx, s.ociStore, qualifiedRef) if pullErr != nil { return nil, httperr.WithCode( fmt.Errorf("pulling OCI artifact %q: %w", qualifiedRef, pullErr), - http.StatusBadGateway, + classifyPullError(pullErr), ) } } diff --git a/pkg/skills/skillsvc/content_test.go b/pkg/skills/skillsvc/content_test.go index 2ee52b512a..352b756a1c 100644 --- a/pkg/skills/skillsvc/content_test.go +++ b/pkg/skills/skillsvc/content_test.go @@ -255,6 +255,290 @@ func TestGetContent(t *testing.T) { assert.Contains(t, content.Body, "Skill Creator") }) + t.Run("remote pull does not pollute ListBuilds", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // The real Pull would tag the pulled artifact in the local store. + // Simulate that side-effect here so we can verify ListBuilds still + // reports an empty list: pulls tag by digest, which yields a plain + // descriptor, so the local-build marker is never applied. + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT(). + Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + DoAndReturn(func(ctx context.Context, store *ociskills.Store, _ string) (godigest.Digest, error) { + d := buildTestArtifact(t, store, "my-skill", "1.0.0") + require.NoError(t, store.Tag(ctx, d, "ghcr.io/org/my-skill:v1")) + return d, nil + }) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + ) + + // Baseline: no builds before the content request. + builds, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Empty(t, builds) + + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "ghcr.io/org/my-skill:v1", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + + // After a content-preview pull, ListBuilds must still be empty: the + // blobs stay on disk as a cache but the tag is not treated as a local build. + builds, err = svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Empty(t, builds, "content API must not leak pulled artifacts into ListBuilds") + }) + + t.Run("unambiguous OCI falls back to registry-declared git package on pull failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/stacklok/dockyard/skills/yara-rule-authoring:0.1.0"). + Return(godigest.Digest(""), fmt.Errorf("registry unreachable")) + + // Registry entry has both an OCI package (the one we just failed to + // pull) and a git package with pinned commit. The fallback should + // reach the git resolver. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("yara-rule-authoring").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "yara-rule-authoring", + Packages: []regtypes.SkillPackage{ + { + RegistryType: "oci", + Identifier: "ghcr.io/stacklok/dockyard/skills/yara-rule-authoring:0.1.0", + }, + { + RegistryType: "git", + URL: "https://github.com/trailofbits/skills", + Ref: "e8cc5baf9329ccb491bfa200e82eacbac83b1ead", + Subfolder: "plugins/yara-authoring/skills/yara-rule-authoring", + }, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, ref *gitresolver.GitReference) (*gitresolver.ResolveResult, error) { + // Verify the fallback pinned ref and subfolder. Scheme depends on + // TOOLHIVE_DEV (http in dev, https in prod) so we only assert the + // suffix here. + assert.True(t, strings.HasSuffix(ref.URL, "://github.com/trailofbits/skills"), + "unexpected clone URL %q", ref.URL) + assert.Equal(t, "e8cc5baf9329ccb491bfa200e82eacbac83b1ead", ref.Ref) + assert.Equal(t, "plugins/yara-authoring/skills/yara-rule-authoring", ref.Path) + return &gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{ + Name: "yara-rule-authoring", + Body: []byte("# YARA Rule Authoring"), + }, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("# YARA"), Mode: 0644}}, + CommitHash: testCommitHash, + }, nil + }) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "ghcr.io/stacklok/dockyard/skills/yara-rule-authoring:0.1.0", + }) + require.NoError(t, err) + assert.Equal(t, "yara-rule-authoring", content.Name) + assert.Contains(t, content.Body, "YARA Rule Authoring") + }) + + t.Run("registry fallback tolerates different OCI version tag", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v9"). + Return(godigest.Digest(""), fmt.Errorf("manifest unknown")) + + // The registry entry records :0.1.0 while the caller asked for :v9. + // Both resolve to the same repository path so the fallback must fire. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.example", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/org/my-skill:0.1.0"}, + {RegistryType: "git", URL: "https://github.com/example/repo"}, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Body: []byte("# git fallback")}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("# x"), Mode: 0644}}, + CommitHash: testCommitHash, + }, nil) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v9"}) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + }) + + t.Run("OCI failure with registry match but no git package returns original error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("registry offline")) + + // Registry entry matches but only has an OCI package. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.example", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/org/my-skill:v1"}, + }, + }, + }, nil) + + // Git resolver must NOT be invoked. + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) + assert.Contains(t, err.Error(), "registry offline") + }) + + t.Run("OCI failure with ambiguous registry matches skips git fallback", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("registry offline")) + + // Two registry entries both point at the same repo path — ambiguous. + // We refuse to guess and propagate the original OCI error. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.alice", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/org/my-skill:v1"}, + {RegistryType: "git", URL: "https://github.com/alice/repo"}, + }, + }, + { + Namespace: "io.github.bob", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/org/my-skill:v2"}, + {RegistryType: "git", URL: "https://github.com/bob/repo"}, + }, + }, + }, nil) + + // Git resolver must NOT be invoked. + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) + assert.Contains(t, err.Error(), "registry offline") + }) + + t.Run("OCI success skips registry lookup entirely", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v2"). + Return(indexDigest, nil) + + // lookup mock with NO expectations — gomock will fail the test if + // SearchSkills is ever invoked. + lookup := regmocks.NewMockProvider(ctrl) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v2"}) + require.NoError(t, err) + }) + + t.Run("registry lookup error treated as no fallback, returns original OCI error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("registry offline")) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return(nil, fmt.Errorf("registry index unreachable")) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + WithSkillLookup(lookup), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) + assert.Contains(t, err.Error(), "registry offline") + }) + t.Run("qualified namespace/name filters registry matches", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/pkg/skills/skillsvc/install.go b/pkg/skills/skillsvc/install.go index 7abb19e741..028f78530f 100644 --- a/pkg/skills/skillsvc/install.go +++ b/pkg/skills/skillsvc/install.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "github.com/stacklok/toolhive-core/httperr" "github.com/stacklok/toolhive/pkg/groups" @@ -40,6 +41,17 @@ func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*ski return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) } + // When the caller supplies `version` separately and the name is a tag-less + // OCI-like reference (contains '/' but no ':' or '@'), splice the version + // in as the tag. Without this, parseOCIReference + qualifiedOCIRef would + // default the pull to ":latest" and silently drop opts.Version. An + // explicit tag in the name still wins (we only splice when none is set). + if opts.Version != "" && + strings.ContainsRune(opts.Name, '/') && + !strings.ContainsAny(opts.Name, ":@") { + opts.Name = opts.Name + ":" + opts.Version + } + ref, isOCI, err := parseOCIReference(opts.Name) if err != nil { return nil, httperr.WithCode( diff --git a/pkg/skills/skillsvc/install_oci.go b/pkg/skills/skillsvc/install_oci.go index bdd7bad17f..f77be955df 100644 --- a/pkg/skills/skillsvc/install_oci.go +++ b/pkg/skills/skillsvc/install_oci.go @@ -13,8 +13,10 @@ import ( "time" nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/opencontainers/go-digest" "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" "github.com/stacklok/toolhive/pkg/skills" ) @@ -52,11 +54,16 @@ func (s *service) installFromOCI( pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) defer cancel() + // Install pulls intentionally do NOT carry the local-build marker: + // Registry.Pull tags by digest, which returns a plain descriptor from + // the OCI store, so no annotations land on the root-index entry. The + // pulled blobs stay in the OCI store as a cache, but the tag is invisible + // to ListBuilds so installed remote skills don't appear as local builds. pulledDigest, err := s.registry.Pull(pullCtx, s.ociStore, ociRef) if err != nil { return nil, httperr.WithCode( fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), - http.StatusBadGateway, + classifyPullError(err), ) } @@ -108,15 +115,38 @@ func (s *service) installFromOCI( return s.installWithExtraction(ctx, opts, scope) } -// resolveFromLocalStore attempts to resolve a skill name as a tag in the local -// OCI store. On success it hydrates opts with layer data, digest, and version -// from the artifact. Returns (true, nil) when resolved, (false, nil) when the -// tag is not found, or (false, err) on validation/extraction failure. +// resolveFromLocalStore attempts to resolve a skill name against the local +// OCI store. It first tries a direct tag lookup (the common "build then +// install by skill name" case). When that misses, or when the direct match's +// declared version does not satisfy opts.Version, it falls back to scanning +// local-build-marked tags for one whose declared skill name matches opts.Name +// (so users can install a build that was tagged with something other than +// the skill name, e.g. `--tag v0.0.1`). Returns (true, nil) when resolved +// (opts is hydrated with layer data, digest, reference, and version), +// (false, nil) when no match is found, or (false, err) on validation, +// extraction, or ambiguity failures. func (s *service) resolveFromLocalStore(ctx context.Context, opts *skills.InstallOptions) (bool, error) { + resolved, err := s.tryDirectLocalTag(ctx, opts) + if err != nil || resolved { + return resolved, err + } + return s.tryLocalBuildScan(ctx, opts) +} + +// tryDirectLocalTag attempts to resolve opts.Name as a literal tag in the +// local OCI store and verifies the artifact's declared skill name matches. +// Returns (true, nil) when the tag resolves and both name and (if specified) +// version match. Returns (false, err) on supply-chain mismatch or extraction +// failure. Returns (false, nil) when no such tag exists OR when name matches +// but a caller-supplied version does not — letting the caller scan for a +// better match instead of erroring. +func (s *service) tryDirectLocalTag( + ctx context.Context, + opts *skills.InstallOptions, +) (bool, error) { d, err := s.ociStore.Resolve(ctx, opts.Name) if err != nil { // Tag not found in the local store — not an error, just unresolved. - slog.Debug("skill name not found in local OCI store", "name", opts.Name, "error", err) return false, nil } @@ -145,14 +175,179 @@ func (s *service) resolveFromLocalStore(ctx context.Context, opts *skills.Instal ) } + // Version constraint: when the caller specifies a version, the direct + // match must agree. If not, fall through to the scan so a sibling build + // with the requested version can win. + if opts.Version != "" && skillConfig.Version != opts.Version { + return false, nil + } + + hydrateOptsFromLocalBuild(opts, layerData, d, skillConfig, opts.Name) + return true, nil +} + +// tryLocalBuildScan walks local-build-marked tags for a skill artifact whose +// declared name (and version, when specified) matches opts. With one match +// it hydrates opts; with multiple, it applies the tag-equals-name tie-breaker +// before surfacing a 409 ambiguity error. +func (s *service) tryLocalBuildScan( + ctx context.Context, + opts *skills.InstallOptions, +) (bool, error) { + matches, err := s.findLocalBuildsByName(ctx, opts.Name, opts.Version) + if err != nil { + return false, err + } + if len(matches) == 0 { + slog.Debug("skill name not found in local OCI store", + "name", opts.Name, "version", opts.Version) + return false, nil + } + + chosen := pickLocalBuildMatch(matches, opts.Name) + if chosen == nil { + return false, ambiguousLocalBuildError(opts.Name, matches) + } + hydrateOptsFromLocalBuild(opts, chosen.LayerData, chosen.Digest, chosen.Config, chosen.Tag) + return true, nil +} + +// localBuildMatch is a candidate match found by scanning local-build-marked +// tags for a skill with a given name (and optional version). +type localBuildMatch struct { + Tag string + Digest digest.Digest + LayerData []byte + Config *ociskills.SkillConfig +} + +// findLocalBuildsByName scans local-build-marked tags for skill artifacts +// whose declared name matches `name`. When version is non-empty, it also +// filters by the artifact's declared version. Tags without the local-build +// marker (e.g. install/content caches) and non-skill artifacts are skipped, +// mirroring ListBuilds. Per-tag inspection errors are logged and the tag is +// skipped, so a single corrupt entry does not poison the whole lookup. +func (s *service) findLocalBuildsByName( + ctx context.Context, + name, version string, +) ([]localBuildMatch, error) { + tags, err := s.ociStore.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("listing local OCI tags: %w", err) + } + + var matches []localBuildMatch + for _, tag := range tags { + local, markerErr := isLocalBuild(ctx, s.ociStore, tag) + if markerErr != nil { + slog.Debug("failed to read local-build marker", "tag", tag, "error", markerErr) + continue + } + if !local { + continue + } + + d, resolveErr := s.ociStore.Resolve(ctx, tag) + if resolveErr != nil { + slog.Debug("failed to resolve local-build tag", "tag", tag, "error", resolveErr) + continue + } + + isSkill, typeErr := s.isSkillArtifact(ctx, d) + if typeErr != nil { + slog.Debug("failed to check artifact type", "tag", tag, "error", typeErr) + continue + } + if !isSkill { + continue + } + + layerData, cfg, extractErr := s.extractOCIContent(ctx, d) + if extractErr != nil { + slog.Debug("failed to extract local-build content", "tag", tag, "error", extractErr) + continue + } + if cfg == nil || cfg.Name != name { + continue + } + // Match `version` against either the artifact's declared version + // (cfg.Version, populated from SKILL.md frontmatter) or the OCI tag + // in the local store. SKILL.md frontmatter often omits the version + // while the build tag carries it (e.g. `thv skill build --tag v0.0.1`), + // and callers seeing `{tag: "v0.0.1"}` in GET /skills/builds naturally + // pass that string as `version`. This also matches the OCI-name path, + // which already treats `version` as the tag (see the splice in + // install.go). + if version != "" && cfg.Version != version && tag != version { + continue + } + + matches = append(matches, localBuildMatch{ + Tag: tag, + Digest: d, + LayerData: layerData, + Config: cfg, + }) + } + return matches, nil +} + +// pickLocalBuildMatch selects a single match among scan results. With one +// match it returns it directly; with multiple, it prefers the match whose +// tag exactly equals name (the "tagged with skill name" case). When more +// than one match remains and none has Tag == name, returns nil to signal +// the caller to surface an ambiguity error. +func pickLocalBuildMatch(matches []localBuildMatch, name string) *localBuildMatch { + if len(matches) == 1 { + return &matches[0] + } + for i := range matches { + if matches[i].Tag == name { + return &matches[i] + } + } + return nil +} + +// ambiguousLocalBuildError builds a 409 error listing each ambiguous match's +// tag and version so the caller knows which builds collide. Returned only +// when the tag-equals-name tie-breaker fails to resolve a unique winner. +func ambiguousLocalBuildError(name string, matches []localBuildMatch) error { + lines := make([]string, 0, len(matches)) + for _, m := range matches { + var version string + if m.Config != nil { + version = m.Config.Version + } + lines = append(lines, fmt.Sprintf(" - tag %q, version %q", m.Tag, version)) + } + return httperr.WithCode( + fmt.Errorf( + "multiple local builds match skill %q; specify version to disambiguate. Candidates:\n%s", + name, strings.Join(lines, "\n"), + ), + http.StatusConflict, + ) +} + +// hydrateOptsFromLocalBuild populates opts.LayerData, Digest, Reference, and +// Version from a resolved local-build artifact. Reference defaults to the tag +// in the local store so subsequent operations (e.g. push) can re-resolve it. +// A caller-supplied Version takes precedence over the artifact's declared +// version; a caller-supplied Reference is preserved. +func hydrateOptsFromLocalBuild( + opts *skills.InstallOptions, + layerData []byte, + d digest.Digest, + cfg *ociskills.SkillConfig, + tag string, +) { opts.LayerData = layerData opts.Digest = d.String() if opts.Reference == "" { - opts.Reference = opts.Name + opts.Reference = tag } - if opts.Version == "" && skillConfig.Version != "" { - opts.Version = skillConfig.Version + if opts.Version == "" && cfg != nil && cfg.Version != "" { + opts.Version = cfg.Version } - - return true, nil } diff --git a/pkg/skills/skillsvc/install_oci_test.go b/pkg/skills/skillsvc/install_oci_test.go index 9a51da7ac5..db4e1daa58 100644 --- a/pkg/skills/skillsvc/install_oci_test.go +++ b/pkg/skills/skillsvc/install_oci_test.go @@ -301,6 +301,60 @@ func TestInstallFromOCI(t *testing.T) { } } +func TestInstallFromOCI_DoesNotLeakIntoListBuilds(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + // Simulate the real Pull side effect: tag the artifact in the local + // store. installFromOCI must not apply the local-build marker — Pull + // tags by digest, which yields a plain descriptor. + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT(). + Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + DoAndReturn(func(ctx context.Context, store *ociskills.Store, _ string) (godigest.Digest, error) { + require.NoError(t, store.Tag(ctx, indexDigest, "ghcr.io/org/my-skill:v1")) + return indexDigest, nil + }) + + skillStore := storemocks.NewMockSkillStore(ctrl) + skillStore.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). + Return(skills.InstalledSkill{}, storage.ErrNotFound) + skillStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + + svc := New(skillStore, + WithRegistryClient(reg), + WithOCIStore(ociStore), + WithPathResolver(pr), + ) + + // Baseline: no builds before the install. + builds, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Empty(t, builds) + + _, err = svc.Install(t.Context(), skills.InstallOptions{ + Name: "ghcr.io/org/my-skill:v1", + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + + // After the install, the OCI store contains the pulled artifact but + // ListBuilds must still be empty — only `thv skill build` output shows up. + builds, err = svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Empty(t, builds, "install pulls must not leak into ListBuilds") +} + func TestInstallFromLocalStore(t *testing.T) { t.Parallel() @@ -412,6 +466,216 @@ func TestInstallFromLocalStore(t *testing.T) { }, wantErr: "checking OCI content type", }, + { + // Mirrors the original GH issue: a build tagged with an OCI tag + // (e.g. `--tag v1.0.0`) must still be installable by skill name. + name: "scan resolves by skill name when tag differs", + opts: skills.InstallOptions{Name: "my-skill", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Build "my-skill" but tag it as "v1.0.0" (typical + // `thv skill build --tag v1.0.0` flow). + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, indexDigest, "v1.0.0")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "my-skill", sk.Metadata.Name) + assert.Equal(t, "1.0.0", sk.Metadata.Version) + // Reference defaults to the local store tag so push + // can re-resolve the artifact later. + assert.Equal(t, "v1.0.0", sk.Reference) + assert.Contains(t, sk.Digest, "sha256:") + assert.Equal(t, skills.InstallStatusInstalled, sk.Status) + return nil + }) + return ociStore, store, pr + }, + wantStatus: string(skills.InstallStatusInstalled), + wantVersion: "1.0.0", + wantDigest: true, + }, + { + // User-facing reproducer: GET /skills/builds shows + // `{tag: "v0.0.1", name: ""}` (cfg.Version may be empty + // or differ from the tag), and a caller passing the displayed + // `tag` as `version` must still resolve. `version` is matched + // against either cfg.Version OR the local store tag, mirroring + // the OCI-name path that already treats `version` as the tag. + name: "scan version matches local store tag", + opts: skills.InstallOptions{Name: "my-skill", Version: "v1.0.0", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Artifact's cfg.Version is "1.0.0" (no leading 'v'); the + // caller passes the tag "v1.0.0" as `version`. + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "v1.0.0")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + // Reference defaults to the resolved tag. + assert.Equal(t, "v1.0.0", sk.Reference) + // cfg.Version (1.0.0) hydrates Metadata.Version + // because the caller-supplied version was matched + // via the tag, not via cfg.Version. + assert.Equal(t, "v1.0.0", sk.Metadata.Version) + return nil + }) + return ociStore, store, pr + }, + wantStatus: string(skills.InstallStatusInstalled), + }, + { + // Two local builds share a skill name but differ in version. + // A caller-supplied version narrows the scan to one match. + name: "scan version filter selects matching build", + opts: skills.InstallOptions{Name: "my-skill", Version: "2.0.0", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + d1 := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d1, "v1")) + d2 := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d2, "v2")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "2.0.0", sk.Metadata.Version) + assert.Equal(t, "v2", sk.Reference) + return nil + }) + return ociStore, store, pr + }, + wantStatus: string(skills.InstallStatusInstalled), + wantVersion: "2.0.0", + }, + { + // Two builds with the same skill name and same version (only + // differ by tag), and no version specified: the scan is + // genuinely ambiguous and must surface a 409 listing both. + name: "scan ambiguous matches return 409 with candidate list", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + d1 := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d1, "alpha")) + d2 := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d2, "beta")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantCode: http.StatusConflict, + wantErr: "multiple local builds match", + }, + { + // A caller-supplied version that no local build satisfies must + // fall through to the registry lookup (which returns 404 here + // since no registry is configured) rather than picking a + // non-matching build or returning the ambiguity error. + name: "scan version mismatch falls through to registry", + opts: skills.InstallOptions{Name: "my-skill", Version: "9.9.9"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "v1")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + // A direct tag match whose artifact name agrees with opts.Name + // but whose version disagrees must fall through to the scan, + // which can then locate a sibling build with the right version. + name: "direct version mismatch falls through to scan", + opts: skills.InstallOptions{Name: "my-skill", Version: "2.0.0", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Canonical tag at version 1.0.0. + d1 := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d1, "my-skill")) + // Sibling build at version 2.0.0. + d2 := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d2, "next")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "2.0.0", sk.Metadata.Version) + assert.Equal(t, "next", sk.Reference) + return nil + }) + return ociStore, store, pr + }, + wantStatus: string(skills.InstallStatusInstalled), + wantVersion: "2.0.0", + }, + { + // A tag carrying the local-build marker on a non-skill artifact + // (defensive case — Build never produces this, but the index + // file is user-editable) must be ignored by the scan. + name: "scan ignores non-skill local-build-marked tags", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Local-build-marked tag pointing at an index whose + // ArtifactType is not the skill type. + otherIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.docker.distribution.manifest.v2","manifests":[]}` + d, putErr := ociStore.PutManifest(t.Context(), []byte(otherIndex)) + require.NoError(t, putErr) + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d, "non-skill-tag")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, } for _, tt := range tests { @@ -434,6 +698,9 @@ func TestInstallFromLocalStore(t *testing.T) { if tt.wantCode != 0 { require.Error(t, err) assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } return } if tt.wantErr != "" { diff --git a/pkg/skills/skillsvc/local_build_marker.go b/pkg/skills/skillsvc/local_build_marker.go new file mode 100644 index 0000000000..6b85b92bf3 --- /dev/null +++ b/pkg/skills/skillsvc/local_build_marker.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + + "github.com/opencontainers/go-digest" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" +) + +// LocalBuildAnnotation marks a tag in the local OCI store as produced by +// `thv skill build` rather than an OCI pull (install, content preview). +// The value is always "true" when set; absence means "not a local build". +// +// The annotation is stamped at the descriptor level inside the store's root +// index.json, not on the manifest content. Two properties follow from that: +// +// 1. Push resolves the artifact by digest, which returns a plain descriptor +// (oras-go's oci.Store strips annotations when the reference is a digest), +// so the marker never crosses the wire. +// 2. Pull calls Store.Tag with the pulled digest, which also resolves by +// digest before tagging, so pulled tags inherit no annotations and stay +// invisible to ListBuilds. +// +// The key is reverse-DNS namespaced so it composes with other locally-built +// artifact types (containers, MCP server images) in the future. +const LocalBuildAnnotation = "dev.stacklok.toolhive.local-build" + +// tagAsLocalBuild tags digest d with the given tag and stamps the local-build +// marker on the root-index descriptor entry. Equivalent to ociStore.Tag plus +// a descriptor annotation; callers must only invoke it from code paths that +// genuinely produced the artifact locally (currently only Build). +func tagAsLocalBuild(ctx context.Context, store *ociskills.Store, d digest.Digest, tag string) error { + target := store.Target() + desc, err := target.Resolve(ctx, d.String()) + if err != nil { + return fmt.Errorf("resolving digest for tag: %w", err) + } + // Resolve-by-digest returns a plain descriptor in oras-go, so overwriting + // Annotations can't clobber anything meaningful on the descriptor itself. + // (Content-level annotations live on the manifest/index blob and are + // unaffected.) + desc.Annotations = map[string]string{LocalBuildAnnotation: "true"} + if err := target.Tag(ctx, desc, tag); err != nil { + return fmt.Errorf("tagging artifact as local build: %w", err) + } + return nil +} + +// isLocalBuild reports whether the given tag in the local OCI store carries +// the local-build marker. Tags created by OCI pulls do not carry it, so this +// is the filter used by ListBuilds to hide cached remote artifacts. +func isLocalBuild(ctx context.Context, store *ociskills.Store, tag string) (bool, error) { + desc, err := store.Target().Resolve(ctx, tag) + if err != nil { + return false, err + } + return desc.Annotations[LocalBuildAnnotation] == "true", nil +} diff --git a/pkg/skills/skillsvc/pull_errors.go b/pkg/skills/skillsvc/pull_errors.go new file mode 100644 index 0000000000..47a6f34621 --- /dev/null +++ b/pkg/skills/skillsvc/pull_errors.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "net/http" + + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry/remote/errcode" +) + +// classifyPullError maps an error returned by Registry.Pull (which wraps +// oras.Copy) into an HTTP status code that best describes the failure to a +// caller. The classifier inspects: +// +// - context.DeadlineExceeded / context.Canceled — mapped to 504 so callers +// can distinguish upstream slowness from a registry-side rejection. +// - *errcode.ErrorResponse (HTTP error from the remote registry) — mapped +// by StatusCode: 401/403 → 401, 404 → 404, 429 → 429, other 4xx → 502, +// 5xx → 502. +// - errdef.ErrNotFound — mapped to 404 (covers cases where oras surfaces +// not-found as a sentinel rather than an ErrorResponse). +// +// Anything else is treated as a generic 502 Bad Gateway. +// +// The returned code is always in the 4xx or 5xx range; callers wrap the +// original error with httperr.WithCode(code) so the ErrorHandler renders an +// appropriate response. +func classifyPullError(err error) int { + if err == nil { + return http.StatusOK + } + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return http.StatusGatewayTimeout + } + + var httpErr *errcode.ErrorResponse + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return http.StatusUnauthorized + case http.StatusNotFound: + return http.StatusNotFound + case http.StatusTooManyRequests: + return http.StatusTooManyRequests + } + // Other 4xx and 5xx registry responses are treated as generic + // upstream failures. + return http.StatusBadGateway + } + + if errors.Is(err, errdef.ErrNotFound) { + return http.StatusNotFound + } + + return http.StatusBadGateway +} diff --git a/pkg/skills/skillsvc/pull_errors_test.go b/pkg/skills/skillsvc/pull_errors_test.go new file mode 100644 index 0000000000..3e6236530b --- /dev/null +++ b/pkg/skills/skillsvc/pull_errors_test.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry/remote/errcode" +) + +func TestClassifyPullError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want int + }{ + { + name: "nil error returns 200", + err: nil, + want: http.StatusOK, + }, + { + name: "context deadline exceeded maps to 504", + err: context.DeadlineExceeded, + want: http.StatusGatewayTimeout, + }, + { + name: "wrapped context deadline exceeded maps to 504", + err: fmt.Errorf("pulling OCI artifact: %w", context.DeadlineExceeded), + want: http.StatusGatewayTimeout, + }, + { + name: "context canceled maps to 504", + err: context.Canceled, + want: http.StatusGatewayTimeout, + }, + { + name: "registry 401 maps to 401", + err: newErrResp(http.StatusUnauthorized), + want: http.StatusUnauthorized, + }, + { + name: "registry 403 maps to 401", + err: newErrResp(http.StatusForbidden), + want: http.StatusUnauthorized, + }, + { + name: "registry 404 maps to 404", + err: newErrResp(http.StatusNotFound), + want: http.StatusNotFound, + }, + { + name: "registry 429 maps to 429", + err: newErrResp(http.StatusTooManyRequests), + want: http.StatusTooManyRequests, + }, + { + name: "registry 400 maps to 502", + err: newErrResp(http.StatusBadRequest), + want: http.StatusBadGateway, + }, + { + name: "registry 500 maps to 502", + err: newErrResp(http.StatusInternalServerError), + want: http.StatusBadGateway, + }, + { + name: "registry 503 maps to 502", + err: newErrResp(http.StatusServiceUnavailable), + want: http.StatusBadGateway, + }, + { + name: "wrapped registry 401 maps to 401", + err: fmt.Errorf("copy graph: %w", newErrResp(http.StatusUnauthorized)), + want: http.StatusUnauthorized, + }, + { + name: "errdef.ErrNotFound maps to 404", + err: errdef.ErrNotFound, + want: http.StatusNotFound, + }, + { + name: "wrapped errdef.ErrNotFound maps to 404", + err: fmt.Errorf("fetching manifest: %w", errdef.ErrNotFound), + want: http.StatusNotFound, + }, + { + name: "generic error maps to 502", + err: errors.New("connection refused"), + want: http.StatusBadGateway, + }, + { + name: "registry error takes precedence over generic wrapper", + err: fmt.Errorf("pulling from registry: %w", + fmt.Errorf("wrapped: %w", newErrResp(http.StatusNotFound))), + want: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyPullError(tt.err) + assert.Equal(t, tt.want, got) + }) + } +} + +// newErrResp constructs a *errcode.ErrorResponse with the given HTTP status +// for use as a synthetic oras-go error. +func newErrResp(status int) *errcode.ErrorResponse { + u, _ := url.Parse("https://registry.example.com/v2/foo/manifests/latest") + return &errcode.ErrorResponse{ + Method: http.MethodGet, + URL: u, + StatusCode: status, + } +} diff --git a/pkg/skills/skillsvc/registry.go b/pkg/skills/skillsvc/registry.go index 09aae72ce2..5a7784eabf 100644 --- a/pkg/skills/skillsvc/registry.go +++ b/pkg/skills/skillsvc/registry.go @@ -91,6 +91,126 @@ func splitQualifiedName(s string) (namespace, name string) { return s[:idx], s[idx+1:] } +// resolveGitFallbackForOCIRef attempts to find a git:// URL in the skill +// registry that can serve as a fallback when an OCI pull failed for ref. +// +// The lookup is purely advisory: any error, ambiguity, or missing data is +// treated as "no fallback available" so the caller can simply return the +// original OCI error. Returning "" means "no fallback found". +// +// Matching strategy: +// - Derive a search term from the ref's tail path segment (e.g. +// "yara-rule-authoring" from "ghcr.io/stacklok/dockyard/skills/yara-rule-authoring:0.1.0"). +// - Query the registry via SearchSkills — no new interface method required. +// - Post-filter to registry entries whose OCI packages share the same +// repository path as ref (ignoring tag/digest, so :0.1.0 and :latest match +// the same entry). +// - If exactly one entry matches and it has a git package, build and return +// its git:// URL. Multiple matches would be ambiguous so we skip the +// fallback rather than guess. +func (s *service) resolveGitFallbackForOCIRef(ref nameref.Reference) string { + if s.skillLookup == nil { + return "" + } + + repo := ref.Context().RepositoryStr() + tail := repo + if idx := strings.LastIndex(repo, "/"); idx >= 0 { + tail = repo[idx+1:] + } + if tail == "" { + return "" + } + + results, err := s.skillLookup.SearchSkills(tail) + if err != nil { + slog.Debug("registry lookup for OCI fallback failed, skipping fallback", + "ref", ref.String(), "error", err) + return "" + } + + wantRepo := canonicalOCIRepo(ref) + + var matches []regtypes.Skill + for _, sk := range results { + if !skillHasMatchingOCIRepo(sk, wantRepo) { + continue + } + matches = append(matches, sk) + } + + // Ambiguous: bail out rather than guess. An ambiguous fallback is worse + // than surfacing the original OCI error because it could silently serve + // content from the wrong skill. + if len(matches) != 1 { + return "" + } + + return firstGitPackageURL(matches[0].Packages) +} + +// canonicalOCIRepo returns the registry+repository portion of ref without a +// tag or digest so two references to the same repository at different versions +// compare equal. +func canonicalOCIRepo(ref nameref.Reference) string { + ctx := ref.Context() + return ctx.RegistryStr() + "/" + ctx.RepositoryStr() +} + +// skillHasMatchingOCIRepo reports whether sk has any OCI package whose +// identifier refers to the same repository as wantRepo. +func skillHasMatchingOCIRepo(sk regtypes.Skill, wantRepo string) bool { + for _, pkg := range sk.Packages { + if pkg.RegistryType != "oci" || pkg.Identifier == "" { + continue + } + parsed, err := nameref.ParseReference(pkg.Identifier) + if err != nil { + continue + } + if canonicalOCIRepo(parsed) == wantRepo { + return true + } + } + return false +} + +// firstGitPackageURL returns the git:// URL for the first usable git package +// in pkgs, or "" if none is usable. The format follows gitresolver's parser: +// +// git://host/owner/repo[@ref][#subfolder] +// +// Commit is preferred over Ref for reproducibility; both are optional. +func firstGitPackageURL(pkgs []regtypes.SkillPackage) string { + for _, pkg := range pkgs { + if pkg.RegistryType != "git" || pkg.URL == "" { + continue + } + gitURL, err := buildGitReferenceFromRegistryURL(pkg.URL) + if err != nil { + continue + } + if ref := preferredGitRef(pkg); ref != "" { + gitURL += "@" + ref + } + if pkg.Subfolder != "" { + gitURL += "#" + pkg.Subfolder + } + return gitURL + } + return "" +} + +// preferredGitRef returns the ref to pin the git fallback to. Commit is +// preferred over branch/tag for reproducibility because the registry records +// both when available. +func preferredGitRef(pkg regtypes.SkillPackage) string { + if pkg.Commit != "" { + return pkg.Commit + } + return pkg.Ref +} + // resolveRegistryPackages selects the best installable package from a registry // entry. OCI packages are preferred; git is the fallback. func resolveRegistryPackages(name string, packages []regtypes.SkillPackage) (*registryResolveResult, error) { diff --git a/pkg/state/local.go b/pkg/state/local.go index 8ac9601508..a75814167a 100644 --- a/pkg/state/local.go +++ b/pkg/state/local.go @@ -52,20 +52,28 @@ func NewLocalStore(appName string, storeName string) (*LocalStore, error) { }, nil } -// getFilePath returns the full file path for a configuration -func (s *LocalStore) getFilePath(name string) string { - // Ensure the name has the correct extension +// getFilePath returns the full file path for a configuration, validating that +// the resolved path stays within the store's base directory. +func (s *LocalStore) getFilePath(name string) (string, error) { if !strings.HasSuffix(name, FileExtension) { name = name + FileExtension } - return filepath.Join(s.basePath, name) + filePath := filepath.Join(s.basePath, name) + // filepath.Join calls filepath.Clean, which resolves ".." components. + // Verify the result is still within basePath to prevent path traversal. + if !strings.HasPrefix(filePath, s.basePath+string(filepath.Separator)) { + return "", fmt.Errorf("path traversal detected: name escapes state directory") + } + return filePath, nil } // GetReader returns a reader for the state data func (s *LocalStore) GetReader(_ context.Context, name string) (io.ReadCloser, error) { - // Open the file - filePath := s.getFilePath(name) - // #nosec G304 - filePath is controlled by getFilePath which ensures it's within our designated directory + filePath, err := s.getFilePath(name) + if err != nil { + return nil, err + } + // #nosec G304 - path traversal is prevented by the containment check in getFilePath file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { @@ -79,9 +87,11 @@ func (s *LocalStore) GetReader(_ context.Context, name string) (io.ReadCloser, e // GetWriter returns a writer for the state data func (s *LocalStore) GetWriter(_ context.Context, name string) (io.WriteCloser, error) { - // Create the file - filePath := s.getFilePath(name) - // #nosec G304 - filePath is controlled by getFilePath which ensures it's within our designated directory + filePath, err := s.getFilePath(name) + if err != nil { + return nil, err + } + // #nosec G304 - path traversal is prevented by the containment check in getFilePath file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return nil, fmt.Errorf("failed to create file: %w", err) @@ -93,9 +103,12 @@ func (s *LocalStore) GetWriter(_ context.Context, name string) (io.WriteCloser, // CreateExclusive creates a new state entry exclusively, failing if it already exists. // This provides atomic check-and-create semantics using O_EXCL to prevent race conditions. func (s *LocalStore) CreateExclusive(_ context.Context, name string) (io.WriteCloser, error) { - filePath := s.getFilePath(name) - // O_EXCL with O_CREATE provides atomic check-and-create behavior - // #nosec G304 - filePath is controlled by getFilePath which ensures it's within our designated directory + filePath, err := s.getFilePath(name) + if err != nil { + return nil, err + } + // O_EXCL with O_CREATE provides atomic check-and-create behavior. + // #nosec G304 - path traversal is prevented by the containment check in getFilePath file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { if os.IsExist(err) { @@ -112,8 +125,10 @@ func (s *LocalStore) CreateExclusive(_ context.Context, name string) (io.WriteCl // Delete removes the data for the given name func (s *LocalStore) Delete(_ context.Context, name string) error { - filePath := s.getFilePath(name) - // #nosec G304 - filePath is controlled by getFilePath which ensures it's within our designated directory + filePath, err := s.getFilePath(name) + if err != nil { + return err + } if err := os.Remove(filePath); err != nil { if os.IsNotExist(err) { return fmt.Errorf("state '%s' not found", name) @@ -154,8 +169,11 @@ func (s *LocalStore) List(_ context.Context) ([]string, error) { // Exists checks if data exists for the given name func (s *LocalStore) Exists(_ context.Context, name string) (bool, error) { - filePath := s.getFilePath(name) - _, err := os.Stat(filePath) + filePath, err := s.getFilePath(name) + if err != nil { + return false, err + } + _, err = os.Stat(filePath) if err != nil { if os.IsNotExist(err) { return false, nil diff --git a/pkg/state/local_test.go b/pkg/state/local_test.go new file mode 100644 index 0000000000..91fa99ff80 --- /dev/null +++ b/pkg/state/local_test.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package state + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestLocalStore creates a LocalStore rooted at a temp directory for testing. +func newTestLocalStore(t *testing.T) *LocalStore { + t.Helper() + dir := t.TempDir() + return &LocalStore{basePath: dir} +} + +func TestLocalStore_PathTraversalPrevented(t *testing.T) { + t.Parallel() + + traversalNames := []string{ + "../escape", + "../../etc/passwd", + "../../../root/.ssh/authorized_keys", + "./../escape", + "subdir/../../escape", + } + + for _, name := range traversalNames { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newTestLocalStore(t) + + _, err := store.GetReader(ctx, name) + assert.ErrorContains(t, err, "path traversal detected", "GetReader should reject %q", name) + + _, err = store.GetWriter(ctx, name) + assert.ErrorContains(t, err, "path traversal detected", "GetWriter should reject %q", name) + + _, err = store.CreateExclusive(ctx, name) + assert.ErrorContains(t, err, "path traversal detected", "CreateExclusive should reject %q", name) + + err = store.Delete(ctx, name) + assert.ErrorContains(t, err, "path traversal detected", "Delete should reject %q", name) + + _, err = store.Exists(ctx, name) + assert.ErrorContains(t, err, "path traversal detected", "Exists should reject %q", name) + }) + } +} + +func TestLocalStore_ValidNamesWork(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newTestLocalStore(t) + + // Write via CreateExclusive + w, err := store.CreateExclusive(ctx, "mystate") + require.NoError(t, err) + _, err = w.Write([]byte(`{"key":"value"}`)) + require.NoError(t, err) + require.NoError(t, w.Close()) + + // File should exist inside basePath + exists, err := store.Exists(ctx, "mystate") + require.NoError(t, err) + assert.True(t, exists) + + // Read it back + r, err := store.GetReader(ctx, "mystate") + require.NoError(t, err) + require.NoError(t, r.Close()) + + // List should return it + names, err := store.List(ctx) + require.NoError(t, err) + assert.Contains(t, names, "mystate") + + // Delete it + require.NoError(t, store.Delete(ctx, "mystate")) + exists, err = store.Exists(ctx, "mystate") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestLocalStore_FileStaysInsideBasePath(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newTestLocalStore(t) + + w, err := store.GetWriter(ctx, "config") + require.NoError(t, err) + require.NoError(t, w.Close()) + + // The written file must be inside the base directory + entries, err := os.ReadDir(store.basePath) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "config"+FileExtension, entries[0].Name()) + + // Absolute path of the file must start with basePath + absPath := filepath.Join(store.basePath, entries[0].Name()) + assert.True(t, filepath.IsAbs(absPath)) + assert.Contains(t, absPath, store.basePath) +} diff --git a/pkg/state/runconfig.go b/pkg/state/runconfig.go index 2bb1a29008..ea1fc91c7d 100644 --- a/pkg/state/runconfig.go +++ b/pkg/state/runconfig.go @@ -121,41 +121,6 @@ func LoadRunConfig[T any](ctx context.Context, name string, readJSONFunc ReadJSO return readJSONFunc(reader) } -// ReadRunConfigJSON deserializes a run configuration from JSON read from the provided reader -// This is a generic JSON deserializer for any type that can be unmarshalled from JSON -func ReadRunConfigJSON[T any](r io.Reader) (*T, error) { - var config T - decoder := json.NewDecoder(r) - if err := decoder.Decode(&config); err != nil { - return nil, err - } - return &config, nil -} - -// LoadRunConfigOfType loads a run configuration of a specific type T from the state store -func LoadRunConfigOfType[T any](ctx context.Context, name string) (*T, error) { - return LoadRunConfig(ctx, name, ReadRunConfigJSON[T]) -} - -// RunConfigReadJSONFunc defines the function signature for reading a RunConfig from JSON -// This allows us to accept the runner.ReadJSON function without creating a circular dependency -type RunConfigReadJSONFunc func(r io.Reader) (interface{}, error) - -// LoadRunConfigWithFunc loads a run configuration using a provided read function -func LoadRunConfigWithFunc(ctx context.Context, name string, readFunc RunConfigReadJSONFunc) (interface{}, error) { - reader, err := LoadRunConfigJSON(ctx, name) - if err != nil { - return nil, err - } - defer func() { - if err := reader.Close(); err != nil { - slog.Warn("Failed to close reader", "error", err) - } - }() - - return readFunc(reader) -} - // ReadJSON deserializes JSON from the provided reader into a generic interface // This function is moved from the runner package to avoid circular dependencies func ReadJSON(r io.Reader, target interface{}) error { diff --git a/pkg/telemetry/config_test.go b/pkg/telemetry/config_test.go index bb6addef6b..72b931f76c 100644 --- a/pkg/telemetry/config_test.go +++ b/pkg/telemetry/config_test.go @@ -165,7 +165,7 @@ func TestTelemetryProviderValidation(t *testing.T) { // getProviderTypeName returns a readable type name for telemetry providers func getProviderTypeName(provider interface{}) string { t := reflect.TypeOf(provider) - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { return t.Elem().PkgPath()[len("go.opentelemetry.io/otel/"):] + "." + t.Elem().Name() } return t.PkgPath()[len("go.opentelemetry.io/otel/"):] + "." + t.Name() diff --git a/pkg/telemetry/middleware.go b/pkg/telemetry/middleware.go index b7411da42a..cc17e5f4db 100644 --- a/pkg/telemetry/middleware.go +++ b/pkg/telemetry/middleware.go @@ -697,11 +697,13 @@ func (m *HTTPMiddleware) recordMetrics(ctx context.Context, r *http.Request, rw m.requestDuration.Record(ctx, duration.Seconds(), attrs) // Record OTEL MCP spec mcp.server.operation.duration for actual MCP requests. - // mcpMethod should never be "unknown" for requests reaching the MCP middleware; - // if it is, the middleware chain is misconfigured (see #3687). + // Only POST requests carry a JSON-RPC body; GET (SSE stream open) and DELETE + // (session termination) are valid Streamable HTTP lifecycle requests with no + // MCP method to record. An unknown method on a POST indicates either a + // misconfigured middleware chain (see #3687) or a parse failure. if mcpMethod != "unknown" { m.recordOperationDuration(ctx, r, mcpMethod, mcpResourceID, rw.statusCode, duration) - } else { + } else if r.Method == http.MethodPost { //nolint:gosec // G706: HTTP method and URL path from request slog.Warn("mcp method could not be determined, middleware may be misconfigured", "http_method", r.Method, "path", r.URL.Path) diff --git a/pkg/telemetry/middleware_test.go b/pkg/telemetry/middleware_test.go index 1404d4f6b5..32797cc25e 100644 --- a/pkg/telemetry/middleware_test.go +++ b/pkg/telemetry/middleware_test.go @@ -4,8 +4,10 @@ package telemetry import ( + "bytes" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "os" @@ -2209,6 +2211,127 @@ func TestHTTPMiddleware_OperationDuration(t *testing.T) { } } +// TestHTTPMiddleware_UnknownMethodWarning pins down the truth table for the +// "method could not be determined" diagnostic introduced in #3687 and refined +// in #4451: +// +// HTTP method | parsed MCP method | warn? | record operation duration? +// ------------+-------------------+-------+--------------------------- +// POST | tools/call | no | yes +// POST | | yes | no +// GET | | no | no +// DELETE | | no | no +// +// GET (SSE stream open) and DELETE (session termination) are valid Streamable +// HTTP lifecycle requests with no JSON-RPC body, so warning on them produces +// noise rather than signal. POST without a parsed method retains the warning +// because it indicates a real misconfiguration on the JSON-RPC path. +// +// Subtests redirect slog.Default (process-global), so they must not run in +// parallel. +// +//nolint:paralleltest,tparallel // Subtests redirect slog.Default, which is process-global state +func TestHTTPMiddleware_UnknownMethodWarning(t *testing.T) { + tests := []struct { + name string + httpMethod string + mcpRequest *mcpparser.ParsedMCPRequest + expectWarn bool + expectMetric bool + }{ + { + name: "POST with parsed MCP method records duration and does not warn", + httpMethod: http.MethodPost, + mcpRequest: &mcpparser.ParsedMCPRequest{ + Method: "tools/call", + ID: "1", + IsRequest: true, + }, + expectWarn: false, + expectMetric: true, + }, + { + name: "POST without parsed MCP method warns and does not record duration", + httpMethod: http.MethodPost, + mcpRequest: nil, + expectWarn: true, + expectMetric: false, + }, + { + name: "GET to /mcp does not warn (SSE stream open)", + httpMethod: http.MethodGet, + mcpRequest: nil, + expectWarn: false, + expectMetric: false, + }, + { + name: "DELETE to /mcp does not warn (session termination)", + httpMethod: http.MethodDelete, + mcpRequest: nil, + expectWarn: false, + expectMetric: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + orig := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(orig) }) + + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + middleware := NewHTTPMiddleware( + Config{ServiceName: "test-service", ServiceVersion: "1.0.0"}, + tracenoop.NewTracerProvider(), + meterProvider, + "test-server", + "streamable-http", + ) + + wrappedHandler := middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(tt.httpMethod, "/mcp", nil) + if tt.mcpRequest != nil { + ctx := context.WithValue(req.Context(), mcpparser.MCPRequestContextKey, tt.mcpRequest) + req = req.WithContext(ctx) + } + rec := httptest.NewRecorder() + wrappedHandler.ServeHTTP(rec, req) + + logged := buf.String() + if tt.expectWarn { + require.Contains(t, logged, "mcp method could not be determined", + "expected WARN for %s with no parsed MCP method", tt.httpMethod) + // Operators rely on these attributes to identify the offending traffic. + assert.Contains(t, logged, `"http_method":"`+tt.httpMethod+`"`) + assert.Contains(t, logged, `"path":"/mcp"`) + } else { + assert.NotContains(t, logged, "mcp method could not be determined", + "unexpected WARN for %s /mcp", tt.httpMethod) + } + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + var hasOperationDuration bool + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == metricOperationDuration { + hasOperationDuration = true + } + } + } + assert.Equal(t, tt.expectMetric, hasOperationDuration, + "mcp.server.operation.duration presence should match expectation") + }) + } +} + func TestRecordSSEConnection_DualEmission(t *testing.T) { t.Parallel() diff --git a/pkg/transport/factory.go b/pkg/transport/factory.go index 003bab13ee..eae3218987 100644 --- a/pkg/transport/factory.go +++ b/pkg/transport/factory.go @@ -55,6 +55,13 @@ func (*Factory) Create(config types.Config, opts ...Option) (types.Transport, er if config.SessionStorage != nil { stdio.SetSessionStorage(config.SessionStorage) } + stdio.SetSessionTTL(config.SessionTTL) + if config.AuthInfoHandler != nil { + stdio.SetAuthInfoHandler(config.AuthInfoHandler) + } + if len(config.PrefixHandlers) > 0 { + stdio.SetPrefixHandlers(config.PrefixHandlers) + } tr = stdio case types.TransportTypeSSE: httpTransport := NewHTTPTransport( @@ -73,6 +80,7 @@ func (*Factory) Create(config types.Config, opts ...Option) (types.Transport, er config.Middlewares..., ) httpTransport.sessionStorage = config.SessionStorage + httpTransport.sessionTTL = config.SessionTTL tr = httpTransport case types.TransportTypeStreamableHTTP: httpTransport := NewHTTPTransport( @@ -91,6 +99,7 @@ func (*Factory) Create(config types.Config, opts ...Option) (types.Transport, er config.Middlewares..., ) httpTransport.sessionStorage = config.SessionStorage + httpTransport.sessionTTL = config.SessionTTL tr = httpTransport case types.TransportTypeInspector: // HTTP transport is not implemented yet diff --git a/pkg/transport/http.go b/pkg/transport/http.go index 53b9af6d12..7f9bb174b9 100644 --- a/pkg/transport/http.go +++ b/pkg/transport/http.go @@ -13,12 +13,13 @@ import ( "os" "strings" "sync" + "time" "golang.org/x/oauth2" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" "github.com/stacklok/toolhive/pkg/container" rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" transporterrors "github.com/stacklok/toolhive/pkg/transport/errors" "github.com/stacklok/toolhive/pkg/transport/middleware" "github.com/stacklok/toolhive/pkg/transport/proxy/transparent" @@ -81,6 +82,10 @@ type HTTPTransport struct { // Used for Redis-backed session sharing across replicas. sessionStorage session.Storage + // sessionTTL overrides the inactivity timeout for sessions managed by the + // underlying proxy. Zero uses the proxy's default. + sessionTTL time.Duration + // Transparent proxy proxy types.Proxy @@ -432,6 +437,9 @@ func (t *HTTPTransport) buildProxyOptions(remoteBasePath, remoteRawQuery string) if t.stateless { opts = append(opts, transparent.WithStateless()) } + if t.sessionTTL > 0 { + opts = append(opts, transparent.WithSessionTTL(t.sessionTTL)) + } if t.sessionStorage != nil { opts = append(opts, transparent.WithSessionStorage(t.sessionStorage)) } diff --git a/pkg/transport/http_test.go b/pkg/transport/http_test.go index 507390232c..650fae7c8f 100644 --- a/pkg/transport/http_test.go +++ b/pkg/transport/http_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/transport/types/mocks" ) diff --git a/pkg/transport/proxy/httpsse/http_proxy.go b/pkg/transport/proxy/httpsse/http_proxy.go index 2821ad6369..b6074fbed5 100644 --- a/pkg/transport/proxy/httpsse/http_proxy.go +++ b/pkg/transport/proxy/httpsse/http_proxy.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net" "net/http" "net/url" @@ -22,6 +23,7 @@ import ( "github.com/google/uuid" "golang.org/x/exp/jsonrpc2" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/healthcheck" "github.com/stacklok/toolhive/pkg/transport/proxy/socket" "github.com/stacklok/toolhive/pkg/transport/session" @@ -74,6 +76,22 @@ type HTTPSSEProxy struct { // Session manager for SSE clients sessionManager *session.Manager + // sessionTTL is the resolved inactivity timeout for the session manager. + // Defaults to session.DefaultSessionTTL; overridable via WithSessionTTL. + sessionTTL time.Duration + + // sessionStorage is the optional custom storage backend for the session manager. + // When nil, in-memory LocalStorage is used. Set via WithSessionStorage. + sessionStorage session.Storage + + // authInfoHandler is the optional RFC 9728 OAuth protected resource discovery handler. + // When nil, /.well-known/ returns a clean JSON 404. Set via WithAuthInfoHandler. + authInfoHandler http.Handler + + // prefixHandlers contains additional HTTP handlers mounted outside the middleware chain. + // Keys are URL path prefixes. Set via WithPrefixHandlers. + prefixHandlers map[string]http.Handler + // liveSSESessions tracks active SSE connections local to this instance. // Keys are clientID strings; values are *session.SSESession. // This is separate from sessionManager so that distributed storage backends @@ -121,11 +139,34 @@ func WithSessionStorage(storage session.Storage) Option { if storage == nil { return } - if p.sessionManager != nil { - _ = p.sessionManager.Stop() + p.sessionStorage = storage + } +} + +// WithSessionTTL overrides the session inactivity timeout used by this proxy. +// Zero or negative values are ignored so the constructor's default is preserved. +func WithSessionTTL(ttl time.Duration) Option { + return func(p *HTTPSSEProxy) { + if ttl <= 0 { + return } - sseFactory := func(id string) session.Session { return session.NewSSESession(id) } - p.sessionManager = session.NewManagerWithStorage(session.ResolveSessionTTLFromEnv(), sseFactory, storage) + p.sessionTTL = ttl + } +} + +// WithAuthInfoHandler sets the RFC 9728 OAuth protected resource discovery handler. +// When nil (the default), requests to /.well-known/ return a clean JSON 404. +func WithAuthInfoHandler(h http.Handler) Option { + return func(p *HTTPSSEProxy) { + p.authInfoHandler = h + } +} + +// WithPrefixHandlers sets additional HTTP handlers that are mounted outside the +// middleware chain, keyed by URL path prefix. +func WithPrefixHandlers(handlers map[string]http.Handler) Option { + return func(p *HTTPSSEProxy) { + p.prefixHandlers = maps.Clone(handlers) } } @@ -138,11 +179,6 @@ func NewHTTPSSEProxy( middlewares []types.NamedMiddleware, opts ...Option, ) *HTTPSSEProxy { - // Create a factory for SSE sessions - sseFactory := func(id string) session.Session { - return session.NewSSESession(id) - } - proxy := &HTTPSSEProxy{ middlewares: middlewares, host: host, @@ -150,7 +186,7 @@ func NewHTTPSSEProxy( trustProxyHeaders: trustProxyHeaders, shutdownCh: make(chan struct{}), messageCh: make(chan jsonrpc2.Message, 100), - sessionManager: session.NewManager(session.ResolveSessionTTLFromEnv(), sseFactory), + sessionTTL: session.ResolveSessionTTLFromEnv(), pendingMessages: []*ssecommon.PendingSSEMessage{}, prometheusHandler: prometheusHandler, } @@ -159,6 +195,14 @@ func NewHTTPSSEProxy( opt(proxy) } + // Construct the session manager once, after options have resolved sessionTTL and sessionStorage. + sseFactory := func(id string) session.Session { return session.NewSSESession(id) } + if proxy.sessionStorage != nil { + proxy.sessionManager = session.NewManagerWithStorage(proxy.sessionTTL, sseFactory, proxy.sessionStorage) + } else { + proxy.sessionManager = session.NewManager(proxy.sessionTTL, sseFactory) + } + // Create MCP pinger and health checker mcpPinger := NewMCPPinger(proxy) proxy.healthChecker = healthcheck.NewHealthChecker("stdio", mcpPinger) @@ -180,6 +224,21 @@ func (p *HTTPSSEProxy) Start(_ context.Context) error { // Create a new HTTP server mux := http.NewServeMux() + // Mount prefix handlers (e.g. embedded auth server routes) outside the middleware chain. + // RFC 9728 requires discovery endpoints to be reachable without authentication. + for prefix, h := range p.prefixHandlers { + mux.Handle(prefix, h) + slog.Debug("mounted prefix handler", "prefix", prefix) + } + + // Mount RFC 9728 OAuth protected resource discovery endpoint (no middlewares). + // Always register so OAuth discovery gets a clean JSON 404 when auth is off. + wellKnownHandler := auth.NewWellKnownHandler(p.authInfoHandler) + mux.Handle("/.well-known/", wellKnownHandler) + if p.authInfoHandler != nil { + slog.Debug("rfc 9728 OAuth discovery endpoint enabled at /.well-known/oauth-protected-resource") + } + // Add handlers for SSE and JSON-RPC with middlewares // At some point we should add support for Streamable HTTP transport here // https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http @@ -429,7 +488,9 @@ func (p *HTTPSSEProxy) handleSSEConnection(w http.ResponseWriter, r *http.Reques } flusher.Flush() case <-keepAliveTicker.C: - // Send SSE comment as keep-alive + // Refresh session TTL while the SSE socket is open so the cleanup + // goroutine does not evict clients that haven't sent a POST recently. + p.sessionManager.Get(clientID) if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil { slog.Debug("failed to write keep-alive", "error", err) return diff --git a/pkg/transport/proxy/httpsse/http_proxy_integration_test.go b/pkg/transport/proxy/httpsse/http_proxy_integration_test.go index 94e57d91a6..3052ee4b3f 100644 --- a/pkg/transport/proxy/httpsse/http_proxy_integration_test.go +++ b/pkg/transport/proxy/httpsse/http_proxy_integration_test.go @@ -6,6 +6,7 @@ package httpsse import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -26,8 +27,7 @@ func TestIntegrationSSEProxyStressTest(t *testing.T) { // Create proxy with a random port proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() // Start the proxy err := proxy.Start(ctx) @@ -173,8 +173,7 @@ func TestIntegrationConcurrentClientsWithLongRunning(t *testing.T) { // Create and start proxy proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() err := proxy.Start(ctx) require.NoError(t, err) @@ -319,7 +318,7 @@ func TestIntegrationConcurrentClientsWithLongRunning(t *testing.T) { func TestIntegrationLiveSessionsCleanup(t *testing.T) { t.Parallel() proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil) - ctx := context.Background() + ctx := t.Context() err := proxy.Start(ctx) require.NoError(t, err) @@ -379,3 +378,45 @@ func extractSessionID(body io.Reader) (string, error) { return string(buf[start:end]), nil } + +// TestHTTPSSEProxy_StartMountsAuthDiscoveryEndpoint is an integration test that +// starts a real SSE proxy with an authInfoHandler and verifies the RFC 9728 +// discovery endpoint responds correctly. +func TestHTTPSSEProxy_StartMountsAuthDiscoveryEndpoint(t *testing.T) { + t.Parallel() + + const wantBody = `{"resource":"https://example.com"}` + + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(wantBody)) + }) + + proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil, WithAuthInfoHandler(sentinel)) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // Poll until the server is ready (Start returns before the goroutine binds). + url := fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", proxy.server.Addr) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") + + var body map[string]string + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "https://example.com", body["resource"]) +} diff --git a/pkg/transport/proxy/httpsse/http_proxy_test.go b/pkg/transport/proxy/httpsse/http_proxy_test.go index 7302db872d..9bd1734e93 100644 --- a/pkg/transport/proxy/httpsse/http_proxy_test.go +++ b/pkg/transport/proxy/httpsse/http_proxy_test.go @@ -181,7 +181,7 @@ func TestConcurrentClientRemoval(t *testing.T) { //nolint:paralleltest // Test modifies shared proxy state func TestForwardResponseToClients(t *testing.T) { proxy := NewHTTPSSEProxy("localhost", 8080, false, nil, nil) - ctx := context.Background() + ctx := t.Context() // Create a client session clientID := testClientID @@ -220,7 +220,7 @@ func TestForwardResponseToClients(t *testing.T) { //nolint:paralleltest // Test modifies shared proxy state func TestForwardResponseToClients_NoClients(t *testing.T) { proxy := NewHTTPSSEProxy("localhost", 8080, false, nil, nil) - ctx := context.Background() + ctx := t.Context() // Create a test response response, err := jsonrpc2.NewResponse(jsonrpc2.StringID("test"), "test result", nil) @@ -603,12 +603,153 @@ func TestNewHTTPSSEProxyWithSessionStorage(t *testing.T) { require.NotNil(t, proxy.sessionManager) } +// TestNewHTTPSSEProxy_AuthInfoHandlerNil verifies that when no authInfoHandler is set, +// requests to /.well-known/oauth-protected-resource return a JSON 404. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPSSEProxy_AuthInfoHandlerNil(t *testing.T) { + proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil) // no WithAuthInfoHandler + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + url := fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", proxy.server.Addr) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") +} + +// TestNewHTTPSSEProxy_PrefixHandlerMounted verifies that a handler registered via +// WithPrefixHandlers is reachable at its declared path prefix. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPSSEProxy_PrefixHandlerMounted(t *testing.T) { + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/oauth/token": sentinel}), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + url := fmt.Sprintf("http://%s/oauth/token", proxy.server.Addr) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestNewHTTPSSEProxy_PrefixHandlerDoesNotShadowSSE verifies that registering prefix +// handlers does not prevent the /sse endpoint from being reachable. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPSSEProxy_PrefixHandlerDoesNotShadowSSE(t *testing.T) { + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/oauth/token": sentinel}), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // /sse returns text/event-stream for GET requests, which proves it is registered. + url := fmt.Sprintf("http://%s/sse", proxy.server.Addr) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) +} + +// TestNewHTTPSSEProxy_ExactWellKnownBeatsSubtree verifies that Go ServeMux +// specificity applies: an exact path registered via WithPrefixHandlers wins +// over the /.well-known/ subtree mount used by WithAuthInfoHandler. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPSSEProxy_ExactWellKnownBeatsSubtree(t *testing.T) { + exactHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + authInfoHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/.well-known/openid-configuration": exactHandler}), + WithAuthInfoHandler(authInfoHandler), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + base := fmt.Sprintf("http://%s", proxy.server.Addr) + + // Poll until the server is ready. + var oidcResp *http.Response + require.Eventually(t, func() bool { + var err error + oidcResp, err = http.Get(base + "/.well-known/openid-configuration") //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer oidcResp.Body.Close() + + assert.Equal(t, http.StatusTeapot, oidcResp.StatusCode, "exact handler must win over subtree mount") + + prmResp, err := http.Get(base + "/.well-known/oauth-protected-resource") //nolint:gosec // test-only URL construction + require.NoError(t, err) + defer prmResp.Body.Close() + + assert.Equal(t, http.StatusOK, prmResp.StatusCode, "subtree handler must serve other /.well-known/ paths") +} + // TestStartStop tests starting and stopping the proxy // //nolint:paralleltest // Test starts/stops HTTP server func TestStartStop(t *testing.T) { proxy := NewHTTPSSEProxy("localhost", 0, false, nil, nil) // Use port 0 for auto-assignment - ctx := context.Background() + ctx := t.Context() // Start the proxy err := proxy.Start(ctx) diff --git a/pkg/transport/proxy/streamable/streamable_proxy.go b/pkg/transport/proxy/streamable/streamable_proxy.go index e5425820e3..5695507882 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy.go +++ b/pkg/transport/proxy/streamable/streamable_proxy.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "os" "strings" @@ -21,6 +22,7 @@ import ( "github.com/google/uuid" "golang.org/x/exp/jsonrpc2" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/healthcheck" "github.com/stacklok/toolhive/pkg/transport/session" "github.com/stacklok/toolhive/pkg/transport/types" @@ -56,9 +58,29 @@ type HTTPProxy struct { // Session manager for streamable HTTP sessions sessionManager *session.Manager - // Waiters keyed by JSON-encoded request ID -> one-shot channel for response delivery + // sessionTTL is the resolved inactivity timeout for the session manager. + // Defaults to session.DefaultSessionTTL; overridable via WithSessionTTL. + sessionTTL time.Duration + + // sessionStorage is the optional custom storage backend for the session manager. + // When nil, in-memory LocalStorage is used. Set via WithSessionStorage. + sessionStorage session.Storage + + // authInfoHandler is the optional RFC 9728 OAuth protected resource discovery handler. + // When nil, /.well-known/ returns a clean JSON 404. Set via WithAuthInfoHandler. + authInfoHandler http.Handler + + // prefixHandlers contains additional HTTP handlers mounted outside the middleware chain. + // Keys are URL path prefixes. Set via WithPrefixHandlers. + prefixHandlers map[string]http.Handler + + // Waiters keyed by compositeKey(sessID, idKey) -> one-shot channel for response delivery. + // The composite key MUST be unique per concurrent request; sharing it across requests + // (e.g. with sessID="" for sessionless requests) silently overwrites entries and crosses + // response payloads between unrelated clients. See resolveSessionForRequest. waiters sync.Map // map[string]chan jsonrpc2.Message - // Map of compositeKey(sessID|idKey) -> original client JSON-RPC ID to restore before replying + // Keyed by the same compositeKey(sessID, idKey); stores the original client JSON-RPC ID + // to restore before replying. Same uniqueness requirement as `waiters`. idRestore sync.Map // map[string]jsonrpc2.ID // Health checker @@ -78,11 +100,35 @@ func WithSessionStorage(storage session.Storage) Option { if storage == nil { return } - if p.sessionManager != nil { - _ = p.sessionManager.Stop() + p.sessionStorage = storage + } +} + +// WithSessionTTL overrides the session inactivity timeout used by this proxy. +// Zero or negative values are ignored so the constructor's default is preserved. +func WithSessionTTL(ttl time.Duration) Option { + return func(p *HTTPProxy) { + if ttl <= 0 { + return } - sFactory := func(id string) session.Session { return session.NewStreamableSession(id) } - p.sessionManager = session.NewManagerWithStorage(session.ResolveSessionTTLFromEnv(), sFactory, storage) + p.sessionTTL = ttl + } +} + +// WithAuthInfoHandler sets the handler for RFC 9728 OAuth protected resource discovery. +// When set, the handler is mounted at /.well-known/ outside the middleware chain. +// When nil, /.well-known/ returns a clean JSON 404 so OAuth clients parse it cleanly. +func WithAuthInfoHandler(h http.Handler) Option { + return func(p *HTTPProxy) { + p.authInfoHandler = h + } +} + +// WithPrefixHandlers registers additional HTTP handlers mounted before the MCP endpoint. +// These are mounted outside the middleware chain (RFC 9728, embedded auth server routes). +func WithPrefixHandlers(handlers map[string]http.Handler) Option { + return func(p *HTTPProxy) { + p.prefixHandlers = maps.Clone(handlers) } } @@ -106,13 +152,20 @@ func NewHTTPProxy( middlewares: middlewares, messageCh: make(chan jsonrpc2.Message, 100), responseCh: make(chan jsonrpc2.Message, 100), - sessionManager: session.NewManager(session.ResolveSessionTTLFromEnv(), sFactory), + sessionTTL: session.ResolveSessionTTLFromEnv(), } for _, opt := range opts { opt(proxy) } + // Construct the session manager once, after options have resolved sessionTTL and sessionStorage. + if proxy.sessionStorage != nil { + proxy.sessionManager = session.NewManagerWithStorage(proxy.sessionTTL, sFactory, proxy.sessionStorage) + } else { + proxy.sessionManager = session.NewManager(proxy.sessionTTL, sFactory) + } + // Create health checker without MCP pinger // Streamable transport doesn't support MCP ping, so health check only verifies proxy is running proxy.healthChecker = healthcheck.NewHealthChecker(string(types.TransportTypeStreamableHTTP), nil) @@ -134,6 +187,21 @@ func (p *HTTPProxy) Start(_ context.Context) error { mux.Handle("/metrics", p.prometheusHandler) } + // Mount prefix handlers (e.g. embedded auth server routes) outside the middleware chain. + // RFC 9728 requires discovery endpoints to be reachable without authentication. + for prefix, h := range p.prefixHandlers { + mux.Handle(prefix, h) + slog.Debug("mounted prefix handler", "prefix", prefix) + } + + // Mount RFC 9728 OAuth protected resource discovery endpoint (no middlewares). + // Always register so OAuth discovery gets a clean JSON 404 when auth is off. + wellKnownHandler := auth.NewWellKnownHandler(p.authInfoHandler) + mux.Handle("/.well-known/", wellKnownHandler) + if p.authInfoHandler != nil { + slog.Debug("rfc 9728 OAuth discovery endpoint enabled at /.well-known/oauth-protected-resource") + } + p.server = &http.Server{ Addr: fmt.Sprintf("%s:%d", p.host, p.port), Handler: mux, @@ -296,7 +364,7 @@ func (p *HTTPProxy) handlePost(w http.ResponseWriter, r *http.Request) { } // Notifications or client responses are accepted and forwarded (202) - if p.handleNotificationOrClientResponse(w, msg) { + if p.handleNotificationOrClientResponse(w, r.Header.Get("Mcp-Session-Id"), msg) { return } @@ -619,17 +687,26 @@ func (p *HTTPProxy) ensureSession(id string) error { return p.sessionManager.AddWithID(id) } -// resolveSessionForBatch resolves or creates an ephemeral session for batch POSTs. +// resolveSessionForBatch resolves the session for batch POSTs. // Writes appropriate HTTP errors and returns an error when handling should stop. +// +// Sessionless POSTs receive a per-request UUID used solely as an in-process +// routing token. Sessionless routing tokens MUST be unique per request: +// sharing one (e.g. the empty string) across concurrent sessionless requests +// causes waiters/idRestore overwrites in the in-process sync.Maps, which leaks +// one client's response payload to another (with the JSON-RPC id rewritten to +// the receiver's). This is a confidentiality bug, not a performance issue -- +// do not collapse the token. The UUID is not registered with sessionManager, +// so no session object is created in any storage backend. func (p *HTTPProxy) resolveSessionForBatch(w http.ResponseWriter, r *http.Request) (string, error) { sessID := r.Header.Get("Mcp-Session-Id") if sessID == "" { - sessID = uuid.New().String() - if err := p.ensureSession(sessID); err != nil { - writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create session: %v", err)) - return "", err + token, err := uuid.NewRandom() + if err != nil { + writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate routing token: %v", err)) + return "", fmt.Errorf("generate routing token: %w", err) } - return sessID, nil + return token.String(), nil } if _, ok := p.sessionManager.Get(sessID); !ok { session.WriteNotFound(w, nil) @@ -639,8 +716,18 @@ func (p *HTTPProxy) resolveSessionForBatch(w http.ResponseWriter, r *http.Reques } // resolveSessionForRequest resolves session rules for a single JSON-RPC request. -// On initialize, assigns session if missing and returns setSessionHeader=true. -// For other methods, allows optional session by creating ephemeral (no header set). +// On initialize, assigns a new session ID if none is provided and returns setSessionHeader=true. +// A provided but unknown session ID returns 404. +// +// Sessionless non-initialize requests receive a per-request UUID used solely as +// an in-process routing token (not registered with sessionManager). Sessionless +// routing tokens MUST be unique per request: sharing one (e.g. the empty string) +// across concurrent sessionless requests with the same JSON-RPC id collapses +// them onto the same compositeKey(sessID, idKey) and overwrites entries in the +// waiters / idRestore sync.Maps, leaking one client's response payload to +// another. This is a confidentiality bug, not a performance issue -- do not +// collapse the token. +// // Writes HTTP errors on failure and returns error to stop handling. func (p *HTTPProxy) resolveSessionForRequest( w http.ResponseWriter, @@ -652,7 +739,12 @@ func (p *HTTPProxy) resolveSessionForRequest( if req.Method == "initialize" { if sessID == "" { - sessID = uuid.New().String() + newID, err := uuid.NewRandom() + if err != nil { + writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate session ID: %v", err)) + return "", false, fmt.Errorf("generate session ID: %w", err) + } + sessID = newID.String() setSessionHeader = true } if err := p.ensureSession(sessID); err != nil { @@ -662,17 +754,19 @@ func (p *HTTPProxy) resolveSessionForRequest( return sessID, setSessionHeader, nil } - // Non-initialize path: sessions are optional; create ephemeral if missing + // Sessionless non-initialize: generate a per-request routing token. + // setSessionHeader stays false so the client never sees this UUID and the + // next request remains sessionless. if sessID == "" { - sessID = uuid.New().String() - if err := p.ensureSession(sessID); err != nil { - writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create session: %v", err)) - return "", false, err + token, err := uuid.NewRandom() + if err != nil { + writeHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate routing token: %v", err)) + return "", false, fmt.Errorf("generate routing token: %w", err) } - return sessID, false, nil + return token.String(), false, nil } - // If session is provided, ensure it exists + // Session ID provided but not found: reject with 404. if _, ok := p.sessionManager.Get(sessID); !ok { session.WriteNotFound(w, req.ID.Raw()) return "", false, fmt.Errorf("session not found") @@ -708,8 +802,12 @@ func decodeJSONRPCMessage(w http.ResponseWriter, body []byte) (jsonrpc2.Message, return msg, true } -func (p *HTTPProxy) handleNotificationOrClientResponse(w http.ResponseWriter, msg jsonrpc2.Message) bool { +func (p *HTTPProxy) handleNotificationOrClientResponse(w http.ResponseWriter, sessID string, msg jsonrpc2.Message) bool { if isNotification(msg) || (func() bool { _, ok := msg.(*jsonrpc2.Response); return ok })() { + // Refresh TTL so a client sending only notifications doesn't get evicted. + if sessID != "" { + p.sessionManager.Get(sessID) + } if err := p.SendMessageToDestination(msg); err != nil { slog.Error("failed to send message to destination", "error", err) } diff --git a/pkg/transport/proxy/streamable/streamable_proxy_integration_test.go b/pkg/transport/proxy/streamable/streamable_proxy_integration_test.go index 653212ce0b..66208d6e81 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy_integration_test.go +++ b/pkg/transport/proxy/streamable/streamable_proxy_integration_test.go @@ -35,7 +35,7 @@ func TestHTTPRequestIgnoresNotifications(t *testing.T) { // Get an available port dynamically port := getFreePort(t) proxy := NewHTTPProxy("localhost", port, nil, nil) - ctx := context.Background() + ctx := t.Context() // Start the proxy server err := proxy.Start(ctx) @@ -106,3 +106,46 @@ func TestHTTPRequestIgnoresNotifications(t *testing.T) { assert.Equal(t, "batch-1", batchResponse[0]["id"]) assert.Equal(t, "operation complete", batchResponse[0]["result"]) } + +// TestHTTPProxy_StartMountsAuthDiscoveryEndpoint is an integration test that +// starts a real proxy with an authInfoHandler and verifies the RFC 9728 +// discovery endpoint responds correctly. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestHTTPProxy_StartMountsAuthDiscoveryEndpoint(t *testing.T) { + const wantBody = `{"resource":"https://example.com"}` + + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(wantBody)) + }) + + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil, WithAuthInfoHandler(sentinel)) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // streamable Start() returns before the goroutine binds — poll until ready. + url := fmt.Sprintf("http://localhost:%d/.well-known/oauth-protected-resource", port) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready in time") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") + + var body map[string]string + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "https://example.com", body["resource"]) +} diff --git a/pkg/transport/proxy/streamable/streamable_proxy_spec_test.go b/pkg/transport/proxy/streamable/streamable_proxy_spec_test.go index c13aea6896..1574462661 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy_spec_test.go +++ b/pkg/transport/proxy/streamable/streamable_proxy_spec_test.go @@ -7,7 +7,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" + "net" "net/http" "testing" "time" @@ -320,3 +322,115 @@ func TestSingleRequestWithStaleSessionIncludesRequestID(t *testing.T) { assert.Contains(t, string(body), `"code":-32001`) assert.Contains(t, string(body), `"id":"test-42"`) } + +// pickFreePort returns a TCP port the OS reports as available. There is a small +// race window before the proxy binds it, but that is the same pattern other +// streamable tests follow and is acceptable here. +func pickFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + return port +} + +// TestSessionlessConcurrentRequestsAreNotMixed verifies that two concurrent +// sessionless POSTs sharing a JSON-RPC id each receive their own response +// payload. Regression test: a previous change collapsed every sessionless +// request onto compositeKey("", idKey), causing the in-process waiters / +// idRestore sync.Maps to silently overwrite — symptoms were response +// cross-talk (one client receiving the other's payload, with the JSON-RPC id +// rewritten back to its own) and a request-timeout for the losing client. +// +// t.Setenv requires a non-parallel test; the trade-off is acceptable for a +// single regression test that needs a short proxy timeout to fail fast. +func TestSessionlessConcurrentRequestsAreNotMixed(t *testing.T) { + // Cap per-request timeout so this test fails in seconds, not the 60s + // default, when the bug regresses. + t.Setenv(proxyRequestTimeoutEnv, "3s") + + port := pickFreePort(t) + proxy := NewHTTPProxy("127.0.0.1", port, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { _ = proxy.Stop(ctx) }) + + time.Sleep(50 * time.Millisecond) + + // Backend uses a synchronization barrier: collect both incoming requests + // before sending any response, so both proxy waiters are registered first + // — that's the precondition under which the bug overwrites one waiter. + // Echo req.Method back in the result so we can prove each client got its + // own payload, not the other's. + go func() { + buffered := make([]*jsonrpc2.Request, 0, 2) + for len(buffered) < 2 { + select { + case msg := <-proxy.GetMessageChannel(): + if req, ok := msg.(*jsonrpc2.Request); ok && req.ID.IsValid() { + buffered = append(buffered, req) + } + case <-ctx.Done(): + return + } + } + for _, req := range buffered { + result := map[string]any{"echoed_method": req.Method} + resp, _ := jsonrpc2.NewResponse(req.ID, result, nil) + _ = proxy.ForwardResponseToClients(ctx, resp) + } + }() + + url := fmt.Sprintf("http://127.0.0.1:%d%s", port, StreamableHTTPEndpoint) + + type result struct { + method string + status int + body map[string]any + err error + } + + // Both POSTs share the same JSON-RPC id (1) and omit Mcp-Session-Id. + fire := func(method string) result { + body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":%q,"params":{}}`, method) + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewReader([]byte(body))) + if err != nil { + return result{method: method, err: err} + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + var decoded map[string]any + _ = json.NewDecoder(resp.Body).Decode(&decoded) + return result{method: method, status: resp.StatusCode, body: decoded} + } + + resCh := make(chan result, 2) + go func() { resCh <- fire("tools/list") }() + go func() { resCh <- fire("resources/list") }() + + received := map[string]result{} + for i := 0; i < 2; i++ { + select { + case r := <-resCh: + received[r.method] = r + case <-time.After(15 * time.Second): + t.Fatalf("timeout waiting for concurrent sessionless responses; received so far: %v", received) + } + } + + for _, method := range []string{"tools/list", "resources/list"} { + r := received[method] + require.NoError(t, r.err, "client %q HTTP error", method) + require.Equal(t, http.StatusOK, r.status, "client %q HTTP status", method) + require.NotNil(t, r.body, "client %q empty body", method) + res, ok := r.body["result"].(map[string]any) + require.True(t, ok, "client %q missing result: %v", method, r.body) + assert.Equal(t, method, res["echoed_method"], + "client %q received the other client's payload (response cross-talk)", method) + } +} diff --git a/pkg/transport/proxy/streamable/streamable_proxy_test.go b/pkg/transport/proxy/streamable/streamable_proxy_test.go index e8edca967e..ed36560ecd 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy_test.go +++ b/pkg/transport/proxy/streamable/streamable_proxy_test.go @@ -35,7 +35,7 @@ func TestNewHTTPProxy(t *testing.T) { //nolint:paralleltest // Test modifies shared proxy state func TestProxyChannelCommunication(t *testing.T) { proxy := NewHTTPProxy("localhost", 8080, nil, nil) - ctx := context.Background() + ctx := t.Context() // Test that we can send a message to the destination request, err := jsonrpc2.NewCall(jsonrpc2.StringID("test"), "test.method", nil) @@ -115,7 +115,7 @@ func TestSendMessageToDestination_ChannelFull(t *testing.T) { //nolint:paralleltest // Test starts/stops HTTP server func TestStartStop(t *testing.T) { proxy := NewHTTPProxy("localhost", 0, nil, nil) // Use port 0 for auto-assignment - ctx := context.Background() + ctx := t.Context() // Start the proxy err := proxy.Start(ctx) @@ -250,3 +250,185 @@ func TestNewHTTPProxyWithSessionStorage(t *testing.T) { require.NotNil(t, proxy) require.NotNil(t, proxy.sessionManager) } + +// TestNewHTTPProxy_AuthInfoHandlerMounted verifies that a sentinel authInfoHandler +// registered via WithAuthInfoHandler is reachable at /.well-known/oauth-protected-resource +// after Start is called. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPProxy_AuthInfoHandlerMounted(t *testing.T) { + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil, WithAuthInfoHandler(sentinel)) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // Poll until server is ready (max 500 ms). + url := fmt.Sprintf("http://localhost:%d/.well-known/oauth-protected-resource", port) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestNewHTTPProxy_AuthInfoHandlerNil verifies that when no authInfoHandler is set, +// requests to /.well-known/oauth-protected-resource return a JSON 404 (not a Go default 404). +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPProxy_AuthInfoHandlerNil(t *testing.T) { + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil) // no WithAuthInfoHandler + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + url := fmt.Sprintf("http://localhost:%d/.well-known/oauth-protected-resource", port) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + // WellKnownHandler returns a JSON 404, not 200. + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") +} + +// TestNewHTTPProxy_PrefixHandlerMounted verifies that a handler registered via +// WithPrefixHandlers is reachable at its declared path prefix. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPProxy_PrefixHandlerMounted(t *testing.T) { + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/oauth/token": sentinel}), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + url := fmt.Sprintf("http://localhost:%d/oauth/token", port) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestNewHTTPProxy_PrefixHandlerDoesNotShadowMCP verifies that registering prefix +// handlers does not prevent the /mcp endpoint from being reachable. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPProxy_PrefixHandlerDoesNotShadowMCP(t *testing.T) { + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/oauth/token": sentinel}), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // /mcp returns 405 for GET (only POST is accepted), which proves the + // endpoint is registered — a missing route would be 404. + url := fmt.Sprintf("http://localhost:%d%s", port, StreamableHTTPEndpoint) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(url) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) +} + +// TestNewHTTPProxy_ExactWellKnownBeatsSubtree verifies that Go's http.ServeMux +// specificity rule causes an exact /.well-known/openid-configuration prefix handler +// to win over the /.well-known/ subtree authInfoHandler (PRM endpoint), ensuring +// the two registrations coexist without collision. +// +//nolint:paralleltest // Test starts/stops HTTP server +func TestNewHTTPProxy_ExactWellKnownBeatsSubtree(t *testing.T) { + oidcHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) // sentinel: exact match wins + }) + prmHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + port := getFreePort(t) + proxy := NewHTTPProxy("localhost", port, nil, nil, + WithPrefixHandlers(map[string]http.Handler{"/.well-known/openid-configuration": oidcHandler}), + WithAuthInfoHandler(prmHandler), + ) + ctx := t.Context() + + require.NoError(t, proxy.Start(ctx)) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Stop(stopCtx) + }) + + // Poll until server is ready. + oidcURL := fmt.Sprintf("http://localhost:%d/.well-known/openid-configuration", port) + var resp *http.Response + require.Eventually(t, func() bool { + var err error + resp, err = http.Get(oidcURL) //nolint:gosec // test-only URL construction + return err == nil + }, 500*time.Millisecond, 10*time.Millisecond, "server did not become ready") + defer resp.Body.Close() + + assert.Equal(t, http.StatusTeapot, resp.StatusCode, "exact /.well-known/openid-configuration must win") + + prmURL := fmt.Sprintf("http://localhost:%d/.well-known/oauth-protected-resource", port) + resp2, err := http.Get(prmURL) //nolint:gosec // test-only URL construction + require.NoError(t, err) + defer resp2.Body.Close() + + assert.Equal(t, http.StatusOK, resp2.StatusCode, "/.well-known/ subtree must still reach authInfoHandler") +} diff --git a/pkg/transport/proxy/transparent/pinger.go b/pkg/transport/proxy/transparent/pinger.go index 9144bff464..52a8d58417 100644 --- a/pkg/transport/proxy/transparent/pinger.go +++ b/pkg/transport/proxy/transparent/pinger.go @@ -26,11 +26,6 @@ const ( DefaultPingerTimeout = 5 * time.Second ) -// NewMCPPinger creates a new MCP pinger for transparent proxies -func NewMCPPinger(targetURL string) healthcheck.MCPPinger { - return NewMCPPingerWithTimeout(targetURL, DefaultPingerTimeout) -} - // NewMCPPingerWithTimeout creates a new MCP pinger with a custom timeout func NewMCPPingerWithTimeout(targetURL string, timeout time.Duration) healthcheck.MCPPinger { if timeout <= 0 { diff --git a/pkg/transport/proxy/transparent/transparent_proxy.go b/pkg/transport/proxy/transparent/transparent_proxy.go index 9914547537..b3948cdd7b 100644 --- a/pkg/transport/proxy/transparent/transparent_proxy.go +++ b/pkg/transport/proxy/transparent/transparent_proxy.go @@ -79,6 +79,14 @@ type TransparentProxy struct { // Sessions for tracking state sessionManager *session.Manager + // sessionTTL is the resolved inactivity timeout for the session manager. + // Defaults to session.DefaultSessionTTL; overridable via WithSessionTTL. + sessionTTL time.Duration + + // sessionStorage is the optional custom storage backend for the session manager. + // When nil, in-memory LocalStorage is used. Set via WithSessionStorage. + sessionStorage session.Storage + // If mcp server has been initialized (atomic access) isServerInitialized atomic.Bool @@ -298,14 +306,18 @@ func WithSessionStorage(storage session.Storage) Option { if storage == nil { return } - if p.sessionManager != nil { - _ = p.sessionManager.Stop() + p.sessionStorage = storage + } +} + +// WithSessionTTL overrides the session inactivity timeout used by this proxy. +// Zero or negative values are ignored so the constructor's default is preserved. +func WithSessionTTL(ttl time.Duration) Option { + return func(p *TransparentProxy) { + if ttl <= 0 { + return } - p.sessionManager = session.NewManagerWithStorage( - session.ResolveSessionTTLFromEnv(), - func(id string) session.Session { return session.NewProxySession(id) }, - storage, - ) + p.sessionTTL = ttl } } @@ -434,7 +446,7 @@ func NewTransparentProxyWithOptions( prometheusHandler: prometheusHandler, authInfoHandler: authInfoHandler, prefixHandlers: prefixHandlers, - sessionManager: session.NewManager(session.ResolveSessionTTLFromEnv(), session.NewProxySession), + sessionTTL: session.ResolveSessionTTLFromEnv(), isRemote: isRemote, transportType: transportType, onHealthCheckFailed: onHealthCheckFailed, @@ -453,6 +465,14 @@ func NewTransparentProxyWithOptions( opt(proxy) } + // Construct the session manager once, after options have resolved sessionTTL and sessionStorage. + proxyFactory := func(id string) session.Session { return session.NewProxySession(id) } + if proxy.sessionStorage != nil { + proxy.sessionManager = session.NewManagerWithStorage(proxy.sessionTTL, proxyFactory, proxy.sessionStorage) + } else { + proxy.sessionManager = session.NewManager(proxy.sessionTTL, proxyFactory) + } + // Create appropriate response processor based on transport type proxy.responseProcessor = createResponseProcessor( transportType, diff --git a/pkg/transport/session/manager.go b/pkg/transport/session/manager.go index c8188673d7..28e4ae1dde 100644 --- a/pkg/transport/session/manager.go +++ b/pkg/transport/session/manager.go @@ -11,6 +11,8 @@ import ( "time" "github.com/google/uuid" + + tcredis "github.com/stacklok/toolhive-core/redis" ) const ( @@ -118,12 +120,20 @@ func NewManagerWithStorage(ttl time.Duration, factory Factory, storage Storage) // NewManagerWithRedis creates a session manager backed by Redis. // ctx is used for the initial Ping during construction and should carry any // deadline appropriate for the connection attempt (e.g. a startup timeout). -// cfg supplies the Redis connection configuration; ttl is applied as both the -// manager's cleanup interval and the Redis key TTL. -// Returns an error if the Redis client cannot be constructed (e.g. invalid config or TLS error). -// Callers that do not require Redis should use NewManager or NewTypedManager instead. -func NewManagerWithRedis(ctx context.Context, ttl time.Duration, factory Factory, cfg RedisConfig) (*Manager, error) { - storage, err := NewRedisStorage(ctx, cfg, ttl) +// cfg supplies the Redis connection configuration (delegated to the shared +// toolhive-core redis package); keyPrefix namespaces the keys and must end +// with ':'. ttl is applied as both the manager's cleanup interval and the +// Redis key TTL. Returns an error if the Redis client cannot be constructed +// (e.g. invalid config or TLS error). Callers that do not require Redis +// should use NewManager or NewTypedManager instead. +func NewManagerWithRedis( + ctx context.Context, + ttl time.Duration, + factory Factory, + cfg tcredis.Config, + keyPrefix string, +) (*Manager, error) { + storage, err := NewRedisStorage(ctx, cfg, keyPrefix, ttl) if err != nil { return nil, fmt.Errorf("creating redis storage: %w", err) } diff --git a/pkg/transport/session/manager_redis_test.go b/pkg/transport/session/manager_redis_test.go index d1c48941a7..d2c203019f 100644 --- a/pkg/transport/session/manager_redis_test.go +++ b/pkg/transport/session/manager_redis_test.go @@ -11,6 +11,8 @@ import ( "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + tcredis "github.com/stacklok/toolhive-core/redis" ) func proxyFactory(id string) Session { return NewProxySession(id) } @@ -23,10 +25,13 @@ func TestNewManagerWithRedis(t *testing.T) { mr := miniredis.RunT(t) defer mr.Close() - m, err := NewManagerWithRedis(context.Background(), time.Hour, proxyFactory, RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:mgr:", - }) + m, err := NewManagerWithRedis( + context.Background(), + time.Hour, + proxyFactory, + tcredis.Config{Addr: mr.Addr()}, + "test:mgr:", + ) require.NoError(t, err) require.NotNil(t, m) defer m.Stop() @@ -35,23 +40,16 @@ func TestNewManagerWithRedis(t *testing.T) { assert.True(t, isRedis, "storage should be *RedisStorage") }) - t.Run("invalid config returns error", func(t *testing.T) { - t.Parallel() - // Missing KeyPrefix → validateRedisConfig fails before Ping - m, err := NewManagerWithRedis(context.Background(), time.Hour, proxyFactory, RedisConfig{ - Addr: "localhost:6379", - }) - require.Error(t, err) - assert.Nil(t, m) - }) - - t.Run("invalid TLS CA cert returns error", func(t *testing.T) { + t.Run("missing key prefix returns error", func(t *testing.T) { t.Parallel() - m, err := NewManagerWithRedis(context.Background(), time.Hour, proxyFactory, RedisConfig{ - Addr: "localhost:6379", - KeyPrefix: "test:mgr:", - TLS: &RedisTLSConfig{CACert: []byte("not-valid-pem")}, - }) + // Empty keyPrefix fails session-invariant check before Ping. + m, err := NewManagerWithRedis( + context.Background(), + time.Hour, + proxyFactory, + tcredis.Config{Addr: "localhost:6379"}, + "", + ) require.Error(t, err) assert.Nil(t, m) }) @@ -61,10 +59,13 @@ func TestNewManagerWithRedis(t *testing.T) { mr := miniredis.RunT(t) defer mr.Close() - m, err := NewManagerWithRedis(context.Background(), time.Hour, proxyFactory, RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:mgr:", - }) + m, err := NewManagerWithRedis( + context.Background(), + time.Hour, + proxyFactory, + tcredis.Config{Addr: mr.Addr()}, + "test:mgr:", + ) require.NoError(t, err) defer m.Stop() @@ -81,10 +82,13 @@ func TestNewManagerWithRedis(t *testing.T) { mr := miniredis.RunT(t) defer mr.Close() - m, err := NewManagerWithRedis(context.Background(), time.Hour, proxyFactory, RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:mgr:", - }) + m, err := NewManagerWithRedis( + context.Background(), + time.Hour, + proxyFactory, + tcredis.Config{Addr: mr.Addr()}, + "test:mgr:", + ) require.NoError(t, err) require.NoError(t, m.AddWithID("bbbbbbbb-0002-0001-0001-000000000002")) diff --git a/pkg/transport/session/redis_config.go b/pkg/transport/session/redis_config.go index 0258fda099..900a911b7a 100644 --- a/pkg/transport/session/redis_config.go +++ b/pkg/transport/session/redis_config.go @@ -3,71 +3,8 @@ package session -import "time" - // RedisPasswordEnvVar is the environment variable name for the Redis session storage password. // The operator injects this as a SecretKeyRef when sessionStorage.provider is "redis" // and passwordRef is set. // #nosec G101 -- This is an environment variable name, not a hardcoded credential const RedisPasswordEnvVar = "THV_SESSION_REDIS_PASSWORD" - -// Default timeouts for Redis operations. -const ( - DefaultDialTimeout = 5 * time.Second - DefaultReadTimeout = 3 * time.Second - DefaultWriteTimeout = 3 * time.Second -) - -// RedisConfig configures the Redis storage backend for session storage. -// Addr is used for standalone; SentinelConfig activates Sentinel mode (mutually exclusive). -type RedisConfig struct { - // Addr is the Redis server address for standalone mode (e.g., "host:port"). - Addr string - - // SentinelConfig, when non-nil, activates Sentinel mode. Mutually exclusive with Addr. - SentinelConfig *SentinelConfig - - // Username is the Redis ACL username (Redis 6.0+). When non-empty, both - // Username and Password are sent as ACL credentials (AUTH username password). - // Leave empty to authenticate as the default user (legacy AUTH password). - Username string - - // Password is the Redis AUTH password. Used with Username for ACL auth, - // or alone for legacy AUTH with the default user. - Password string //nolint:gosec // G101: not a hardcoded credential - - // DB is the Redis database index. - DB int - - // KeyPrefix namespaces all session keys (e.g., "thv:vmcp:session:"). - KeyPrefix string - - // DialTimeout is the timeout for establishing a connection. Defaults to 5s. - DialTimeout time.Duration - - // ReadTimeout is the timeout for read operations. Defaults to 3s. - ReadTimeout time.Duration - - // WriteTimeout is the timeout for write operations. Defaults to 3s. - WriteTimeout time.Duration - - // TLS configures TLS for Redis connections. nil means plaintext. - TLS *RedisTLSConfig -} - -// SentinelConfig contains Redis Sentinel configuration. -type SentinelConfig struct { - MasterName string - SentinelAddrs []string -} - -// RedisTLSConfig holds TLS configuration for Redis connections. -// Presence of this struct enables TLS for the connection. -type RedisTLSConfig struct { - // InsecureSkipVerify skips TLS certificate verification. - InsecureSkipVerify bool - - // CACert is the PEM-encoded CA certificate for verifying the server. - // When nil, the system root CAs are used. - CACert []byte -} diff --git a/pkg/transport/session/session_data_storage_redis.go b/pkg/transport/session/session_data_storage_redis.go index 82ca1767d5..1ca52279fc 100644 --- a/pkg/transport/session/session_data_storage_redis.go +++ b/pkg/transport/session/session_data_storage_redis.go @@ -11,6 +11,8 @@ import ( "time" "github.com/redis/go-redis/v9" + + tcredis "github.com/stacklok/toolhive-core/redis" ) // RedisSessionDataStorage implements DataStorage backed by Redis/Valkey. @@ -27,23 +29,31 @@ type RedisSessionDataStorage struct { ttl time.Duration } -// NewRedisSessionDataStorage constructs a RedisSessionDataStorage. -// cfg provides connection parameters; ttl is the sliding-window expiry applied -// on every Create/Update and Load. The caller must call Close when done. -func NewRedisSessionDataStorage(ctx context.Context, cfg RedisConfig, ttl time.Duration) (*RedisSessionDataStorage, error) { - if err := validateRedisConfig(&cfg); err != nil { - return nil, fmt.Errorf("invalid redis configuration: %w", err) - } - if ttl <= 0 { - return nil, fmt.Errorf("ttl must be a positive duration") +// NewRedisSessionDataStorage constructs a RedisSessionDataStorage. Connection-mode +// topology, timeouts, TLS, and credentials are configured through cfg; keyPrefix +// is the per-tenant key prefix (e.g. "thv:vmcp:session:") and must end with ':' +// to avoid key collisions. ttl is the sliding-window expiry applied on every +// Create/Update and Load. The caller must call Close when done. +// +// Connection-mode validation, timeout defaults, client construction (standalone, +// cluster, or sentinel), TLS plumbing, and connectivity verification are +// delegated to the shared toolhive-core redis package. +func NewRedisSessionDataStorage( + ctx context.Context, + cfg tcredis.Config, + keyPrefix string, + ttl time.Duration, +) (*RedisSessionDataStorage, error) { + if err := validateSessionInvariants(keyPrefix, ttl); err != nil { + return nil, err } - client, err := buildRedisClient(ctx, &cfg) + client, err := tcredis.NewClient(ctx, &cfg) if err != nil { return nil, err } return &RedisSessionDataStorage{ client: client, - keyPrefix: cfg.KeyPrefix, + keyPrefix: keyPrefix, ttl: ttl, }, nil } diff --git a/pkg/transport/session/storage_redis.go b/pkg/transport/session/storage_redis.go index f78bcdf41f..3b046d7df7 100644 --- a/pkg/transport/session/storage_redis.go +++ b/pkg/transport/session/storage_redis.go @@ -5,13 +5,13 @@ package session import ( "context" - "crypto/tls" - "crypto/x509" "errors" "fmt" "time" "github.com/redis/go-redis/v9" + + tcredis "github.com/stacklok/toolhive-core/redis" ) // RedisStorage implements the Storage interface backed by Redis. @@ -21,107 +21,33 @@ type RedisStorage struct { ttl time.Duration } -func validateRedisConfig(cfg *RedisConfig) error { - if cfg.Addr != "" && cfg.SentinelConfig != nil { - return errors.New("addr and SentinelConfig are mutually exclusive") - } - if cfg.Addr == "" && cfg.SentinelConfig == nil { - return errors.New("one of Addr (standalone) or SentinelConfig (sentinel) must be set") - } - if cfg.SentinelConfig != nil { - if cfg.SentinelConfig.MasterName == "" { - return errors.New("SentinelConfig.MasterName is required") - } - if len(cfg.SentinelConfig.SentinelAddrs) == 0 { - return errors.New("SentinelConfig.SentinelAddrs must not be empty") - } - } - if cfg.KeyPrefix == "" { - return errors.New("KeyPrefix is required") - } - if cfg.KeyPrefix[len(cfg.KeyPrefix)-1] != ':' { - return errors.New("KeyPrefix must end with ':' to avoid key collisions (e.g. \"thv:vmcp:session:\")") - } - return nil -} - -// NewRedisStorage constructs a RedisStorage from a RedisConfig. -// ttl is the expiry applied to every key on Store and refreshed on every Load (sliding window). -// Because TTL is sliding, sessions remain valid indefinitely while actively used; revocation -// requires an explicit Delete call. There is no absolute maximum session lifetime. -func NewRedisStorage(ctx context.Context, cfg RedisConfig, ttl time.Duration) (*RedisStorage, error) { - if err := validateRedisConfig(&cfg); err != nil { - return nil, fmt.Errorf("invalid redis configuration: %w", err) - } - if ttl <= 0 { - return nil, fmt.Errorf("ttl must be a positive duration") +// NewRedisStorage constructs a RedisStorage. Connection-mode topology, timeouts, +// TLS, and credentials are configured through cfg; keyPrefix is the per-tenant +// key prefix (e.g. "thv:proxy:session:") and must end with ':' to avoid key +// collisions. ttl is the sliding-window expiry applied to every key on Store +// and refreshed on every Load (sliding window): sessions remain valid +// indefinitely while actively used; revocation requires an explicit Delete call. +// +// Connection-mode validation, timeout defaults, client construction (standalone, +// cluster, or sentinel), TLS plumbing, and connectivity verification are +// delegated to the shared toolhive-core redis package. +func NewRedisStorage(ctx context.Context, cfg tcredis.Config, keyPrefix string, ttl time.Duration) (*RedisStorage, error) { + if err := validateSessionInvariants(keyPrefix, ttl); err != nil { + return nil, err } - client, err := buildRedisClient(ctx, &cfg) + client, err := tcredis.NewClient(ctx, &cfg) if err != nil { return nil, err } return &RedisStorage{ client: client, - keyPrefix: cfg.KeyPrefix, + keyPrefix: keyPrefix, ttl: ttl, }, nil } -// buildRedisClient applies timeout defaults, resolves TLS, constructs either a -// standalone or Sentinel client from cfg, and verifies the connection with Ping. -// cfg is modified in place (timeout defaults written back). -func buildRedisClient(ctx context.Context, cfg *RedisConfig) (redis.UniversalClient, error) { - if cfg.DialTimeout == 0 { - cfg.DialTimeout = DefaultDialTimeout - } - if cfg.ReadTimeout == 0 { - cfg.ReadTimeout = DefaultReadTimeout - } - if cfg.WriteTimeout == 0 { - cfg.WriteTimeout = DefaultWriteTimeout - } - - tlsCfg, err := buildRedisTLSConfig(cfg.TLS) - if err != nil { - return nil, fmt.Errorf("tls configuration error: %w", err) - } - - var client redis.UniversalClient - if cfg.SentinelConfig != nil { - client = redis.NewFailoverClient(&redis.FailoverOptions{ - MasterName: cfg.SentinelConfig.MasterName, - SentinelAddrs: cfg.SentinelConfig.SentinelAddrs, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - DialTimeout: cfg.DialTimeout, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - TLSConfig: tlsCfg, - }) - } else { - client = redis.NewClient(&redis.Options{ - Addr: cfg.Addr, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - DialTimeout: cfg.DialTimeout, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - TLSConfig: tlsCfg, - }) - } - - if err := client.Ping(ctx).Err(); err != nil { - _ = client.Close() - return nil, fmt.Errorf("failed to connect to redis: %w", err) - } - return client, nil -} - // newRedisStorageWithClient creates a RedisStorage with a pre-configured client. -// Intended for tests only (bypasses config validation and Ping); production callers -// must use NewRedisStorage. +// Intended for tests only (bypasses Ping); production callers must use NewRedisStorage. func newRedisStorageWithClient(client redis.UniversalClient, keyPrefix string, ttl time.Duration) *RedisStorage { return &RedisStorage{ client: client, @@ -130,23 +56,22 @@ func newRedisStorageWithClient(client redis.UniversalClient, keyPrefix string, t } } -// buildRedisTLSConfig creates a *tls.Config from a RedisTLSConfig. -func buildRedisTLSConfig(cfg *RedisTLSConfig) (*tls.Config, error) { - if cfg == nil { - return nil, nil +// validateSessionInvariants checks the per-storage invariants that the shared +// toolhive-core redis package does not enforce (key-prefix conventions, TTL +// bounds). +func validateSessionInvariants(keyPrefix string, ttl time.Duration) error { + if keyPrefix == "" { + return errors.New("invalid redis configuration: key prefix is required") } - tc := &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // G402: configurable per-deployment + if keyPrefix[len(keyPrefix)-1] != ':' { + return errors.New( + `invalid redis configuration: key prefix must end with ':' to avoid key collisions (e.g. "thv:vmcp:session:")`, + ) } - if len(cfg.CACert) > 0 { - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(cfg.CACert) { - return nil, fmt.Errorf("failed to parse CA certificate PEM data") - } - tc.RootCAs = pool + if ttl <= 0 { + return fmt.Errorf("ttl must be a positive duration") } - return tc, nil + return nil } func (s *RedisStorage) key(id string) string { diff --git a/pkg/transport/session/storage_redis_test.go b/pkg/transport/session/storage_redis_test.go index 3155ce4668..8804f65a11 100644 --- a/pkg/transport/session/storage_redis_test.go +++ b/pkg/transport/session/storage_redis_test.go @@ -16,6 +16,8 @@ import ( "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + tcredis "github.com/stacklok/toolhive-core/redis" ) // --- Test Helpers --- @@ -44,65 +46,60 @@ func withRedisStorage(t *testing.T, fn func(context.Context, *RedisStorage, *min } // --- Config Validation Tests --- +// +// Connection-mode topology, TLS plumbing, and timeout defaults are validated by +// the shared toolhive-core redis package and are exercised in its own test +// suite. The cases below cover only the session-specific invariants enforced +// by NewRedisStorage (key prefix conventions, TTL bounds, ACL pass-through). -func TestValidateRedisConfig(t *testing.T) { +func TestNewRedisStorageInvariants(t *testing.T) { t.Parallel() + // These cases fail in validateSessionInvariants before any Redis call, so + // the Addr below is never dialed. tests := []struct { - name string - cfg RedisConfig - wantErr string + name string + keyPrefix string + ttl time.Duration + wantErr string }{ { - name: "both Addr and SentinelConfig set", - cfg: RedisConfig{Addr: "localhost:6379", SentinelConfig: &SentinelConfig{MasterName: "m", SentinelAddrs: []string{"s:26379"}}, KeyPrefix: "p:"}, - wantErr: "mutually exclusive", - }, - { - name: "neither Addr nor SentinelConfig set", - cfg: RedisConfig{KeyPrefix: "p:"}, - wantErr: "one of Addr", - }, - { - name: "Sentinel missing MasterName", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{SentinelAddrs: []string{"s:26379"}}, KeyPrefix: "p:"}, - wantErr: "MasterName", - }, - { - name: "Sentinel missing SentinelAddrs", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "m"}, KeyPrefix: "p:"}, - wantErr: "SentinelAddrs", + name: "empty key prefix", + keyPrefix: "", + ttl: time.Minute, + wantErr: "key prefix is required", }, { - name: "empty KeyPrefix", - cfg: RedisConfig{Addr: "localhost:6379"}, - wantErr: "KeyPrefix", + name: "key prefix without trailing colon", + keyPrefix: "thvsession", + ttl: time.Minute, + wantErr: "must end with ':'", }, { - name: "KeyPrefix without trailing colon", - cfg: RedisConfig{Addr: "localhost:6379", KeyPrefix: "thvsession"}, - wantErr: "must end with ':'", + name: "zero ttl", + keyPrefix: "test:", + ttl: 0, + wantErr: "ttl", }, { - name: "valid standalone", - cfg: RedisConfig{Addr: "localhost:6379", KeyPrefix: "thv:vmcp:session:"}, - }, - { - name: "valid sentinel", - cfg: RedisConfig{SentinelConfig: &SentinelConfig{MasterName: "m", SentinelAddrs: []string{"s:26379"}}, KeyPrefix: "thv:vmcp:session:"}, + name: "negative ttl", + keyPrefix: "test:", + ttl: -1 * time.Second, + wantErr: "ttl", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := validateRedisConfig(&tc.cfg) - if tc.wantErr == "" { - assert.NoError(t, err) - } else { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantErr) - } + _, err := NewRedisStorage( + context.Background(), + tcredis.Config{Addr: "localhost:0"}, + tc.keyPrefix, + tc.ttl, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) }) } } @@ -116,12 +113,11 @@ func TestNewRedisStorageACLAuth(t *testing.T) { defer mr.Close() mr.RequireUserAuth("alice", "secret") - storage, err := NewRedisStorage(context.Background(), RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:acl:", - Username: "alice", - Password: "secret", - }, time.Minute) + storage, err := NewRedisStorage(context.Background(), tcredis.Config{ + Addr: mr.Addr(), + Username: "alice", + Password: "secret", + }, "test:acl:", time.Minute) require.NoError(t, err) defer storage.Close() @@ -139,32 +135,16 @@ func TestNewRedisStorageACLAuth(t *testing.T) { defer mr.Close() mr.RequireUserAuth("alice", "secret") - _, err := NewRedisStorage(context.Background(), RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:acl:", - Username: "alice", - Password: "wrong", - }, time.Minute) + _, err := NewRedisStorage(context.Background(), tcredis.Config{ + Addr: mr.Addr(), + Username: "alice", + Password: "wrong", + }, "test:acl:", time.Minute) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect to redis") + assert.Contains(t, err.Error(), "failed to connect") }) } -func TestNewRedisStorageTTLValidation(t *testing.T) { - t.Parallel() - mr := miniredis.RunT(t) - defer mr.Close() - cfg := RedisConfig{Addr: mr.Addr(), KeyPrefix: "test:"} - - _, err := NewRedisStorage(context.Background(), cfg, 0) - require.Error(t, err) - assert.Contains(t, err.Error(), "ttl") - - _, err = NewRedisStorage(context.Background(), cfg, -1*time.Second) - require.Error(t, err) - assert.Contains(t, err.Error(), "ttl") -} - // redisTestIDs holds fixed UUIDs for use across Redis storage tests. // Each test uses a distinct UUID to prevent cross-test key collisions. const ( diff --git a/pkg/transport/stdio.go b/pkg/transport/stdio.go index 315dbb11ab..b9bb516384 100644 --- a/pkg/transport/stdio.go +++ b/pkg/transport/stdio.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net" "net/http" "strings" @@ -63,6 +64,9 @@ type StdioTransport struct { prometheusHandler http.Handler trustProxyHeaders bool sessionStorage session.Storage + sessionTTL time.Duration + authInfoHandler http.Handler + prefixHandlers map[string]http.Handler // Mutex for protecting shared state mutex sync.Mutex @@ -142,6 +146,22 @@ func (t *StdioTransport) SetSessionStorage(storage session.Storage) { t.sessionStorage = storage } +// SetSessionTTL configures the inactivity timeout for sessions managed by the +// underlying proxy. Zero is valid and means "use the proxy's default". +func (t *StdioTransport) SetSessionTTL(ttl time.Duration) { + t.sessionTTL = ttl +} + +// SetAuthInfoHandler sets the RFC 9728 OAuth protected resource discovery handler. +func (t *StdioTransport) SetAuthInfoHandler(h http.Handler) { + t.authInfoHandler = h +} + +// SetPrefixHandlers sets additional HTTP handlers mounted outside the middleware chain. +func (t *StdioTransport) SetPrefixHandlers(m map[string]http.Handler) { + t.prefixHandlers = maps.Clone(m) +} + // Mode returns the transport mode. func (*StdioTransport) Mode() types.TransportType { return types.TransportTypeStdio @@ -192,9 +212,16 @@ func (t *StdioTransport) Start(ctx context.Context) error { switch t.proxyMode { case types.ProxyModeStreamableHTTP: var streamableOpts []streamable.Option + if t.sessionTTL > 0 { + streamableOpts = append(streamableOpts, streamable.WithSessionTTL(t.sessionTTL)) + } if t.sessionStorage != nil { streamableOpts = append(streamableOpts, streamable.WithSessionStorage(t.sessionStorage)) } + streamableOpts = append(streamableOpts, + streamable.WithAuthInfoHandler(t.authInfoHandler), + streamable.WithPrefixHandlers(t.prefixHandlers), + ) t.httpProxy = streamable.NewHTTPProxy(t.host, t.proxyPort, t.prometheusHandler, t.middlewares, streamableOpts...) if err := t.httpProxy.Start(ctx); err != nil { return err @@ -202,9 +229,16 @@ func (t *StdioTransport) Start(ctx context.Context) error { slog.Debug("streamable HTTP proxy started, processing messages") case types.ProxyModeSSE: var sseOpts []httpsse.Option + if t.sessionTTL > 0 { + sseOpts = append(sseOpts, httpsse.WithSessionTTL(t.sessionTTL)) + } if t.sessionStorage != nil { sseOpts = append(sseOpts, httpsse.WithSessionStorage(t.sessionStorage)) } + sseOpts = append(sseOpts, + httpsse.WithAuthInfoHandler(t.authInfoHandler), + httpsse.WithPrefixHandlers(t.prefixHandlers), + ) t.httpProxy = httpsse.NewHTTPSSEProxy( t.host, t.proxyPort, diff --git a/pkg/transport/stdio_test.go b/pkg/transport/stdio_test.go index 6640d5def5..4b5f135964 100644 --- a/pkg/transport/stdio_test.go +++ b/pkg/transport/stdio_test.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "net/http" "sync" "testing" "time" @@ -21,6 +22,7 @@ import ( rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/container/runtime/mocks" + "github.com/stacklok/toolhive/pkg/transport/types" ) // MockHTTPProxy is a mock implementation of types.Proxy @@ -1036,3 +1038,160 @@ func TestStdioTransport_ShouldRestart(t *testing.T) { }) } } + +// TestNewStdioTransport_PreservesAuthInfoHandler verifies that SetAuthInfoHandler +// stores the handler on the struct (no networking required). +func TestNewStdioTransport_PreservesAuthInfoHandler(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + tr := NewStdioTransport("localhost", 8080, nil, false, false, nil) + tr.SetAuthInfoHandler(handler) + + // authInfoHandler is unexported but accessible within the same package. + require.NotNil(t, tr.authInfoHandler) +} + +// TestNewStdioTransport_PreservesPrefixHandlers verifies that SetPrefixHandlers +// stores the map on the struct (no networking required). +func TestNewStdioTransport_PreservesPrefixHandlers(t *testing.T) { + t.Parallel() + + handlers := map[string]http.Handler{ + "/oauth/token": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + } + + tr := NewStdioTransport("localhost", 8080, nil, false, false, nil) + tr.SetPrefixHandlers(handlers) + + require.Len(t, tr.prefixHandlers, 1) + require.Contains(t, tr.prefixHandlers, "/oauth/token") +} + +func TestFactory_Create_PreservesAuthFields(t *testing.T) { + t.Parallel() + + sentinel := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + prefixHandlers := map[string]http.Handler{ + "/oauth/token": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + } + + tests := []struct { + name string + cfg types.Config + check func(t *testing.T, tr types.Transport) + }{ + { + name: "stdio/authInfoHandler", + cfg: types.Config{ + Type: types.TransportTypeStdio, + Host: "localhost", + ProxyPort: 8080, + AuthInfoHandler: sentinel, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + stdio, ok := tr.(*StdioTransport) + require.True(t, ok, "expected *StdioTransport") + require.NotNil(t, stdio.authInfoHandler) + }, + }, + { + name: "stdio/prefixHandlers", + cfg: types.Config{ + Type: types.TransportTypeStdio, + Host: "localhost", + ProxyPort: 8080, + PrefixHandlers: prefixHandlers, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + stdio, ok := tr.(*StdioTransport) + require.True(t, ok, "expected *StdioTransport") + require.Len(t, stdio.prefixHandlers, 1) + require.Contains(t, stdio.prefixHandlers, "/oauth/token") + }, + }, + { + name: "sse/authInfoHandler", + cfg: types.Config{ + Type: types.TransportTypeSSE, + Host: "localhost", + ProxyPort: 8080, + AuthInfoHandler: sentinel, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + ht, ok := tr.(*HTTPTransport) + require.True(t, ok, "expected *HTTPTransport") + require.NotNil(t, ht.authInfoHandler) + }, + }, + { + name: "sse/prefixHandlers", + cfg: types.Config{ + Type: types.TransportTypeSSE, + Host: "localhost", + ProxyPort: 8080, + PrefixHandlers: prefixHandlers, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + ht, ok := tr.(*HTTPTransport) + require.True(t, ok, "expected *HTTPTransport") + require.Len(t, ht.prefixHandlers, 1) + require.Contains(t, ht.prefixHandlers, "/oauth/token") + }, + }, + { + name: "streamable-http/authInfoHandler", + cfg: types.Config{ + Type: types.TransportTypeStreamableHTTP, + Host: "localhost", + ProxyPort: 8080, + AuthInfoHandler: sentinel, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + ht, ok := tr.(*HTTPTransport) + require.True(t, ok, "expected *HTTPTransport") + require.NotNil(t, ht.authInfoHandler) + }, + }, + { + name: "streamable-http/prefixHandlers", + cfg: types.Config{ + Type: types.TransportTypeStreamableHTTP, + Host: "localhost", + ProxyPort: 8080, + PrefixHandlers: prefixHandlers, + }, + check: func(t *testing.T, tr types.Transport) { + t.Helper() + ht, ok := tr.(*HTTPTransport) + require.True(t, ok, "expected *HTTPTransport") + require.Len(t, ht.prefixHandlers, 1) + require.Contains(t, ht.prefixHandlers, "/oauth/token") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + factory := NewFactory() + tr, err := factory.Create(tt.cfg) + require.NoError(t, err) + tt.check(t, tr) + }) + } +} diff --git a/pkg/transport/types/transport.go b/pkg/transport/types/transport.go index beec37e407..1096827531 100644 --- a/pkg/transport/types/transport.go +++ b/pkg/transport/types/transport.go @@ -11,6 +11,7 @@ import ( "context" "encoding/json" "net/http" + "time" "golang.org/x/exp/jsonrpc2" "golang.org/x/oauth2" @@ -276,6 +277,11 @@ type Config struct { // Used for Redis-backed session sharing across replicas. // When nil, transports use their default in-memory LocalStorage. SessionStorage session.Storage + + // SessionTTL is the inactivity timeout for sessions managed by this proxy. + // Sessions idle for longer than this duration are cleaned up by the session + // manager's background worker. Zero uses session.DefaultSessionTTL. + SessionTTL time.Duration } // ProxyMode represents the proxy mode for stdio transport. diff --git a/pkg/tui/actions.go b/pkg/tui/actions.go new file mode 100644 index 0000000000..c5da9a910b --- /dev/null +++ b/pkg/tui/actions.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "fmt" + "log/slog" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + cfg "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/runner/retriever" + "github.com/stacklok/toolhive/pkg/transport" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// actionDoneMsg is sent when a stop/restart/delete action completes. +type actionDoneMsg struct { + action string // "stopped", "restarted", "deleted" + name string // workload name + err error +} + +// stopWorkload returns a tea.Cmd that stops the named workload. +func stopWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.StopWorkloads(ctx, []string{name}) + if err != nil { + return actionDoneMsg{action: "stopped", name: name, err: err} + } + return actionDoneMsg{action: "stopped", name: name, err: fn()} + } +} + +// deleteWorkload returns a tea.Cmd that removes the named workload. +func deleteWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.DeleteWorkloads(ctx, []string{name}) + if err != nil { + return actionDoneMsg{action: "deleted", name: name, err: err} + } + return actionDoneMsg{action: "deleted", name: name, err: fn()} + } +} + +// restartWorkload returns a tea.Cmd that restarts the named workload. +func restartWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.RestartWorkloads(ctx, []string{name}, false) + if err != nil { + return actionDoneMsg{action: "restarted", name: name, err: err} + } + return actionDoneMsg{action: "restarted", name: name, err: fn()} + } +} + +// runFormResultMsg is sent when a "run from registry" command completes. +type runFormResultMsg struct { + name string + server string + err error +} + +// runFromRegistry returns a tea.Cmd that builds a RunConfig from registry +// metadata and launches the workload via the in-process workloads manager. +// This avoids shelling out to `thv run`, which leaks secrets in +// /proc//cmdline and introduces unnecessary subprocess complexity. +func runFromRegistry( + ctx context.Context, + manager workloads.Manager, + item regtypes.ServerMetadata, + workloadName string, + secrets, envs map[string]string, +) tea.Cmd { + return func() tea.Msg { + serverName := item.GetName() + + runCfg, err := buildRunConfigFromRegistry(ctx, item, workloadName, secrets, envs) + if err != nil { + return runFormResultMsg{name: workloadName, server: serverName, err: err} + } + + // Enforce policy before saving state so violations surface immediately. + if err := runner.EagerCheckCreateServer(ctx, runCfg); err != nil { + return runFormResultMsg{name: workloadName, server: serverName, + err: fmt.Errorf("server creation blocked by policy: %w", err)} + } + + // Persist the config before starting (both foreground and detached need this). + if err := runCfg.SaveState(ctx); err != nil { + return runFormResultMsg{name: workloadName, server: serverName, + err: fmt.Errorf("save run configuration: %w", err)} + } + + if err := manager.RunWorkloadDetached(ctx, runCfg); err != nil { + return runFormResultMsg{name: workloadName, server: serverName, err: err} + } + + return runFormResultMsg{name: workloadName, server: serverName} + } +} + +// validateAndMergeEnvVars validates that no key contains '=' and merges +// secrets and env vars into a single map for the run config builder. +func validateAndMergeEnvVars(secrets, envs map[string]string) (map[string]string, error) { + for k := range secrets { + if strings.ContainsRune(k, '=') { + return nil, fmt.Errorf("invalid secret name %q: must not contain '='", k) + } + } + for k := range envs { + if strings.ContainsRune(k, '=') { + return nil, fmt.Errorf("invalid env var name %q: must not contain '='", k) + } + } + merged := make(map[string]string, len(secrets)+len(envs)) + for k, v := range secrets { + merged[k] = v + } + for k, v := range envs { + merged[k] = v + } + return merged, nil +} + +// permissionProfileName extracts the permission profile name from ImageMetadata, +// returning "" if none is set or the name is "none". +func permissionProfileName(img *regtypes.ImageMetadata) string { + if img == nil || img.Permissions == nil { + return "" + } + if name := img.Permissions.Name; name != "" && name != "none" { + return name + } + return "" +} + +// buildRunConfigFromRegistry constructs a runner.RunConfig from the registry +// metadata and the form field values. It mirrors the logic in +// cmd/thv/app/run_flags.go BuildRunnerConfig but only for the subset of +// options relevant to a TUI "run from registry" flow. +func buildRunConfigFromRegistry( + ctx context.Context, + item regtypes.ServerMetadata, + workloadName string, + secrets, envs map[string]string, +) (*runner.RunConfig, error) { + serverName := item.GetName() + + mergedEnvVars, err := validateAndMergeEnvVars(secrets, envs) + if err != nil { + return nil, err + } + + // Extract ImageMetadata if available (needed for the builder). + var imageMetadata *regtypes.ImageMetadata + if img, ok := item.(*regtypes.ImageMetadata); ok && img != nil { + imageMetadata = img + } + + // Resolve the image URL from the registry (no pull yet). + imageURL, serverMetadata, err := retriever.ResolveMCPServer( + ctx, serverName, "" /* caCertPath */, retriever.VerifyImageWarn, "" /* groupName */, nil) + if err != nil { + return nil, fmt.Errorf("resolve MCP server %s: %w", serverName, err) + } + + // Resolve the transport: prefer registry metadata, default to streamable-http. + transportType := item.GetTransport() + if transportType == "" { + transportType = "streamable-http" + } + + // Load application config for registry source URLs. + configProvider := cfg.NewProvider() + appConfig, loadErr := configProvider.LoadOrCreateConfig() + if loadErr != nil { + slog.Warn("failed to load application config, registry source URLs will be empty", "error", loadErr) + } + regAPIURL, regURL := runner.ResolveRegistrySourceURLs(serverMetadata, appConfig) + regServerName := runner.ResolveRegistryServerName(serverMetadata) + + runConfig, err := runner.NewRunConfigBuilder( + ctx, + imageMetadata, + mergedEnvVars, + &runner.DetachedEnvVarValidator{}, + runner.WithName(workloadName), + runner.WithImage(imageURL), + runner.WithHost(transport.LocalhostIPv4), + runner.WithTargetHost(transport.LocalhostIPv4), + runner.WithTransportAndPorts(transportType, 0 /* proxyPort */, 0 /* targetPort */), + runner.WithPermissionProfileNameOrPath(permissionProfileName(imageMetadata)), + runner.WithGroup("default"), + runner.WithRegistrySourceURLs(regAPIURL, regURL), + runner.WithRegistryServerName(regServerName), + ) + if err != nil { + return nil, fmt.Errorf("build run config: %w", err) + } + + // Pull the image (for container-based servers) after the config is built. + if err := retriever.EnforcePolicyAndPullImage( + ctx, runConfig, serverMetadata, imageURL, + retriever.PullMCPServerImage, 0, false, + ); err != nil { + return nil, fmt.Errorf("pull image: %w", err) + } + + return runConfig, nil +} diff --git a/pkg/tui/form_helpers.go b/pkg/tui/form_helpers.go new file mode 100644 index 0000000000..cd3e39df83 --- /dev/null +++ b/pkg/tui/form_helpers.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// formNextField advances focus to the next field in a formField slice (wraps around). +func formNextField(fields []formField, idx *int) { + if len(fields) == 0 { + return + } + if *idx >= 0 { + fields[*idx].input.Blur() + } + *idx = (*idx + 1) % len(fields) + fields[*idx].input.Focus() +} + +// formPrevField moves focus to the previous field in a formField slice (wraps around). +func formPrevField(fields []formField, idx *int) { + if len(fields) == 0 { + return + } + if *idx >= 0 { + fields[*idx].input.Blur() + } + if *idx <= 0 { + *idx = len(fields) - 1 + } else { + *idx-- + } + fields[*idx].input.Focus() +} + +// formBlurAll blurs every field and resets the focused index to -1. +func formBlurAll(fields []formField, idx *int) { + for i := range fields { + fields[i].input.Blur() + } + *idx = -1 +} + +// formForwardKey forwards a key message to the currently focused field. +func formForwardKey(fields []formField, idx int, msg tea.KeyMsg) tea.Cmd { + if idx < 0 || idx >= len(fields) { + return nil + } + var cmd tea.Cmd + fields[idx].input, cmd = fields[idx].input.Update(msg) + return cmd +} diff --git a/pkg/tui/form_helpers_test.go b/pkg/tui/form_helpers_test.go new file mode 100644 index 0000000000..5fff5db163 --- /dev/null +++ b/pkg/tui/form_helpers_test.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/stretchr/testify/assert" +) + +func makeFormFields() []formField { + const n = 3 + fields := make([]formField, n) + for i := range fields { + fields[i] = formField{input: textinput.New(), name: "field"} + } + return fields +} + +func TestFormNextField(t *testing.T) { + t.Parallel() + + t.Run("empty slice is safe", func(t *testing.T) { + t.Parallel() + idx := 0 + formNextField(nil, &idx) // should not panic + assert.Equal(t, 0, idx) + }) + + t.Run("advances from -1 to 0", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := -1 + formNextField(fields, &idx) + assert.Equal(t, 0, idx) + }) + + t.Run("wraps around from last to first", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 2 + // Focus field 2 so Blur can be called + fields[2].input.Focus() + formNextField(fields, &idx) + assert.Equal(t, 0, idx) + }) + + t.Run("advances sequentially", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 0 + fields[0].input.Focus() + formNextField(fields, &idx) + assert.Equal(t, 1, idx) + }) +} + +func TestFormPrevField(t *testing.T) { + t.Parallel() + + t.Run("empty slice is safe", func(t *testing.T) { + t.Parallel() + idx := 0 + formPrevField(nil, &idx) // should not panic + assert.Equal(t, 0, idx) + }) + + t.Run("wraps from 0 to last", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 0 + fields[0].input.Focus() + formPrevField(fields, &idx) + assert.Equal(t, 2, idx) + }) + + t.Run("wraps from -1 to last", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := -1 + formPrevField(fields, &idx) + assert.Equal(t, 2, idx) + }) + + t.Run("moves backwards sequentially", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 2 + fields[2].input.Focus() + formPrevField(fields, &idx) + assert.Equal(t, 1, idx) + }) +} + +func TestFormBlurAll(t *testing.T) { + t.Parallel() + + t.Run("resets idx to -1", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 1 + fields[1].input.Focus() + formBlurAll(fields, &idx) + assert.Equal(t, -1, idx) + }) + + t.Run("empty fields safe", func(t *testing.T) { + t.Parallel() + idx := 5 + formBlurAll(nil, &idx) + assert.Equal(t, -1, idx) + }) +} diff --git a/pkg/tui/helpers_test.go b/pkg/tui/helpers_test.go new file mode 100644 index 0000000000..0c404360de --- /dev/null +++ b/pkg/tui/helpers_test.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" +) + +func TestWrapText(t *testing.T) { + t.Parallel() + tests := []struct { + name string + text string + maxW int + indent string + expected []string + }{ + { + name: "empty string", + text: "", + maxW: 40, + indent: "", + expected: nil, + }, + { + name: "single word shorter than maxW", + text: "hello", + maxW: 40, + indent: " ", + expected: []string{" hello"}, + }, + { + name: "wraps at word boundary", + text: "hello world foo bar", + maxW: 12, + indent: "", + expected: []string{"hello world", "foo bar"}, + }, + { + name: "word longer than maxW stays on its own line", + text: "superlongword short", + maxW: 5, + indent: "", + expected: []string{"superlongword", "short"}, + }, + { + name: "unicode characters counted as runes", + text: "\u4f60\u597d \u4e16\u754c \u6d4b\u8bd5 \u6587\u672c", + maxW: 7, + indent: "", + expected: []string{"\u4f60\u597d \u4e16\u754c", "\u6d4b\u8bd5 \u6587\u672c"}, + }, + { + name: "indent prefix included in width calculation", + text: "aaa bbb ccc", + maxW: 8, + indent: ">>> ", + expected: []string{">>> aaa", ">>> bbb", ">>> ccc"}, + }, + { + name: "whitespace-only input", + text: " ", + maxW: 40, + indent: "", + expected: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, wrapText(tc.text, tc.maxW, tc.indent)) + }) + } +} + +func TestRunesTruncate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + n int + expected string + }{ + { + name: "no truncation needed", + input: "hello", + n: 10, + expected: "hello", + }, + { + name: "exact length", + input: "hello", + n: 5, + expected: "hello", + }, + { + name: "truncated with ellipsis", + input: "hello world", + n: 5, + expected: "hell\u2026", + }, + { + name: "unicode input truncated", + input: "\u4f60\u597d\u4e16\u754c\u6d4b\u8bd5", + n: 3, + expected: "\u4f60\u597d\u2026", + }, + { + name: "n equals 1 gives just ellipsis", + input: "hello", + n: 1, + expected: "\u2026", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, runesTruncate(tc.input, tc.n)) + }) + } +} + +func TestTruncateSidebar(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + n int + expected string + }{ + { + name: "n <= 0 returns original", + input: "hello", + n: 0, + expected: "hello", + }, + { + name: "negative n returns original", + input: "hello", + n: -5, + expected: "hello", + }, + { + name: "exact length no truncation", + input: "hello", + n: 5, + expected: "hello", + }, + { + name: "truncated with ellipsis", + input: "hello world", + n: 5, + expected: "hell\u2026", + }, + { + name: "short string not truncated", + input: "hi", + n: 10, + expected: "hi", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, truncateSidebar(tc.input, tc.n)) + }) + } +} + +func TestCountStatuses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + workloads []core.Workload + expectedRunning int + expectedStopped int + }{ + { + name: "empty list", + workloads: nil, + expectedRunning: 0, + expectedStopped: 0, + }, + { + name: "all running variants counted as running", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusUnauthenticated}, + {Status: rt.WorkloadStatusUnhealthy}, + }, + expectedRunning: 3, + expectedStopped: 0, + }, + { + name: "all stopped variants counted as stopped", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusStopped}, + {Status: rt.WorkloadStatusError}, + {Status: rt.WorkloadStatusStarting}, + {Status: rt.WorkloadStatusStopping}, + {Status: rt.WorkloadStatusRemoving}, + {Status: rt.WorkloadStatusUnknown}, + }, + expectedRunning: 0, + expectedStopped: 6, + }, + { + name: "mixed statuses", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusStopped}, + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusError}, + }, + expectedRunning: 2, + expectedStopped: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + running, stopped := countStatuses(tc.workloads) + assert.Equal(t, tc.expectedRunning, running) + assert.Equal(t, tc.expectedStopped, stopped) + }) + } +} diff --git a/pkg/tui/init.go b/pkg/tui/init.go new file mode 100644 index 0000000000..d5492ffd1b --- /dev/null +++ b/pkg/tui/init.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/viewport" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// New creates a new TUI model. +// logCh (optional) receives slog WARN/ERROR messages captured while the TUI runs. +func New(ctx context.Context, manager workloads.Manager, logCh <-chan string) (Model, error) { + // Fetch initial workload list + list, err := manager.ListWorkloads(ctx, true) + if err != nil { + return Model{}, fmt.Errorf("failed to list workloads: %w", err) + } + core.SortWorkloadsByName(list) + + vp := viewport.New(80, 20) + vp.SetContent("") + + pvp := viewport.New(80, 20) + pvp.SetContent("") + + tvp := viewport.New(80, 20) + tvp.SetContent("") + + ivp := viewport.New(60, 20) + ivp.SetContent("") + + lvp := viewport.New(60, 6) + lvp.SetContent("") + + m := Model{ + ctx: ctx, + manager: manager, + workloads: list, + panel: panelLogs, + logView: vp, + logFollow: true, + proxyLogView: pvp, + toolsView: tvp, + insp: inspectorState{ + respView: ivp, + logView: lvp, + fieldIdx: -1, + }, + tuiLogCh: logCh, + } + + return m, nil +} diff --git a/pkg/tui/inspector.go b/pkg/tui/inspector.go new file mode 100644 index 0000000000..bb431866f5 --- /dev/null +++ b/pkg/tui/inspector.go @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive/pkg/core" +) + +// inspSpinFrames holds the spinner animation frames for the inspector loading state. +var inspSpinFrames = []string{"⠋", "⠙", "⠸", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// inspCallResultMsg is sent when a tool call completes in the inspector. +type inspCallResultMsg struct { + result *mcp.CallToolResult + elapsedMs int64 + err error +} + +// inspSpinTickMsg is sent on each spinner tick while loading. +type inspSpinTickMsg struct{} + +// buildInspFields parses a tool's InputSchema and returns form fields. +// It extracts properties from the JSON Schema and creates textinput models. +func buildInspFields(tool mcp.Tool) []formField { + props := tool.InputSchema.Properties + if props == nil { + return nil + } + + reqSet := buildRequiredSetFromSlice(tool.InputSchema.Required) + + // Iterate in a stable order by collecting keys first. + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + slices.Sort(keys) + + var fields []formField + for _, name := range keys { + def, ok := props[name].(map[string]any) + if !ok { + continue + } + + fieldType, _ := def["type"].(string) + if fieldType == "" { + fieldType = "string" + } + desc, _ := def["description"].(string) + + ti := textinput.New() + ti.Placeholder = fieldType + ti.Width = 40 + + fields = append(fields, formField{ + input: ti, + name: name, + required: reqSet[name], + desc: desc, + typeName: fieldType, + }) + } + + return fields +} + +// buildRequiredSetFromSlice returns a set of required field names from a string slice. +func buildRequiredSetFromSlice(required []string) map[string]bool { + reqSet := map[string]bool{} + for _, s := range required { + reqSet[s] = true + } + return reqSet +} + +// shellEscapeSingleQuote escapes single quotes for safe inclusion inside +// a single-quoted shell string: ' → '"'"' (end quote, escaped quote, reopen). +func shellEscapeSingleQuote(s string) string { + return strings.ReplaceAll(s, "'", `'"'"'`) +} + +// buildCurlStr constructs a curl command string for the given tool call. +func buildCurlStr(sel *core.Workload, toolName string, args map[string]any) string { + if sel == nil { + return "" + } + + url := sel.URL + if url == "" { + url = fmt.Sprintf("http://127.0.0.1:%d/sse", sel.Port) + } + + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": toolName, + "arguments": args, + }, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + payloadJSON = []byte("{}") + } + + return fmt.Sprintf("curl -X POST \\\n '%s' \\\n -H 'Content-Type: application/json' \\\n -d '%s'", + shellEscapeSingleQuote(url), shellEscapeSingleQuote(string(payloadJSON))) +} + +// startInspCallTool returns a tea.Cmd that calls a tool asynchronously. +func startInspCallTool(ctx context.Context, c *mcpclient.Client, toolName string, args map[string]any) tea.Cmd { + return func() tea.Msg { + start := time.Now() + result, err := callTool(ctx, c, toolName, args) + elapsed := time.Since(start).Milliseconds() + return inspCallResultMsg{result: result, elapsedMs: elapsed, err: err} + } +} + +// callTool invokes a tool on the backend MCP server via an already-connected client. +func callTool(ctx context.Context, c *mcpclient.Client, toolName string, args map[string]any) (*mcp.CallToolResult, error) { + req := mcp.CallToolRequest{} + req.Params.Name = toolName + req.Params.Arguments = args + return c.CallTool(ctx, req) +} + +// inspFieldValues builds a map[string]any from current form field input values, +// coercing each value to the type declared in the tool's JSON Schema. +// Required fields that are empty produce an error. Empty optional fields are skipped. +// On error, errIdx is the index of the offending field (or -1 if unknown). +func inspFieldValues(fields []formField) (map[string]any, int, error) { + result := make(map[string]any) + for i, f := range fields { + v := strings.TrimSpace(f.input.Value()) + if v == "" { + if f.required { + return nil, i, fmt.Errorf("field %q is required", f.name) + } + continue + } + parsed, err := parseFieldValue(v, f.typeName) + if err != nil { + return nil, i, fmt.Errorf("field %q: %w", f.name, err) + } + result[f.name] = parsed + } + return result, -1, nil +} + +// parseFieldValue converts a string input to the Go type matching the given +// JSON Schema type name. Unknown types default to string. +func parseFieldValue(v, typeName string) (any, error) { + switch typeName { + case "integer": + return strconv.ParseInt(v, 10, 64) + case "number": + return strconv.ParseFloat(v, 64) + case "boolean": + return strconv.ParseBool(v) + case "array", "object": + var parsed any + if err := json.Unmarshal([]byte(v), &parsed); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return parsed, nil + default: + return v, nil + } +} + +// formatInspResult formats a CallToolResult as a pretty-printed JSON string. +func formatInspResult(result *mcp.CallToolResult) string { + if result == nil { + return "" + } + parts := make([]string, 0, len(result.Content)) + for _, c := range result.Content { + switch tc := c.(type) { + case mcp.TextContent: + parts = append(parts, tc.Text) + default: + b, _ := json.MarshalIndent(c, "", " ") + parts = append(parts, string(b)) + } + } + if len(parts) == 0 { + b, _ := json.MarshalIndent(result, "", " ") + return string(b) + } + return strings.Join(parts, "\n") +} diff --git a/pkg/tui/inspector_test.go b/pkg/tui/inspector_test.go new file mode 100644 index 0000000000..2bff458729 --- /dev/null +++ b/pkg/tui/inspector_test.go @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/core" +) + +func TestBuildRequiredSetFromSlice(t *testing.T) { + t.Parallel() + tests := []struct { + name string + required []string + expected map[string]bool + }{ + { + name: "nil slice", + required: nil, + expected: map[string]bool{}, + }, + { + name: "empty slice", + required: []string{}, + expected: map[string]bool{}, + }, + { + name: "valid required strings", + required: []string{"name", "url"}, + expected: map[string]bool{"name": true, "url": true}, + }, + { + name: "single entry", + required: []string{"id"}, + expected: map[string]bool{"id": true}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, buildRequiredSetFromSlice(tc.required)) + }) + } +} + +func TestInspFieldValues(t *testing.T) { + t.Parallel() + + makeField := func(name, value, typeName string, required bool) formField { + ti := textinput.New() + ti.SetValue(value) + return formField{input: ti, name: name, typeName: typeName, required: required} + } + + tests := []struct { + name string + fields []formField + expected map[string]any + expectErr string + expectErrIdx int + }{ + { + name: "empty fields", + fields: nil, + expected: map[string]any{}, + }, + { + name: "empty optional values skipped", + fields: []formField{ + makeField("a", "", "string", false), + makeField("b", " ", "string", false), + }, + expected: map[string]any{}, + }, + { + name: "whitespace trimmed", + fields: []formField{ + makeField("url", " https://example.com ", "string", false), + }, + expected: map[string]any{"url": "https://example.com"}, + }, + { + name: "mixed types coerced and empty skipped", + fields: []formField{ + makeField("name", "test", "string", false), + makeField("empty", "", "string", false), + makeField("count", "42", "integer", false), + }, + expected: map[string]any{"name": "test", "count": int64(42)}, + }, + { + name: "required empty field errors with field index", + fields: []formField{ + makeField("name", "ok", "string", false), + makeField("title", "", "string", true), + }, + expectErr: `field "title" is required`, + expectErrIdx: 1, + }, + { + name: "parse error bubbles with field index", + fields: []formField{ + makeField("count", "abc", "integer", false), + }, + expectErr: `field "count"`, + expectErrIdx: 0, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, errIdx, err := inspFieldValues(tc.fields) + if tc.expectErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectErr) + assert.Equal(t, tc.expectErrIdx, errIdx) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestParseFieldValue(t *testing.T) { + t.Parallel() + tests := []struct { + name string + value string + typeName string + expected any + wantErr bool + }{ + {name: "string passthrough", value: "hello", typeName: "string", expected: "hello"}, + {name: "unknown type defaults to string", value: "hello", typeName: "custom", expected: "hello"}, + {name: "valid integer", value: "42", typeName: "integer", expected: int64(42)}, + {name: "invalid integer", value: "3.5", typeName: "integer", wantErr: true}, + {name: "valid number", value: "3.14", typeName: "number", expected: 3.14}, + {name: "invalid number", value: "abc", typeName: "number", wantErr: true}, + {name: "valid boolean", value: "true", typeName: "boolean", expected: true}, + {name: "invalid boolean", value: "maybe", typeName: "boolean", wantErr: true}, + {name: "valid array", value: `[1,2,3]`, typeName: "array", expected: []any{float64(1), float64(2), float64(3)}}, + {name: "invalid array", value: "not json", typeName: "array", wantErr: true}, + {name: "valid object", value: `{"a":1}`, typeName: "object", expected: map[string]any{"a": float64(1)}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseFieldValue(tc.value, tc.typeName) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestShellEscapeSingleQuote(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {"no quotes", "hello", "hello"}, + {"single quote", "it's", `it'"'"'s`}, + {"multiple quotes", "a'b'c", `a'"'"'b'"'"'c`}, + {"empty string", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, shellEscapeSingleQuote(tc.input)) + }) + } +} + +func TestBuildCurlStr(t *testing.T) { + t.Parallel() + tests := []struct { + name string + workload *core.Workload + toolName string + args map[string]any + check func(t *testing.T, result string) + }{ + { + name: "nil workload returns empty", + workload: nil, + check: func(t *testing.T, result string) { + t.Helper() + assert.Empty(t, result) + }, + }, + { + name: "single quote in arg value is escaped", + workload: &core.Workload{Name: "test", URL: "http://localhost:8080/sse", Port: 8080}, + toolName: "echo", + args: map[string]any{"msg": "it's dangerous"}, + check: func(t *testing.T, result string) { + t.Helper() + assert.NotContains(t, result, "'it's", "unescaped single quote in payload") + assert.Contains(t, result, "curl -X POST") + }, + }, + { + name: "single quote in URL is escaped", + workload: &core.Workload{Name: "test", URL: "http://localhost:8080/path'inject", Port: 8080}, + toolName: "echo", + args: map[string]any{}, + check: func(t *testing.T, result string) { + t.Helper() + assert.NotContains(t, result, "'http://localhost:8080/path'inject'", + "unescaped single quote in URL") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := buildCurlStr(tc.workload, tc.toolName, tc.args) + tc.check(t, result) + }) + } +} + +func TestFormatInspResult(t *testing.T) { + t.Parallel() + tests := []struct { + name string + result *mcp.CallToolResult + expected string + containsSubstr string // when set, output must contain this substring + }{ + { + name: "nil result", + result: nil, + expected: "", + }, + { + name: "single text content", + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "hello world"}, + }, + }, + expected: "hello world", + }, + { + name: "multiple text contents joined", + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "line1"}, + mcp.TextContent{Type: "text", Text: "line2"}, + }, + }, + expected: "line1\nline2", + }, + { + name: "non-text content serialized as JSON", + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.ImageContent{Type: "image", Data: "base64data", MIMEType: "image/png"}, + }, + }, + containsSubstr: "base64data", + }, + { + name: "empty content falls back to full result JSON", + result: &mcp.CallToolResult{ + Content: []mcp.Content{}, + IsError: true, + }, + containsSubstr: "true", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := formatInspResult(tc.result) + if tc.expected != "" { + assert.Equal(t, tc.expected, got) + } else if tc.result != nil { + require.NotEmpty(t, got) + if tc.containsSubstr != "" { + assert.Contains(t, got, tc.containsSubstr) + } + } + }) + } +} diff --git a/pkg/tui/json_tree.go b/pkg/tui/json_tree.go new file mode 100644 index 0000000000..549d261af0 --- /dev/null +++ b/pkg/tui/json_tree.go @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// jsonNodeKind identifies the JSON value type of a tree node. +type jsonNodeKind int8 + +const ( + kindObject jsonNodeKind = iota + kindArray + kindString + kindNumber + kindBool + kindNull +) + +// jsonNode is a node in a parsed JSON tree. +type jsonNode struct { + kind jsonNodeKind + key string // non-empty when this is an object field + value string // rendered value for primitive types + children []*jsonNode + collapsed bool + isLast bool // last child in parent — no trailing comma +} + +// visItem is an entry in the flattened visible-node list produced by flattenVisible. +type visItem struct { + node *jsonNode + depth int + closingBracket bool // true → render the closing } or ] for this node's parent +} + +// parseJSONTree parses a JSON string into a jsonNode tree. +// Returns nil if the input is not valid JSON or not an object/array at the root. +func parseJSONTree(s string) *jsonNode { + trimmed := strings.TrimSpace(s) + if len(trimmed) == 0 { + return nil + } + // Only attempt tree rendering for objects and arrays. + if trimmed[0] != '{' && trimmed[0] != '[' { + return nil + } + var raw any + if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { + return nil + } + return buildJSONNode(raw, "", true) +} + +// buildJSONNode recursively converts an unmarshalled value into a jsonNode tree. +func buildJSONNode(v any, key string, isLast bool) *jsonNode { + node := &jsonNode{key: key, isLast: isLast} + switch val := v.(type) { + case map[string]any: + node.kind = kindObject + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + slices.Sort(keys) + for i, k := range keys { + child := buildJSONNode(val[k], k, i == len(keys)-1) + node.children = append(node.children, child) + } + case []any: + node.kind = kindArray + for i, item := range val { + child := buildJSONNode(item, "", i == len(val)-1) + node.children = append(node.children, child) + } + case string: + node.kind = kindString + node.value = fmt.Sprintf("%q", val) + case float64: + node.kind = kindNumber + if val == float64(int64(val)) { + node.value = fmt.Sprintf("%d", int64(val)) + } else { + node.value = fmt.Sprintf("%g", val) + } + case bool: + node.kind = kindBool + if val { + node.value = "true" + } else { + node.value = "false" + } + case nil: + node.kind = kindNull + node.value = "null" + } + return node +} + +// flattenVisible returns a flat DFS-ordered list of all currently visible nodes. +// Closing-bracket entries are appended after each expanded object/array's children. +func flattenVisible(root *jsonNode) []visItem { + var out []visItem + appendVisible(root, 0, &out) + return out +} + +func appendVisible(node *jsonNode, depth int, out *[]visItem) { + *out = append(*out, visItem{node: node, depth: depth}) + if node.collapsed || len(node.children) == 0 { + return + } + for _, child := range node.children { + appendVisible(child, depth+1, out) + } + // Append closing bracket line at the same depth as the opening line. + *out = append(*out, visItem{node: node, depth: depth, closingBracket: true}) +} + +// toggleCollapse toggles the collapsed state of the node at the given cursor position. +// Both the opening line and the closing-bracket line of an object/array toggle it. +func toggleCollapse(vis []visItem, cursor int) { + if cursor < 0 || cursor >= len(vis) { + return + } + node := vis[cursor].node + if node.kind == kindObject || node.kind == kindArray { + node.collapsed = !node.collapsed + } +} + +// renderJSONTree renders a windowed view of the visible list, highlighting the cursor item. +// width is the available column width; visH is the number of lines to render. +func renderJSONTree(vis []visItem, cursor, scrollOff, width, visH int) string { + if len(vis) == 0 { + return "" + } + cursorBg := lipgloss.Color("#2a2e45") + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + end := scrollOff + visH + if end > len(vis) { + end = len(vis) + } + for i := scrollOff; i < end; i++ { + line := renderJSONItem(vis[i]) + if i == cursor { + line = lipgloss.NewStyle(). + Background(cursorBg). + Width(width - 2). + Render(line) + } + sb.WriteString(line + "\n") + } + // Scroll position indicator when content overflows. + if len(vis) > visH { + pct := 0 + if len(vis) > 1 { + pct = (cursor * 100) / (len(vis) - 1) + } + sb.WriteString(dimStyle.Render(fmt.Sprintf(" ─ %d/%d %d%% ─", cursor+1, len(vis), pct)) + "\n") + } + return sb.String() +} + +// nodeToAny reconstructs a Go value from a jsonNode tree (for re-serialization). +func nodeToAny(node *jsonNode) any { + switch node.kind { + case kindObject: + m := make(map[string]any, len(node.children)) + for _, child := range node.children { + m[child.key] = nodeToAny(child) + } + return m + case kindArray: + s := make([]any, len(node.children)) + for i, child := range node.children { + s[i] = nodeToAny(child) + } + return s + case kindString: + var s string + _ = json.Unmarshal([]byte(node.value), &s) + return s + case kindNumber: + var n float64 + _ = json.Unmarshal([]byte(node.value), &n) + return n + case kindBool: + return node.value == "true" + case kindNull: + return nil + } + return nil +} + +// nodeToJSON serializes the selected node back to indented JSON. +func nodeToJSON(node *jsonNode) string { + b, err := json.MarshalIndent(nodeToAny(node), "", " ") + if err != nil { + return "" + } + return string(b) +} + +// renderJSONItem converts a single visItem to a syntax-colored terminal line. +// +//nolint:gocyclo // switch on kind + collapsed/empty sub-cases; splitting would obscure the rendering logic +func renderJSONItem(item visItem) string { + node := item.node + indent := strings.Repeat(" ", item.depth) + + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + dim2Style := lipgloss.NewStyle().Foreground(ui.ColorDim2) + keyStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + strStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + numStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow) + boolStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple) + + comma := "" + if !node.isLast { + comma = textStyle.Render(",") + } + + // Closing bracket line (}, ]). + if item.closingBracket { + bracket := func() string { + if node.kind == kindObject { + return "}" + } + return "]" + }() + return indent + textStyle.Render(bracket) + comma + } + + // Key prefix for object fields. + keyPart := "" + if node.key != "" { + keyPart = keyStyle.Render(fmt.Sprintf("%q", node.key)) + textStyle.Render(": ") + } + + // Collapse/expand toggle indicator for objects and arrays. + toggle := "" + if node.kind == kindObject || node.kind == kindArray { + if node.collapsed { + toggle = dimStyle.Render("▶ ") + } else { + toggle = dimStyle.Render("▼ ") + } + } + + switch node.kind { + case kindObject: + if node.collapsed { + return indent + toggle + keyPart + dim2Style.Render(fmt.Sprintf("{…%d}", len(node.children))) + comma + } + if len(node.children) == 0 { + return indent + toggle + keyPart + textStyle.Render("{}") + comma + } + return indent + toggle + keyPart + textStyle.Render("{") + case kindArray: + if node.collapsed { + return indent + toggle + keyPart + dim2Style.Render(fmt.Sprintf("[…%d]", len(node.children))) + comma + } + if len(node.children) == 0 { + return indent + toggle + keyPart + textStyle.Render("[]") + comma + } + return indent + toggle + keyPart + textStyle.Render("[") + case kindString: + return indent + keyPart + strStyle.Render(node.value) + comma + case kindNumber: + return indent + keyPart + numStyle.Render(node.value) + comma + case kindBool: + return indent + keyPart + boolStyle.Render(node.value) + comma + case kindNull: + return indent + keyPart + dimStyle.Render(node.value) + comma + } + return indent + keyPart + node.value + comma +} diff --git a/pkg/tui/json_tree_test.go b/pkg/tui/json_tree_test.go new file mode 100644 index 0000000000..1a3edca9c2 --- /dev/null +++ b/pkg/tui/json_tree_test.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseJSONTree(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expectNil bool + expectKind jsonNodeKind + }{ + { + name: "empty string", + input: "", + expectNil: true, + }, + { + name: "scalar string", + input: `"hello"`, + expectNil: true, + }, + { + name: "scalar number", + input: `42`, + expectNil: true, + }, + { + name: "invalid JSON", + input: `{broken`, + expectNil: true, + }, + { + name: "valid object", + input: `{"key": "value"}`, + expectKind: kindObject, + }, + { + name: "valid array", + input: `[1, 2, 3]`, + expectKind: kindArray, + }, + { + name: "empty object", + input: `{}`, + expectKind: kindObject, + }, + { + name: "whitespace around valid object", + input: ` {"a": 1} `, + expectKind: kindObject, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := parseJSONTree(tc.input) + if tc.expectNil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, tc.expectKind, result.kind) + } + }) + } +} + +func TestFlattenVisible(t *testing.T) { + t.Parallel() + + t.Run("flat object", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1, "b": "two"}`) + require.NotNil(t, root) + + vis := flattenVisible(root) + // root opening + 2 children + root closing = 4 + assert.Len(t, vis, 4) + // First item is the root object opening + assert.Equal(t, kindObject, vis[0].node.kind) + assert.False(t, vis[0].closingBracket) + // Last item is the closing bracket + assert.True(t, vis[len(vis)-1].closingBracket) + }) + + t.Run("nested object", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"outer": {"inner": 1}}`) + require.NotNil(t, root) + + vis := flattenVisible(root) + // root{ + outer{ + inner + outer} + root} = 5 + assert.Len(t, vis, 5) + // Check depths + assert.Equal(t, 0, vis[0].depth) // root opening + assert.Equal(t, 1, vis[1].depth) // outer opening + assert.Equal(t, 2, vis[2].depth) // inner value + assert.Equal(t, 1, vis[3].depth) // outer closing + assert.Equal(t, 0, vis[4].depth) // root closing + }) + + t.Run("collapsed nodes skip children", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": {"b": 1}, "c": 2}`) + require.NotNil(t, root) + + // Collapse the "a" child (first child of root) + root.children[0].collapsed = true + + vis := flattenVisible(root) + // root{ + collapsed-a + c + root} = 4 (no "b", no closing for "a") + assert.Len(t, vis, 4) + }) +} + +func TestToggleCollapse(t *testing.T) { + t.Parallel() + + t.Run("toggle on object works", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + assert.False(t, root.collapsed) + toggleCollapse(vis, 0) // toggle root object + assert.True(t, root.collapsed) + toggleCollapse(vis, 0) // toggle back + assert.False(t, root.collapsed) + }) + + t.Run("toggle on scalar is noop", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + // vis[1] is the "a": 1 scalar child + child := vis[1].node + assert.Equal(t, kindNumber, child.kind) + toggleCollapse(vis, 1) // should be noop + assert.False(t, child.collapsed) + }) + + t.Run("out of bounds is safe", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + // These should not panic + toggleCollapse(vis, -1) + toggleCollapse(vis, len(vis)) + toggleCollapse(nil, 0) + }) +} + +func TestNodeToJSON(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + }{ + { + name: "simple object roundtrip", + input: `{"name":"test","value":42}`, + }, + { + name: "array roundtrip", + input: `[1,2,3]`, + }, + { + name: "nested structure roundtrip", + input: `{"items":[{"id":1},{"id":2}],"total":2}`, + }, + { + name: "booleans and null", + input: `{"active":true,"deleted":false,"meta":null}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + root := parseJSONTree(tc.input) + require.NotNil(t, root) + + output := nodeToJSON(root) + + // Parse both to compare structurally (formatting may differ) + var expected, actual any + require.NoError(t, json.Unmarshal([]byte(tc.input), &expected)) + require.NoError(t, json.Unmarshal([]byte(output), &actual)) + assert.Equal(t, expected, actual) + }) + } +} diff --git a/pkg/tui/keys.go b/pkg/tui/keys.go new file mode 100644 index 0000000000..e3c566fc78 --- /dev/null +++ b/pkg/tui/keys.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import "github.com/charmbracelet/bubbles/key" + +// keyMap holds all key bindings for the TUI. +type keyMap struct { + Up key.Binding + Down key.Binding + Tab key.Binding + ShiftTab key.Binding + Stop key.Binding + Restart key.Binding + Delete key.Binding + Filter key.Binding + Help key.Binding + Quit key.Binding + Enter key.Binding + Escape key.Binding + Follow key.Binding + Registry key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Space key.Binding // toggle JSON node collapse + CopyNode key.Binding // copy response JSON to clipboard (c) + CopyCurl key.Binding // copy curl command to clipboard (y) + SearchNext key.Binding // n — next search match in logs + SearchPrev key.Binding // N — previous search match in logs + CopyURL key.Binding // u — copy workload URL to clipboard + ToolInfo key.Binding // i — show tool description modal +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "navigate up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "navigate down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch panel"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "previous field"), + ), + Stop: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "stop"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + Follow: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "follow logs"), + ), + Registry: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "registry"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "scroll right"), + ), + Space: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle collapse"), + ), + CopyNode: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy node JSON"), + ), + CopyCurl: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "copy curl"), + ), + SearchNext: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "next match"), + ), + SearchPrev: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("N", "prev match"), + ), + CopyURL: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "copy URL"), + ), + ToolInfo: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "tool info"), + ), +} diff --git a/pkg/tui/logformat.go b/pkg/tui/logformat.go new file mode 100644 index 0000000000..65e11fef31 --- /dev/null +++ b/pkg/tui/logformat.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + xansi "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// formatLogLine parses a structured JSON log line (slog format) and returns a +// human-readable, colorized string. Non-JSON lines are returned unchanged. +func formatLogLine(raw string) string { + raw = strings.TrimRight(raw, "\r\n") + if len(raw) == 0 || raw[0] != '{' { + return raw + } + + var entry map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + return raw + } + + ts := extractStr(entry, "time") + if len(ts) >= 19 { + ts = ts[11:19] // HH:MM:SS + } + level := strings.ToUpper(extractStr(entry, "level")) + msg := extractStr(entry, "msg") + + // Collect remaining fields sorted for stable output. + skip := map[string]bool{"time": true, "level": true, "msg": true} + var extras []string + for k, v := range entry { + if skip[k] { + continue + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + extras = append(extras, fmt.Sprintf("%s=%s", k, s)) + } else { + extras = append(extras, fmt.Sprintf("%s=%s", k, string(v))) + } + } + sort.Strings(extras) + + dim := lipgloss.NewStyle().Foreground(ui.ColorDim) + + // Message and extras color depend on log level. + msgColor := ui.ColorText + extrasColor := ui.ColorDim2 + switch level { + case "ERROR": + msgColor = ui.ColorRed + extrasColor = ui.ColorRed + case "WARN": + msgColor = ui.ColorYellow + extrasColor = ui.ColorYellow + } + + var sb strings.Builder + sb.WriteString(dim.Render(ts)) + sb.WriteString(" ") + sb.WriteString(levelStyle(level)) + sb.WriteString(" ") + sb.WriteString(lipgloss.NewStyle().Foreground(msgColor).Render(msg)) + if len(extras) > 0 { + sb.WriteString(" ") + sb.WriteString(lipgloss.NewStyle().Foreground(extrasColor).Render(strings.Join(extras, " "))) + } + return sb.String() +} + +func extractStr(m map[string]json.RawMessage, key string) string { + v, ok := m[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(v, &s); err != nil { + return string(v) + } + return s +} + +func levelStyle(level string) string { + label := fmt.Sprintf("%-5s", level) + switch level { + case "ERROR": + return lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render(label) + case "WARN": + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render(label) + case "INFO": + return lipgloss.NewStyle().Foreground(ui.ColorBlue).Render(label) + default: + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(label) + } +} + +// buildHScrollContent builds viewport content applying horizontal scroll. +// Each line is ANSI-cut to [hOff, hOff+viewW] so no wrapping occurs. +func buildHScrollContent(lines []string, viewW, hOff int) string { + if len(lines) == 0 { + return "" + } + if viewW <= 0 || (hOff == 0 && viewW >= 512) { + return strings.Join(lines, "\n") + } + var sb strings.Builder + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(xansi.Cut(line, hOff, hOff+viewW)) + } + return sb.String() +} diff --git a/pkg/tui/logformat_test.go b/pkg/tui/logformat_test.go new file mode 100644 index 0000000000..1612588463 --- /dev/null +++ b/pkg/tui/logformat_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatLogLine(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + expectContains []string + }{ + { + name: "non-JSON passthrough", + raw: "plain log line", + expectContains: []string{"plain log line"}, + }, + { + name: "empty string", + raw: "", + expectContains: []string{""}, + }, + { + name: "invalid JSON passthrough", + raw: "{not valid json", + expectContains: []string{"{not valid json"}, + }, + { + name: "valid slog JSON extracts message", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"INFO","msg":"server started"}`, + expectContains: []string{"10:30:45", "INFO", "server started"}, + }, + { + name: "extra fields included in output", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"ERROR","msg":"failed","component":"proxy"}`, + expectContains: []string{"ERROR", "failed", "component=proxy"}, + }, + { + name: "short timestamp handled gracefully", + raw: `{"time":"short","level":"WARN","msg":"test"}`, + expectContains: []string{"WARN", "test"}, + }, + { + name: "trailing CR stripped", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"DEBUG","msg":"ok"}` + "\r", + expectContains: []string{"ok"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := formatLogLine(tc.raw) + for _, substr := range tc.expectContains { + assert.Contains(t, result, substr) + } + }) + } +} + +func TestLevelStyle(t *testing.T) { + t.Parallel() + levels := []string{"ERROR", "WARN", "INFO", "DEBUG", "TRACE", ""} + for _, level := range levels { + t.Run("level_"+level, func(t *testing.T) { + t.Parallel() + result := levelStyle(level) + assert.NotEmpty(t, result, "levelStyle should return non-empty for level %q", level) + if level != "" { + assert.Contains(t, result, "\x1b[", "non-empty level %q should produce ANSI styled output", level) + } + }) + } + + // ERROR and INFO must produce different styled output. + errorResult := levelStyle("ERROR") + infoResult := levelStyle("INFO") + assert.NotEqual(t, errorResult, infoResult, "ERROR and INFO should produce different styled output") +} diff --git a/pkg/tui/logs.go b/pkg/tui/logs.go new file mode 100644 index 0000000000..b86c99274e --- /dev/null +++ b/pkg/tui/logs.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/workloads" +) + +const ( + logPollInterval = 500 * time.Millisecond + logFetchLines = 500 +) + +// StreamWorkloadLogs starts a goroutine that polls manager.GetLogs for the +// given workload name and sends new log lines to the returned channel. +// Cancel the context to stop streaming. +func StreamWorkloadLogs(ctx context.Context, manager workloads.Manager, name string) <-chan string { + ch := make(chan string, 256) + go func() { + defer close(ch) + var lastLines []string + var errBackoff time.Duration + const maxBackoff = 5 * time.Second + for { + select { + case <-ctx.Done(): + return + case <-time.After(logPollInterval): + } + + raw, err := manager.GetLogs(ctx, name, false, logFetchLines) + if err != nil { + select { + case ch <- "[error] " + err.Error(): + case <-ctx.Done(): + return + } + if errBackoff == 0 { + errBackoff = time.Second + } else { + errBackoff = min(errBackoff*2, maxBackoff) + } + select { + case <-time.After(errBackoff): + case <-ctx.Done(): + return + } + continue + } + errBackoff = 0 // reset on success + + lines := splitLines(raw) + newLines := diffLines(lastLines, lines) + lastLines = lines + + for _, l := range newLines { + select { + case ch <- l: + case <-ctx.Done(): + return + } + } + } + }() + return ch +} + +// splitLines splits a string into non-empty lines. +func splitLines(s string) []string { + all := strings.Split(s, "\n") + out := make([]string, 0, len(all)) + for _, l := range all { + if l != "" { + out = append(out, l) + } + } + return out +} + +// diffLines returns lines in next that are not in prev (suffix-based). +// This detects new lines appended since the last poll. +// +// To handle duplicate log lines reliably, we match a suffix of prev (up to +// diffMatchLen lines) as a contiguous sequence in next, rather than matching +// only the last line. This makes false positional matches exponentially +// less likely when the same log message repeats. +func diffLines(prev, next []string) []string { + if len(next) == 0 { + return nil + } + if len(prev) == 0 { + return next + } + + // Take the last few lines of prev as the match sequence. + matchLen := diffMatchLen + if matchLen > len(prev) { + matchLen = len(prev) + } + suffix := prev[len(prev)-matchLen:] + + // Scan next from the end looking for the suffix sequence. + for i := len(next) - matchLen; i >= 0; i-- { + if slicesEqual(next[i:i+matchLen], suffix) { + return next[i+matchLen:] + } + } + + // No sequence match — fall back to single-line match on the last prev line. + lastPrev := prev[len(prev)-1] + for i := len(next) - 1; i >= 0; i-- { + if next[i] == lastPrev { + return next[i+1:] + } + } + + // No overlap found — return all lines in next. + return next +} + +// diffMatchLen is the number of trailing lines from the previous poll used +// to anchor the suffix match. Higher values reduce false matches with +// duplicate lines but require more overlap to detect the boundary. +const diffMatchLen = 3 + +// slicesEqual returns true if a and b have the same length and contents. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/tui/logs_test.go b/pkg/tui/logs_test.go new file mode 100644 index 0000000000..f9e7e29e5b --- /dev/null +++ b/pkg/tui/logs_test.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitLines(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "single line no newline", + input: "hello", + expected: []string{"hello"}, + }, + { + name: "trailing newline skipped", + input: "hello\nworld\n", + expected: []string{"hello", "world"}, + }, + { + name: "multiple empty lines filtered", + input: "a\n\n\nb", + expected: []string{"a", "b"}, + }, + { + name: "carriage return not stripped by splitLines", + input: "hello\r\nworld\r\n", + expected: []string{"hello\r", "world\r"}, + }, + { + name: "only newlines", + input: "\n\n\n", + expected: []string{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, splitLines(tc.input)) + }) + } +} + +func TestDiffLines(t *testing.T) { + t.Parallel() + tests := []struct { + name string + prev []string + next []string + expected []string + }{ + { + name: "prev empty returns all next", + prev: nil, + next: []string{"a", "b"}, + expected: []string{"a", "b"}, + }, + { + name: "next empty returns nil", + prev: []string{"a"}, + next: nil, + expected: nil, + }, + { + name: "both empty", + prev: nil, + next: nil, + expected: nil, + }, + { + name: "full overlap no new lines", + prev: []string{"a", "b", "c"}, + next: []string{"a", "b", "c"}, + expected: []string{}, + }, + { + name: "partial overlap returns new tail", + prev: []string{"a", "b"}, + next: []string{"a", "b", "c", "d"}, + expected: []string{"c", "d"}, + }, + { + name: "no overlap returns all next", + prev: []string{"x", "y"}, + next: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "duplicate last line resolved by suffix sequence match", + prev: []string{"a", "b"}, + next: []string{"a", "b", "b", "c"}, + expected: []string{"b", "c"}, + }, + { + name: "single-line prev falls back to last-line match", + prev: []string{"b"}, + next: []string{"b", "x", "b", "y"}, + expected: []string{"y"}, + }, + { + name: "suffix sequence anchors correctly with repeating lines", + prev: []string{"x", "x", "x"}, + next: []string{"x", "x", "x", "x", "x", "new"}, + expected: []string{"new"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, diffLines(tc.prev, tc.next)) + }) + } +} diff --git a/pkg/tui/main_test.go b/pkg/tui/main_test.go new file mode 100644 index 0000000000..a59dcedc7a --- /dev/null +++ b/pkg/tui/main_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "os" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func TestMain(m *testing.M) { + // Force ANSI color output so lipgloss renders escape sequences in tests. + // Without this, lipgloss detects a non-TTY environment and strips all + // styling, making it impossible to verify that styled output is produced. + lipgloss.DefaultRenderer().SetColorProfile(termenv.ANSI256) + os.Exit(m.Run()) +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000000..d1b1f2258b --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package tui provides an interactive terminal dashboard for ToolHive. +package tui + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// activePanel identifies which tab is currently visible in the main area. +type activePanel int + +const ( + panelLogs activePanel = iota + panelInfo + panelTools + panelProxyLogs + panelInspector +) + +// formField bundles a text input with its metadata, replacing parallel slices. +type formField struct { + input textinput.Model + name string + required bool + desc string + typeName string // inspector: type hint like "string", "integer" + secret bool // run form: whether this is a secret field +} + +// inspectorState holds all state for the tool inspector panel. +type inspectorState struct { + toolIdx int + filterActive bool + filterQuery string + fields []formField + fieldIdx int // -1 = no field focused; 0..n-1 = field focused + result string + resultOK bool + resultMs int64 + resultTool string // tool name the current result belongs to + loading bool + showInfo bool // showing tool description modal + spinFrame int + respView viewport.Model + logLines []string + logView viewport.Model + jsonRoot *jsonNode // nil when response is not valid JSON + treeVis []visItem // flattened visible-node list (rebuilt on collapse/expand) + treeCursor int // cursor position in treeVis + treeScroll int // index of first visible item + treeVisH int // available render height (set by resizeViewport) +} + +// runFormState holds state for the "run from registry" form overlay. +type runFormState struct { + open bool + item regtypes.ServerMetadata + fields []formField + idx int + running bool + scroll int +} + +// registryState holds state for the registry browser overlay. +type registryState struct { + open bool + items []regtypes.ServerMetadata + provider registry.Provider // cached provider for SearchServers filtering + filter string + idx int + scrollOff int // first visible item index in list + loading bool + err error + detail bool // showing detail view for selected item + detailScroll int // scroll offset in detail view +} + +// Model is the top-level BubbleTea model for the TUI dashboard. +type Model struct { + ctx context.Context + manager workloads.Manager + + // Dimensions + width int + height int + + // Sidebar state + workloads []core.Workload + selectedIdx int + filterQuery string + filterActive bool + + // Main panel + panel activePanel + logView viewport.Model + logLines []string + logFollow bool + logHScrollOff int + + // Log search state + logSearchActive bool + logSearchQuery string + logSearchMatches []int // indices into logLines that match + logSearchIdx int // current focused match index + + // Log streaming + logCh <-chan string + logCtxCancel context.CancelFunc + streamingFor string // workload name currently being streamed + + // MCP client (persistent per selected workload) + mcpClient *mcpclient.Client + + // Tools panel state + tools []mcp.Tool + toolsLoading bool + toolsFor string // workload name whose tools are loaded + toolsErr error + toolsView viewport.Model + toolsSelectedIdx int // currently highlighted tool in Tools panel + + // Proxy logs panel state + proxyLogView viewport.Model + proxyLogLines []string + proxyLogCh <-chan string + proxyLogCancel context.CancelFunc + proxyLogFor string // workload name currently being streamed for proxy logs + proxyLogHScrollOff int + + // Proxy log search state + proxyLogSearchActive bool + proxyLogSearchQuery string + proxyLogSearchMatches []int + proxyLogSearchIdx int + + // RunConfig (enhanced info panel) + runConfig *runner.RunConfig + runConfigFor string // workload name whose runConfig is loaded + + // Registry overlay state + registry registryState + + // Run-from-registry form state + runForm runFormState + + // Inspector panel state + insp inspectorState + + // TUI-level log capture: slog WARN/ERROR messages sent here while TUI runs. + tuiLogCh <-chan string + + // Transient status bar notification (right-aligned, auto-clears after 3s). + notifMsg string + notifOK bool + + // After a run-from-registry completes, select the new workload by name. + pendingSelect string + + // UI flags + showHelp bool + confirmDelete bool // waiting for second 'd' to confirm deletion + quitting bool +} + +// selected returns the currently selected workload, or nil if none. +func (m *Model) selected() *core.Workload { + list := m.filteredWorkloads() + if len(list) == 0 { + return nil + } + if m.selectedIdx >= len(list) { + return nil + } + w := list[m.selectedIdx] + return &w +} + +// filteredWorkloads returns workloads matching the current filter query. +// Filtering applies whenever the query is non-empty, even after the prompt +// is dismissed with Enter, so the user can navigate the filtered list. +func (m *Model) filteredWorkloads() []core.Workload { + if m.filterQuery == "" { + return m.workloads + } + var out []core.Workload + for _, w := range m.workloads { + if strings.Contains(w.Name, m.filterQuery) { + out = append(out, w) + } + } + return out +} + +// filteredRegistryItems returns registry items matching the current registry filter. +// When the filter starts with "/" it matches only the short name (the part after +// the last "/" in the server name), so "/github" finds "io.github.stacklok/github" +// without matching the "io.github" prefix. Otherwise it delegates to SearchServers +// for full-text matching across name, description, and tags. +func (m *Model) filteredRegistryItems() []regtypes.ServerMetadata { + if m.registry.filter == "" { + return m.registry.items + } + // "/" prefix: match only the short name (after the last "/"). + if strings.HasPrefix(m.registry.filter, "/") { + q := strings.ToLower(strings.TrimPrefix(m.registry.filter, "/")) + var out []regtypes.ServerMetadata + for _, item := range m.registry.items { + shortName := item.GetName() + if idx := strings.LastIndex(shortName, "/"); idx >= 0 { + shortName = shortName[idx+1:] + } + if strings.Contains(strings.ToLower(shortName), q) { + out = append(out, item) + } + } + return out + } + if m.registry.provider != nil { + results, err := m.registry.provider.SearchServers(m.registry.filter) + if err == nil { + return results + } + // On error fall through to unfiltered list so the UI stays responsive. + } + return m.registry.items +} + +// filteredTools returns tools matching the current inspector filter query. +func (m *Model) filteredTools() []mcp.Tool { + if !m.insp.filterActive || m.insp.filterQuery == "" { + return m.tools + } + var out []mcp.Tool + for _, t := range m.tools { + if strings.Contains(t.Name, m.insp.filterQuery) { + out = append(out, t) + } + } + return out +} diff --git a/pkg/tui/proxylogs.go b/pkg/tui/proxylogs.go new file mode 100644 index 0000000000..483050c94b --- /dev/null +++ b/pkg/tui/proxylogs.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/workloads" +) + +// proxyLogLineMsg carries a single new proxy log line from the polling goroutine. +type proxyLogLineMsg string + +// proxyLogStreamDoneMsg is sent when the proxy log stream channel is closed. +type proxyLogStreamDoneMsg struct{} + +// StreamProxyLogs polls manager.GetProxyLogs for the given workload and sends +// new lines to the returned channel. Cancel ctx to stop. +func StreamProxyLogs(ctx context.Context, manager workloads.Manager, name string) <-chan string { + ch := make(chan string, 256) + go func() { + defer close(ch) + var lastLines []string + for { + select { + case <-ctx.Done(): + return + case <-time.After(logPollInterval): + } + + raw, err := manager.GetProxyLogs(ctx, name, logFetchLines) + if err != nil { + continue + } + + lines := splitLines(raw) + newLines := diffLines(lastLines, lines) + lastLines = lines + + for _, l := range newLines { + select { + case ch <- l: + case <-ctx.Done(): + return + } + } + } + }() + return ch +} + +// readProxyLogLine returns a tea.Cmd that waits for the next proxy log line. +func readProxyLogLine(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return proxyLogStreamDoneMsg{} + } + return proxyLogLineMsg(line) + } +} diff --git a/pkg/tui/registry.go b/pkg/tui/registry.go new file mode 100644 index 0000000000..e46af28419 --- /dev/null +++ b/pkg/tui/registry.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry" +) + +// registryLoadedMsg is sent when the registry server list has been fetched. +type registryLoadedMsg struct { + items []regtypes.ServerMetadata + provider registry.Provider + err error +} + +// fetchRegistryItems returns a tea.Cmd that loads all servers from the registry. +// The returned provider is stored so SearchServers can be used for filtering. +func fetchRegistryItems(_ context.Context) tea.Cmd { + return func() tea.Msg { + provider, err := registry.GetDefaultProvider() + if err != nil { + return registryLoadedMsg{err: err} + } + items, err := provider.ListServers() + return registryLoadedMsg{items: items, provider: provider, err: err} + } +} + +// sanitizeRegistryName replaces dots and slashes with dashes for use as a workload name. +func sanitizeRegistryName(name string) string { + r := strings.NewReplacer(".", "-", "/", "-") + return r.Replace(name) +} + +// buildRunFormFields creates form fields from a registry item's metadata. +func buildRunFormFields(item regtypes.ServerMetadata) []formField { + var fields []formField + + // First field: workload name (pre-filled, required). + nameInput := textinput.New() + nameInput.Placeholder = "workload name" + nameInput.SetValue(sanitizeRegistryName(item.GetName())) + nameInput.CharLimit = 64 + fields = append(fields, formField{ + input: nameInput, + name: "name", + required: true, + desc: "Name for the running workload", + }) + + // One field per env var declared by the server. + for _, ev := range item.GetEnvVars() { + if ev == nil { + continue + } + ti := textinput.New() + ti.Placeholder = ev.Name + if ev.Default != "" { + ti.SetValue(ev.Default) + } + if ev.Secret { + ti.EchoMode = textinput.EchoPassword + } + fields = append(fields, formField{ + input: ti, + name: ev.Name, + required: ev.Required, + desc: ev.Description, + secret: ev.Secret, + }) + } + + return fields +} diff --git a/pkg/tui/registry_test.go b/pkg/tui/registry_test.go new file mode 100644 index 0000000000..12a259ba3d --- /dev/null +++ b/pkg/tui/registry_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive-core/permissions" + regtypes "github.com/stacklok/toolhive-core/registry/types" +) + +func TestSanitizeRegistryName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "dots and slashes replaced", + input: "io.github.stacklok/fetch", + expected: "io-github-stacklok-fetch", + }, + { + name: "multiple consecutive dots", + input: "a..b", + expected: "a--b", + }, + { + name: "no special characters", + input: "simple-name", + expected: "simple-name", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "mixed dots slashes and dashes", + input: "io.github/org/tool.v2", + expected: "io-github-org-tool-v2", + }, + { + name: "only dots", + input: "...", + expected: "---", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, sanitizeRegistryName(tc.input)) + }) + } +} + +func TestBuildRunCmd(t *testing.T) { + t.Parallel() + tests := []struct { + name string + item regtypes.ServerMetadata + contains []string + excludes []string + }{ + { + name: "minimal image metadata", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + }, + contains: []string{"thv run 'my-server'"}, + excludes: []string{"--transport", "--permission-profile", "--secret", "--env"}, + }, + { + name: "non-default transport included", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "stdio"}, + }, + contains: []string{"--transport 'stdio'"}, + }, + { + name: "default transport omitted", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "streamable-http"}, + }, + excludes: []string{"--transport"}, + }, + { + name: "permission profile included when non-none", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + Permissions: &permissions.Profile{Name: "network"}, + }, + contains: []string{"--permission-profile 'network'"}, + }, + { + name: "permission profile 'none' omitted", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + Permissions: &permissions.Profile{Name: "none"}, + }, + excludes: []string{"--permission-profile"}, + }, + { + name: "required env var becomes --secret flag", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + EnvVars: []*regtypes.EnvVar{{Name: "API_KEY", Required: true}}, + }, + contains: []string{"--secret 'API_KEY'"}, + }, + { + name: "optional env var becomes comment", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + EnvVars: []*regtypes.EnvVar{{Name: "LOG_LEVEL", Required: false}}, + }, + contains: []string{"# optional: --env 'LOG_LEVEL'="}, + excludes: []string{"--secret 'LOG_LEVEL'"}, + }, + { + name: "transport and permission profile combined", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "sse"}, + Permissions: &permissions.Profile{Name: "network"}, + }, + contains: []string{"--transport 'sse'", "--permission-profile 'network'"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := buildRunCmd(tc.item) + for _, want := range tc.contains { + assert.True(t, strings.Contains(result, want), + "expected %q in output: %q", want, result) + } + for _, unwanted := range tc.excludes { + assert.False(t, strings.Contains(result, unwanted), + "unexpected %q in output: %q", unwanted, result) + } + }) + } +} diff --git a/pkg/tui/search_test.go b/pkg/tui/search_test.go new file mode 100644 index 0000000000..10b21a25b1 --- /dev/null +++ b/pkg/tui/search_test.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestHighlightSubstring(t *testing.T) { + t.Parallel() + + bg := lipgloss.Color("#ffff00") + + tests := []struct { + name string + line string + query string + expectContains []string + expectSame bool // if true, result should equal line exactly + expectHighlighted bool // if true, output must differ from input and contain ANSI escapes + }{ + { + name: "empty query returns original", + line: "hello world", + query: "", + expectSame: true, + }, + { + name: "no match returns line with all original segments", + line: "hello world", + query: "xyz", + expectContains: []string{"hello world"}, + }, + { + name: "case insensitive match wraps with style", + line: "Hello World", + query: "hello", + expectContains: []string{"Hello", "World"}, + expectHighlighted: true, + }, + { + name: "multiple matches all highlighted", + line: "foo bar foo baz foo", + query: "foo", + expectContains: []string{"foo", "bar", "baz"}, + expectHighlighted: true, + }, + { + name: "match at end of line", + line: "prefix match", + query: "match", + expectContains: []string{"prefix", "match"}, + expectHighlighted: true, + }, + { + name: "match at start of line", + line: "start of line", + query: "start", + expectContains: []string{"start", "of line"}, + expectHighlighted: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lowerQuery := "" + if tc.query != "" { + lq := make([]byte, len(tc.query)) + for i, c := range []byte(tc.query) { + if c >= 'A' && c <= 'Z' { + lq[i] = c + 32 + } else { + lq[i] = c + } + } + lowerQuery = string(lq) + } + result := highlightSubstring(tc.line, tc.query, lowerQuery, bg) + + if tc.expectSame { + assert.Equal(t, tc.line, result) + return + } + for _, substr := range tc.expectContains { + assert.Contains(t, result, substr) + } + // When highlighting is expected, the output must differ from the + // original line and contain ANSI escape sequences. + if tc.expectHighlighted { + assert.NotEqual(t, tc.line, result, "highlighting should modify the output") + assert.Contains(t, result, "\x1b[", "highlighted output must contain ANSI escape sequences") + } + }) + } +} diff --git a/pkg/tui/tools.go b/pkg/tui/tools.go new file mode 100644 index 0000000000..e0e3c2fa06 --- /dev/null +++ b/pkg/tui/tools.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "errors" + + tea "github.com/charmbracelet/bubbletea" + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive/pkg/core" + thclient "github.com/stacklok/toolhive/pkg/mcp/client" +) + +var errStdioToolsNotAvailable = errors.New("tool listing not available for STDIO servers") + +// workloadTransport returns the transport string to use for the given workload. +// It prefers ProxyMode (set for stdio transports) and falls back to TransportType. +func workloadTransport(w *core.Workload) string { + if w.ProxyMode != "" { + return w.ProxyMode + } + return string(w.TransportType) +} + +// startMCPClientConnect returns a tea.Cmd that creates and connects an MCP +// client asynchronously, keeping the Update goroutine non-blocking. +func startMCPClientConnect(ctx context.Context, w *core.Workload) tea.Cmd { + name := w.Name + serverURL := w.URL + transport := workloadTransport(w) + return func() tea.Msg { + c, err := thclient.Connect(ctx, serverURL, transport, "toolhive-tui") + if err != nil { + return mcpClientReadyMsg{workloadName: name, err: err} + } + return mcpClientReadyMsg{workloadName: name, client: c} + } +} + +// fetchTools queries the MCP server for its tool list via an already-connected client. +func fetchTools(ctx context.Context, c *mcpclient.Client) ([]mcp.Tool, error) { + result, err := c.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + return nil, err + } + return result.Tools, nil +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000000..efbdd3d186 --- /dev/null +++ b/pkg/tui/update.go @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" +) + +// workloadsRefreshMsg is sent when the workload list is refreshed. +type workloadsRefreshMsg struct { + workloads []core.Workload +} + +// notifClearMsg is sent after the notification auto-dismiss timer fires. +type notifClearMsg struct{} + +// showNotif sets a transient notification and schedules its auto-clear after 3s. +func (m *Model) showNotif(msg string, ok bool) tea.Cmd { + m.notifMsg = msg + m.notifOK = ok + return tea.Tick(3*time.Second, func(time.Time) tea.Msg { return notifClearMsg{} }) +} + +// tuiLogMsg carries a captured slog message for display in the inspector. +type tuiLogMsg string + +// logLineMsg carries a single new log line from the streaming goroutine. +type logLineMsg string + +// logStreamDoneMsg is sent when the log stream channel is closed. +type logStreamDoneMsg struct{} + +// tickMsg is sent by the periodic workload refresh ticker. +type tickMsg time.Time + +// mcpClientReadyMsg is sent when the async MCP client connect completes. +type mcpClientReadyMsg struct { + workloadName string + client *mcpclient.Client + err error +} + +// toolsFetchedMsg is sent when the tools list is loaded from an MCP server. +type toolsFetchedMsg struct { + workloadName string + tools []mcp.Tool + err error +} + +// runConfigLoadedMsg is sent when the RunConfig is loaded for a workload. +type runConfigLoadedMsg struct { + workloadName string + cfg *runner.RunConfig + err error +} + +const refreshInterval = 5 * time.Second + +// maxLogLines caps the in-memory log buffer to prevent unbounded growth during +// long-running sessions. When exceeded, the oldest lines are dropped. +const maxLogLines = 10_000 + +// Init starts background ticks for workload refresh. +func (m Model) Init() tea.Cmd { + return tea.Batch( + scheduleRefresh(), + m.startLogStream(), + m.watchTUILog(), + ) +} + +// watchTUILog returns a command that waits for the next slog message on tuiLogCh. +func (m *Model) watchTUILog() tea.Cmd { + if m.tuiLogCh == nil { + return nil + } + ch := m.tuiLogCh + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + return tuiLogMsg(msg) + } +} + +func scheduleRefresh() tea.Cmd { + return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// startLogStream begins streaming logs for the currently selected workload. +func (m *Model) startLogStream() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + + // Cancel any existing stream. + if m.logCtxCancel != nil { + m.logCtxCancel() + } + m.logLines = nil + m.logView.SetContent("") + + ctx, cancel := context.WithCancel(m.ctx) + m.logCtxCancel = cancel + m.streamingFor = sel.Name + m.logCh = StreamWorkloadLogs(ctx, m.manager, sel.Name) + + return readLogLine(m.logCh) +} + +// readLogLine returns a tea.Cmd that waits for the next log line. +func readLogLine(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logStreamDoneMsg{} + } + return logLineMsg(line) + } +} + +// startProxyLogStream begins streaming proxy logs for the currently selected workload. +func (m *Model) startProxyLogStream() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + + if m.proxyLogCancel != nil { + m.proxyLogCancel() + } + m.proxyLogLines = nil + m.proxyLogView.SetContent("") + + ctx, cancel := context.WithCancel(m.ctx) + m.proxyLogCancel = cancel + m.proxyLogFor = sel.Name + m.proxyLogCh = StreamProxyLogs(ctx, m.manager, sel.Name) + + return readProxyLogLine(m.proxyLogCh) +} + +// Update handles all incoming messages and key events. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if cmd, done := m.handleMsg(msg); done { + return m, cmd + } else if cmd != nil { + cmds = append(cmds, cmd) + } + + // Forward scroll events to the active viewport. + switch m.panel { + case panelLogs: + var vpCmd tea.Cmd + m.logView, vpCmd = m.logView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelProxyLogs: + var vpCmd tea.Cmd + m.proxyLogView, vpCmd = m.proxyLogView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelInspector: + var vpCmd tea.Cmd + m.insp.respView, vpCmd = m.insp.respView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelTools: + var vpCmd tea.Cmd + m.toolsView, vpCmd = m.toolsView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelInfo: + // no viewport to forward scroll to + } + + return m, tea.Batch(cmds...) +} + +// handleMsg dispatches a message and returns (cmd, earlyReturn). +// earlyReturn=true means Update should return immediately with cmd. +// +//nolint:gocyclo // dispatches over all message types; splitting would add indirection without clarity +func (m *Model) handleMsg(msg tea.Msg) (tea.Cmd, bool) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeViewport() + return nil, true + case tea.KeyMsg: + return m.handleKey(msg), false + case tickMsg: + return tea.Batch(m.refreshWorkloads(), scheduleRefresh()), false + case workloadsRefreshMsg: + return m.handleWorkloadsRefresh(msg) + case logLineMsg: + return m.handleLogLine(msg) + case logStreamDoneMsg: + // Stream ended; do nothing — a future selection change will restart it. + case proxyLogLineMsg: + return m.handleProxyLogLine(msg) + case proxyLogStreamDoneMsg: + // Stream ended. + case actionDoneMsg: + var notifCmd tea.Cmd + if msg.err != nil { + notifCmd = m.showNotif("✗ "+msg.name+": "+msg.err.Error(), false) + } else { + notifCmd = m.showNotif("✓ "+msg.name+" "+msg.action, true) + } + return tea.Batch(m.refreshWorkloads(), notifCmd), false + case runFormResultMsg: + m.runForm.running = false + m.runForm.open = false + m.registry.detail = false + m.registry.open = false + var notifCmd tea.Cmd + if msg.err != nil { + notifCmd = m.showNotif("✗ "+msg.server+": "+msg.err.Error(), false) + } else { + m.pendingSelect = msg.name + notifCmd = m.showNotif("✓ "+msg.name+" started", true) + } + return tea.Batch(m.refreshWorkloads(), notifCmd), false + case notifClearMsg: + m.notifMsg = "" + return nil, false + case mcpClientReadyMsg: + return m.handleMCPClientReady(msg), false + case toolsFetchedMsg: + m.handleToolsFetched(msg) + case registryLoadedMsg: + m.handleRegistryLoaded(msg) + case runConfigLoadedMsg: + m.handleRunConfigLoaded(msg) + case inspCallResultMsg: + m.handleInspCallResult(msg) + case tuiLogMsg: + return m.handleTUILog(msg), false + case inspSpinTickMsg: + if m.insp.loading { + m.insp.spinFrame = (m.insp.spinFrame + 1) % len(inspSpinFrames) + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return inspSpinTickMsg{} }), false + } + } + return nil, false +} + +func (m *Model) handleWorkloadsRefresh(msg workloadsRefreshMsg) (tea.Cmd, bool) { + core.SortWorkloadsByName(msg.workloads) + m.workloads = msg.workloads + filtered := m.filteredWorkloads() + if m.pendingSelect != "" { + for i, w := range filtered { + if w.Name == m.pendingSelect { + m.selectedIdx = i + m.pendingSelect = "" + return nil, false + } + } + } + if m.selectedIdx >= len(filtered) && m.selectedIdx > 0 { + m.selectedIdx = len(filtered) - 1 + } + return nil, false +} + +func (m *Model) handleLogLine(msg logLineMsg) (tea.Cmd, bool) { + m.logLines = append(m.logLines, formatLogLine(string(msg))) + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + if m.logFollow { + m.logView.GotoBottom() + } + if m.logCh != nil { + return readLogLine(m.logCh), false + } + return nil, false +} + +func (m *Model) handleProxyLogLine(msg proxyLogLineMsg) (tea.Cmd, bool) { + m.proxyLogLines = append(m.proxyLogLines, formatLogLine(string(msg))) + if len(m.proxyLogLines) > maxLogLines { + m.proxyLogLines = m.proxyLogLines[len(m.proxyLogLines)-maxLogLines:] + } + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.GotoBottom() + if m.proxyLogCh != nil { + return readProxyLogLine(m.proxyLogCh), false + } + return nil, false +} + +func (m *Model) handleMCPClientReady(msg mcpClientReadyMsg) tea.Cmd { + // Ignore if the user switched to a different workload while connecting. + if msg.workloadName != m.toolsFor { + if msg.client != nil { + _ = msg.client.Close() + } + return nil + } + if msg.err != nil { + m.toolsLoading = false + m.toolsErr = msg.err + return nil + } + m.mcpClient = msg.client + sel := m.selected() + if sel == nil { + return nil + } + return startToolsFetch(m.ctx, m.mcpClient, sel) +} + +func (m *Model) handleToolsFetched(msg toolsFetchedMsg) { + if msg.workloadName == m.toolsFor { + m.tools = msg.tools + m.toolsErr = msg.err + m.toolsLoading = false + m.toolsSelectedIdx = 0 + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsView.GotoTop() + } +} + +func (m *Model) handleRegistryLoaded(msg registryLoadedMsg) { + m.registry.loading = false + m.registry.err = msg.err + m.registry.items = msg.items + m.registry.provider = msg.provider + m.registry.idx = 0 +} + +func (m *Model) handleRunConfigLoaded(msg runConfigLoadedMsg) { + if msg.workloadName == m.runConfigFor { + m.runConfig = msg.cfg + } +} + +// maxTUILogLines caps the inspector slog buffer to prevent unbounded growth. +const maxTUILogLines = 500 + +// handleTUILog appends a captured slog message to the inspector log view. +func (m *Model) handleTUILog(msg tuiLogMsg) tea.Cmd { + m.insp.logLines = append(m.insp.logLines, string(msg)) + if len(m.insp.logLines) > maxTUILogLines { + m.insp.logLines = m.insp.logLines[len(m.insp.logLines)-maxTUILogLines:] + } + content := strings.Join(m.insp.logLines, "\n") + m.insp.logView.SetContent(content) + m.insp.logView.GotoBottom() + return m.watchTUILog() +} + +// handleInspCallResult processes the result of a tool call from the inspector. +func (m *Model) handleInspCallResult(msg inspCallResultMsg) { + m.insp.loading = false + m.insp.resultMs = msg.elapsedMs + if msg.err != nil { + m.insp.result = "Error: " + msg.err.Error() + m.insp.resultOK = false + } else { + m.insp.result = formatInspResult(msg.result) + m.insp.resultOK = msg.result == nil || !msg.result.IsError + } + m.insp.respView.SetContent(m.insp.result) + m.insp.respView.GotoTop() + + // Attempt to parse the result as a JSON tree for interactive display. + m.insp.jsonRoot = parseJSONTree(m.insp.result) + if m.insp.jsonRoot != nil { + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + m.insp.treeCursor = 0 + m.insp.treeScroll = 0 + } +} + +// handleKey dispatches key events and returns a follow-up tea.Cmd if any. +func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { + // Registry overlay has its own key handling. + if m.registry.open { + return m.handleRegistryKey(msg) + } + if m.filterActive { + return m.handleFilterKey(msg) + } + if m.showHelp { + m.showHelp = false + return nil + } + if m.confirmDelete { + return m.handleConfirmDeleteKey(msg) + } + if m.panel == panelInspector { + return m.handleInspectorKey(msg) + } + // Log search prompt captures all input while active. + if m.panel == panelLogs && m.logSearchActive { + return m.handleLogSearchKey(msg) + } + if m.panel == panelProxyLogs && m.proxyLogSearchActive { + return m.handleProxyLogSearchKey(msg) + } + return m.handleNormalKey(msg) +} diff --git a/pkg/tui/update_inspector.go b/pkg/tui/update_inspector.go new file mode 100644 index 0000000000..eb41e4298d --- /dev/null +++ b/pkg/tui/update_inspector.go @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/mark3labs/mcp-go/mcp" +) + +// handleInspectorKey handles key input when the inspector panel is active. +// +//nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all inspector key bindings +func (m *Model) handleInspectorKey(msg tea.KeyMsg) tea.Cmd { + // Info modal captures all input — any key closes it. + if m.insp.showInfo { + m.insp.showInfo = false + return nil + } + + // Filter prompt captures all input until Enter or Esc. + if m.insp.filterActive { + return m.handleInspFilterKey(msg) + } + + // When a text field is focused, forward everything except Tab/ShiftTab/Escape/Enter/arrows. + if m.insp.fieldIdx >= 0 { + switch { + case key.Matches(msg, keys.Escape): + m.blurAllInspFields() + return nil + case key.Matches(msg, keys.Tab): + return m.inspNextField() + case key.Matches(msg, keys.ShiftTab): + return m.inspPrevField() + case key.Matches(msg, keys.Enter): + // Enter calls the tool even while a field is focused. + return m.inspDoCall() + case key.Matches(msg, keys.Up): + // Arrow keys move the JSON tree cursor; single-line textinputs ignore them anyway. + if m.insp.jsonRoot != nil { + return m.inspTreeMove(-1) + } + if m.insp.result != "" { + m.insp.respView.ScrollUp(1) + return nil + } + return m.inspForwardToField(msg) + case key.Matches(msg, keys.Down): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(1) + } + if m.insp.result != "" { + m.insp.respView.ScrollDown(1) + return nil + } + return m.inspForwardToField(msg) + case key.Matches(msg, keys.Space): + // Intercept Space for JSON tree collapse even when a field is focused. + if m.insp.jsonRoot != nil { + toggleCollapse(m.insp.treeVis, m.insp.treeCursor) + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + return nil + } + return m.inspForwardToField(msg) + default: + return m.inspForwardToField(msg) + } + } + + // No field focused — navigation mode. + switch { + case key.Matches(msg, keys.Escape): + // Esc goes back to the tools panel; response is preserved until + // the user changes tool or leaves the inspector panel. + m.panel = panelTools + m.blurAllInspFields() + return nil + case key.Matches(msg, keys.Up): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(-1) + } + if m.insp.result != "" { + m.insp.respView.ScrollUp(1) + return nil + } + return m.inspNavigateUp() + case key.Matches(msg, keys.Down): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(1) + } + if m.insp.result != "" { + m.insp.respView.ScrollDown(1) + return nil + } + return m.inspNavigateDown() + case key.Matches(msg, keys.Space): + // Toggle collapse on the selected JSON node. + if m.insp.jsonRoot != nil { + toggleCollapse(m.insp.treeVis, m.insp.treeCursor) + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + // Clamp cursor in case collapsed nodes removed items below cursor. + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + } + return nil + case key.Matches(msg, keys.CopyCurl): + // y copies the curl command for the current tool call to clipboard. + if sel := m.selected(); sel != nil { + if ft := m.filteredTools(); len(ft) > 0 && m.insp.toolIdx < len(ft) { + tool := ft[m.insp.toolIdx] + args, _, err := inspFieldValues(m.insp.fields) + if err != nil { + return m.showNotif(err.Error(), false) + } + curl := buildCurlStr(sel, tool.Name, args) + if err := clipboard.WriteAll(curl); err != nil { + return m.showNotif("clipboard: "+err.Error(), false) + } + return m.showNotif("✓ curl copied", true) + } + } + return nil + case key.Matches(msg, keys.CopyNode): + // c copies the full response JSON to clipboard. + if m.insp.result != "" { + m.inspCopyNode() + return m.showNotif("✓ copied to clipboard", true) + } + return nil + case key.Matches(msg, keys.Filter): + // / opens the tool filter prompt. + m.insp.filterActive = true + m.insp.filterQuery = "" + m.insp.toolIdx = 0 + m.inspRebuildForm() + return nil + case key.Matches(msg, keys.ToolInfo): + // i opens the tool description modal. + if ft := m.filteredTools(); len(ft) > 0 && m.insp.toolIdx < len(ft) { + m.insp.showInfo = true + } + return nil + case key.Matches(msg, keys.Tab): + return m.togglePanel() + case key.Matches(msg, keys.Enter): + if len(m.insp.fields) > 0 { + return m.inspNextField() + } + return m.inspDoCall() + default: + return nil + } +} + +// handleInspFilterKey handles key input while the inspector tool filter is active. +func (m *Model) handleInspFilterKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + m.insp.filterActive = false + m.insp.filterQuery = "" + m.insp.toolIdx = 0 + m.inspRebuildForm() + case key.Matches(msg, keys.Enter): + // Find the currently selected tool in the filtered list, then clear the + // filter so the full list is shown with that tool still highlighted. + filtered := m.filteredTools() + var selectedTool *mcp.Tool + if len(filtered) > 0 && m.insp.toolIdx < len(filtered) { + t := filtered[m.insp.toolIdx] + selectedTool = &t + } + m.insp.filterActive = false + m.insp.filterQuery = "" + // Restore toolIdx to the tool's position in the full list. + if selectedTool != nil { + for i, t := range m.tools { + if t.Name == selectedTool.Name { + m.insp.toolIdx = i + break + } + } + } + if len(m.insp.fields) > 0 { + m.insp.fieldIdx = 0 + m.insp.fields[0].input.Focus() + } + case key.Matches(msg, keys.Up): + return m.inspNavigateUp() + case key.Matches(msg, keys.Down): + return m.inspNavigateDown() + case msg.Type == tea.KeyBackspace: + if len(m.insp.filterQuery) > 0 { + r := []rune(m.insp.filterQuery) + m.insp.filterQuery = string(r[:len(r)-1]) + m.insp.toolIdx = 0 + m.inspRebuildForm() + } + default: + if msg.Type == tea.KeyRunes { + m.insp.filterQuery += msg.String() + m.insp.toolIdx = 0 + m.inspRebuildForm() + } + } + return nil +} + +// inspNavigateUp moves to the previous tool in the filtered list. +func (m *Model) inspNavigateUp() tea.Cmd { + if m.insp.toolIdx > 0 { + m.insp.toolIdx-- + m.inspRebuildForm() + } + return nil +} + +// inspNavigateDown moves to the next tool in the filtered list. +func (m *Model) inspNavigateDown() tea.Cmd { + if m.insp.toolIdx < len(m.filteredTools())-1 { + m.insp.toolIdx++ + m.inspRebuildForm() + } + return nil +} + +// inspNextField advances focus to the next inspector form field. +func (m *Model) inspNextField() tea.Cmd { + formNextField(m.insp.fields, &m.insp.fieldIdx) + return nil +} + +// inspPrevField moves focus to the previous inspector form field. +func (m *Model) inspPrevField() tea.Cmd { + formPrevField(m.insp.fields, &m.insp.fieldIdx) + return nil +} + +// blurAllInspFields blurs all inspector text inputs and resets the focused index. +func (m *Model) blurAllInspFields() { + formBlurAll(m.insp.fields, &m.insp.fieldIdx) +} + +// inspRebuildForm rebuilds the form fields for the currently selected tool. +func (m *Model) inspRebuildForm() { + filtered := m.filteredTools() + if len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + m.insp.fields = nil + m.insp.fieldIdx = -1 + m.insp.result = "" + m.insp.resultOK = false + m.insp.resultMs = 0 + m.insp.respView.SetContent("") + m.insp.logLines = nil + m.insp.logView.SetContent("") + return + } + tool := filtered[m.insp.toolIdx] + // Preserve the result if we're rebuilding for the same tool (e.g. re-entering inspector). + if m.insp.resultTool != tool.Name { + m.insp.result = "" + m.insp.resultOK = false + m.insp.resultMs = 0 + m.insp.respView.SetContent("") + m.insp.logLines = nil + m.insp.logView.SetContent("") + m.insp.jsonRoot = nil + m.insp.treeVis = nil + m.insp.treeCursor = 0 + m.insp.treeScroll = 0 + } + m.insp.fields = buildInspFields(tool) + m.insp.fieldIdx = -1 +} + +// inspTreeMove moves the JSON tree cursor by delta (+1 down, -1 up) and adjusts scroll. +func (m *Model) inspTreeMove(delta int) tea.Cmd { + if len(m.insp.treeVis) == 0 { + return nil + } + m.insp.treeCursor += delta + if m.insp.treeCursor < 0 { + m.insp.treeCursor = 0 + } + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + return nil +} + +// treeClampScroll adjusts treeScroll so that treeCursor stays in the visible window. +func (m *Model) treeClampScroll() { + if m.insp.treeVisH <= 0 { + return + } + if m.insp.treeCursor < m.insp.treeScroll { + m.insp.treeScroll = m.insp.treeCursor + } + if m.insp.treeCursor >= m.insp.treeScroll+m.insp.treeVisH { + m.insp.treeScroll = m.insp.treeCursor - m.insp.treeVisH + 1 + } +} + +// inspCopyNode copies the full response JSON to the clipboard. +func (m *Model) inspCopyNode() { + if m.insp.result == "" { + return + } + if err := clipboard.WriteAll(m.insp.result); err != nil { + return + } +} + +// inspForwardToField forwards a key message to the currently focused field. +func (m *Model) inspForwardToField(msg tea.KeyMsg) tea.Cmd { + return formForwardKey(m.insp.fields, m.insp.fieldIdx, msg) +} + +// inspDoCall starts an async tool call with the current field values. +func (m *Model) inspDoCall() tea.Cmd { + if m.insp.loading { + return nil + } + if m.mcpClient == nil { + return nil + } + filtered := m.filteredTools() + if len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + return nil + } + tool := filtered[m.insp.toolIdx] + args, errIdx, err := inspFieldValues(m.insp.fields) + if err != nil { + if errIdx >= 0 { + m.blurAllInspFields() + m.insp.fieldIdx = errIdx + m.insp.fields[errIdx].input.Focus() + } + return m.showNotif(err.Error(), false) + } + m.blurAllInspFields() + m.insp.loading = true + m.insp.spinFrame = 0 + m.insp.resultTool = tool.Name // track which tool the result belongs to + spinCmd := tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return inspSpinTickMsg{} }) + callCmd := startInspCallTool(m.ctx, m.mcpClient, tool.Name, args) + return tea.Batch(spinCmd, callCmd) +} diff --git a/pkg/tui/update_navigation.go b/pkg/tui/update_navigation.go new file mode 100644 index 0000000000..c8f4a6993e --- /dev/null +++ b/pkg/tui/update_navigation.go @@ -0,0 +1,529 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + mcpclient "github.com/mark3labs/mcp-go/client" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" + types "github.com/stacklok/toolhive/pkg/transport/types" +) + +// handleConfirmDeleteKey handles key input while waiting for delete confirmation. +func (m *Model) handleConfirmDeleteKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Delete): + m.confirmDelete = false + return m.doDelete() + default: + m.confirmDelete = false + } + return nil +} + +// handleFilterKey handles key input while the filter prompt is active. +func (m *Model) handleFilterKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape) || key.Matches(msg, keys.Quit): + m.filterActive = false + m.filterQuery = "" + m.selectedIdx = 0 + case key.Matches(msg, keys.Enter): + m.filterActive = false + case msg.Type == tea.KeyBackspace: + if len(m.filterQuery) > 0 { + r := []rune(m.filterQuery) + m.filterQuery = string(r[:len(r)-1]) + } + default: + if msg.Type == tea.KeyRunes { + m.filterQuery += msg.String() + } + } + return nil +} + +// handleNormalKey handles key input in normal (non-filter) mode. +// +//nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all normal-mode key bindings +func (m *Model) handleNormalKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Quit): + if m.mcpClient != nil { + _ = m.mcpClient.Close() + m.mcpClient = nil + } + if m.logCtxCancel != nil { + m.logCtxCancel() + m.logCtxCancel = nil + } + if m.proxyLogCancel != nil { + m.proxyLogCancel() + m.proxyLogCancel = nil + } + m.quitting = true + return tea.Quit + + case key.Matches(msg, keys.Up): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsNavigateUp() + } + return m.navigateUp() + + case key.Matches(msg, keys.Down): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsNavigateDown() + } + return m.navigateDown() + + case key.Matches(msg, keys.Enter): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsJumpToInspector() + } + + case key.Matches(msg, keys.Tab): + return m.togglePanel() + + case key.Matches(msg, keys.Follow): + m.toggleFollow() + + case key.Matches(msg, keys.Stop): + return m.doStop() + + case key.Matches(msg, keys.Restart): + return m.doRestart() + + case key.Matches(msg, keys.Delete): + if sel := m.selected(); sel != nil { + m.confirmDelete = true + } + + case key.Matches(msg, keys.Filter): + if m.panel == panelLogs { + m.logSearchActive = true + return nil + } + if m.panel == panelProxyLogs { + m.proxyLogSearchActive = true + return nil + } + m.filterActive = true + m.filterQuery = "" + + case key.Matches(msg, keys.Help): + m.showHelp = true + + case key.Matches(msg, keys.Registry): + return m.openRegistry() + + case key.Matches(msg, keys.Escape): + if m.panel == panelLogs && m.logSearchQuery != "" { + m.logSearchQuery = "" + m.logSearchMatches = nil + m.logSearchIdx = 0 + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + if m.panel == panelProxyLogs && m.proxyLogSearchQuery != "" { + m.proxyLogSearchQuery = "" + m.proxyLogSearchMatches = nil + m.proxyLogSearchIdx = 0 + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + + case key.Matches(msg, keys.SearchNext): + if m.panel == panelLogs && len(m.logSearchMatches) > 0 { + m.logSearchIdx = (m.logSearchIdx + 1) % len(m.logSearchMatches) + m.scrollToMatch() + } + if m.panel == panelProxyLogs && len(m.proxyLogSearchMatches) > 0 { + m.proxyLogSearchIdx = (m.proxyLogSearchIdx + 1) % len(m.proxyLogSearchMatches) + m.scrollToProxyMatch() + } + + case key.Matches(msg, keys.SearchPrev): + if m.panel == panelLogs && len(m.logSearchMatches) > 0 { + m.logSearchIdx = (m.logSearchIdx - 1 + len(m.logSearchMatches)) % len(m.logSearchMatches) + m.scrollToMatch() + } + if m.panel == panelProxyLogs && len(m.proxyLogSearchMatches) > 0 { + m.proxyLogSearchIdx = (m.proxyLogSearchIdx - 1 + len(m.proxyLogSearchMatches)) % len(m.proxyLogSearchMatches) + m.scrollToProxyMatch() + } + + case key.Matches(msg, keys.ScrollLeft): + m.hScrollLeft() + + case key.Matches(msg, keys.ScrollRight): + m.hScrollRight() + + case key.Matches(msg, keys.CopyURL): + if sel := m.selected(); sel != nil && sel.URL != "" { + if err := clipboard.WriteAll(sel.URL); err != nil { + return m.showNotif("clipboard: "+err.Error(), false) + } + return m.showNotif("✓ URL copied", true) + } + } + + return nil +} + +// toolsNavigateUp moves the tool selection up and refreshes the viewport. +func (m *Model) toolsNavigateUp() tea.Cmd { + if m.toolsSelectedIdx > 0 { + m.toolsSelectedIdx-- + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsScrollToSelected() + } + return nil +} + +// toolsNavigateDown moves the tool selection down and refreshes the viewport. +func (m *Model) toolsNavigateDown() tea.Cmd { + if m.toolsSelectedIdx < len(m.tools)-1 { + m.toolsSelectedIdx++ + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsScrollToSelected() + } + return nil +} + +// toolsScrollToSelected adjusts the viewport so the selected tool stays visible. +func (m *Model) toolsScrollToSelected() { + // Each tool occupies approximately 1-3 lines; use a rough line-per-tool estimate. + // The header is 2 lines (count + blank line). + const headerLines = 2 + line := headerLines + m.toolsSelectedIdx + if line < m.toolsView.YOffset { + m.toolsView.SetYOffset(line) + } else if line >= m.toolsView.YOffset+m.toolsView.Height { + m.toolsView.SetYOffset(line - m.toolsView.Height + 1) + } +} + +// toolsJumpToInspector switches to the Inspector panel with the currently +// selected tool pre-selected and the form ready to fill. +func (m *Model) toolsJumpToInspector() tea.Cmd { + // Find the matching index in the inspector tool list (same m.tools slice). + m.insp.toolIdx = m.toolsSelectedIdx + if m.insp.toolIdx >= len(m.tools) { + m.insp.toolIdx = 0 + } + m.panel = panelInspector + m.inspRebuildForm() + // Focus the first field if available. + if len(m.insp.fields) > 0 { + m.insp.fieldIdx = 0 + m.insp.fields[0].input.Focus() + } + return m.maybeStartToolsFetch() +} + +func (m *Model) navigateUp() tea.Cmd { + if m.selectedIdx > 0 { + m.selectedIdx-- + return m.onSelectionChanged() + } + return nil +} + +func (m *Model) navigateDown() tea.Cmd { + list := m.filteredWorkloads() + if m.selectedIdx < len(list)-1 { + m.selectedIdx++ + return m.onSelectionChanged() + } + return nil +} + +// hScrollLeft scrolls the active log panel left by 8 columns. +func (m *Model) hScrollLeft() { + const step = 8 + switch m.panel { + case panelLogs: + if m.logHScrollOff > 0 { + m.logHScrollOff -= step + if m.logHScrollOff < 0 { + m.logHScrollOff = 0 + } + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + case panelProxyLogs: + if m.proxyLogHScrollOff > 0 { + m.proxyLogHScrollOff -= step + if m.proxyLogHScrollOff < 0 { + m.proxyLogHScrollOff = 0 + } + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + case panelInfo, panelTools, panelInspector: + // h-scroll not applicable to these panels + } +} + +// hScrollRight scrolls the active log panel right by 8 columns. +func (m *Model) hScrollRight() { + const step = 8 + switch m.panel { + case panelLogs: + maxOff := maxLineLen(m.logLines) + if m.logHScrollOff+step <= maxOff { + m.logHScrollOff += step + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + case panelProxyLogs: + maxOff := maxLineLen(m.proxyLogLines) + if m.proxyLogHScrollOff+step <= maxOff { + m.proxyLogHScrollOff += step + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + case panelInfo, panelTools, panelInspector: + // h-scroll not applicable to these panels + } +} + +// maxLineLen returns the length (in runes) of the longest line in the slice. +func maxLineLen(lines []string) int { + m := 0 + for _, l := range lines { + if n := len([]rune(l)); n > m { + m = n + } + } + return m +} + +// onSelectionChanged resets panel state and starts any needed background fetches. +func (m *Model) onSelectionChanged() tea.Cmd { + // Close the previous workload's MCP client. + if m.mcpClient != nil { + _ = m.mcpClient.Close() + m.mcpClient = nil + } + + m.toolsFor = "" // invalidate tools cache + m.toolsSelectedIdx = 0 // reset tool selection + m.runConfigFor = "" // invalidate runConfig cache + m.runConfig = nil + m.logHScrollOff = 0 + m.proxyLogHScrollOff = 0 + + // Reset inspector state on selection change. + m.insp.toolIdx = 0 + m.insp.fields = nil + m.insp.fieldIdx = -1 + m.insp.result = "" + + // Cancel proxy log stream for old selection. + if m.proxyLogCancel != nil { + m.proxyLogCancel() + m.proxyLogCancel = nil + m.proxyLogLines = nil + m.proxyLogView.SetContent("") + m.proxyLogFor = "" + } + + cmds := []tea.Cmd{m.startLogStream()} + switch m.panel { + case panelTools: + cmds = append(cmds, m.maybeStartToolsFetch()) + case panelInfo: + cmds = append(cmds, m.maybeLoadRunConfig()) + case panelProxyLogs: + cmds = append(cmds, m.startProxyLogStream()) + case panelInspector: + cmds = append(cmds, m.maybeStartToolsFetch()) + case panelLogs: + // log stream already started above + } + return tea.Batch(cmds...) +} + +func (m *Model) togglePanel() tea.Cmd { + switch m.panel { + case panelLogs: + m.panel = panelInfo + return m.maybeLoadRunConfig() + case panelInfo: + m.panel = panelTools + return m.maybeStartToolsFetch() + case panelTools: + m.panel = panelProxyLogs + return m.startProxyLogStream() + case panelProxyLogs: + // Stop proxy log stream when leaving the panel. + if m.proxyLogCancel != nil { + m.proxyLogCancel() + m.proxyLogCancel = nil + } + m.panel = panelInspector + m.inspRebuildForm() + return m.maybeStartToolsFetch() + case panelInspector: + m.blurAllInspFields() + m.panel = panelLogs + } + return nil +} + +// maybeStartToolsFetch fetches tools for the selected workload if not already loaded. +func (m *Model) maybeStartToolsFetch() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + // STDIO servers only support a single initialize handshake; calling it again + // from the TUI would interfere with the real client connection. + if sel.TransportType == types.TransportTypeStdio { + m.toolsFor = sel.Name + m.toolsLoading = false + m.tools = nil + m.toolsErr = errStdioToolsNotAvailable + return nil + } + // Retry if previously failed; skip only when successfully loaded. + if m.toolsFor == sel.Name && !m.toolsLoading && m.toolsErr == nil { + return nil // already loaded successfully + } + m.toolsFor = sel.Name + m.toolsLoading = true + m.tools = nil + m.toolsErr = nil + + // Connect the MCP client asynchronously if not already present. + if m.mcpClient == nil { + return startMCPClientConnect(m.ctx, sel) + } + return startToolsFetch(m.ctx, m.mcpClient, sel) +} + +// maybeLoadRunConfig loads the RunConfig for the selected workload if not already loaded. +func (m *Model) maybeLoadRunConfig() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + if m.runConfigFor == sel.Name && m.runConfig != nil { + return nil // already loaded + } + m.runConfigFor = sel.Name + m.runConfig = nil + name := sel.Name + ctx := m.ctx + return func() tea.Msg { + cfg, err := runner.LoadState(ctx, name) + if err != nil { + return runConfigLoadedMsg{workloadName: name, cfg: nil, err: err} + } + return runConfigLoadedMsg{workloadName: name, cfg: cfg} + } +} + +// startToolsFetch returns a tea.Cmd that fetches tools for a workload via an MCP client. +func startToolsFetch(ctx context.Context, c *mcpclient.Client, w *core.Workload) tea.Cmd { + name := w.Name + return func() tea.Msg { + tools, err := fetchTools(ctx, c) + return toolsFetchedMsg{workloadName: name, tools: tools, err: err} + } +} + +func (m *Model) toggleFollow() { + m.logFollow = !m.logFollow + if m.logFollow { + m.logView.GotoBottom() + } +} + +func (m *Model) doStop() tea.Cmd { + if sel := m.selected(); sel != nil { + return stopWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +func (m *Model) doRestart() tea.Cmd { + if sel := m.selected(); sel != nil { + return restartWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +func (m *Model) doDelete() tea.Cmd { + if sel := m.selected(); sel != nil { + return deleteWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +// openRegistry opens the registry overlay and triggers a fetch if needed. +func (m *Model) openRegistry() tea.Cmd { + m.registry.open = true + m.registry.filter = "" + m.registry.idx = 0 + if len(m.registry.items) > 0 { + return nil // already loaded + } + m.registry.loading = true + m.registry.err = nil + return fetchRegistryItems(m.ctx) +} + +// refreshWorkloads returns a tea.Cmd that fetches the workload list. +func (m *Model) refreshWorkloads() tea.Cmd { + return func() tea.Msg { + list, err := m.manager.ListWorkloads(m.ctx, true) + if err != nil { + return nil + } + return workloadsRefreshMsg{workloads: list} + } +} + +// resizeViewport recalculates the viewport dimensions based on the terminal size. +func (m *Model) resizeViewport() { + sidebarWidth := sidebarW(m.width) + mainWidth := m.width - sidebarWidth - 1 // 1 for the divider + // mainStyle Height = m.height-2; title(1)+tabBar(1)+sep(1)+toolbar(1) = 4 overhead + logHeight := max(m.height-6, 1) + m.logView.Width = mainWidth + m.logView.Height = logHeight + m.proxyLogView.Width = mainWidth + m.proxyLogView.Height = logHeight + // Tools viewport: same height as logs, rebuild content to reflect new width. + if m.toolsView.Width != mainWidth || m.toolsView.Height != logHeight { + m.toolsView.Width = mainWidth + m.toolsView.Height = logHeight + if len(m.tools) > 0 { + m.toolsView.SetContent(buildToolsContent(m.tools, mainWidth, m.toolsSelectedIdx)) + } + } + // Inspector response viewport: right-column minus headers (~8 lines) and log section (6 lines). + const inspLogHeight = 6 + // inspH = m.height - 5 (from renderInspector); 8 lines of REQUEST/RESPONSE headers overhead. + const inspHeaderOverhead = 8 + m.insp.logView.Width = mainWidth + m.insp.logView.Height = inspLogHeight + m.insp.respView.Width = mainWidth + m.insp.respView.Height = max(m.height-10-inspLogHeight, 3) + m.insp.treeVisH = max(m.height-5-inspHeaderOverhead, 3) +} + +// sidebarW returns the sidebar width given total terminal width. +func sidebarW(totalWidth int) int { + w := totalWidth / 4 + if w < 24 { + return 24 + } + if w > 40 { + return 40 + } + return w +} diff --git a/pkg/tui/update_registry.go b/pkg/tui/update_registry.go new file mode 100644 index 0000000000..398060a8c6 --- /dev/null +++ b/pkg/tui/update_registry.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" +) + +// handleRegistryKey handles key input while the registry overlay is open. +func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { + // Run form captures all input while open. + if m.runForm.open { + return m.handleRunFormKey(msg) + } + // Detail view has its own key handling. + if m.registry.detail { + return m.handleRegistryDetailKey(msg) + } + switch { + case key.Matches(msg, keys.Escape), key.Matches(msg, keys.Registry): + m.registry.open = false + m.registry.detail = false + m.registry.filter = "" + m.registry.idx = 0 + m.registry.scrollOff = 0 + case key.Matches(msg, keys.Enter): + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + m.registry.detail = true + m.registry.detailScroll = 0 + } + case key.Matches(msg, keys.Up): + if m.registry.idx > 0 { + m.registry.idx-- + m.clampRegistryScroll() + } + case key.Matches(msg, keys.Down): + items := m.filteredRegistryItems() + if m.registry.idx < len(items)-1 { + m.registry.idx++ + m.clampRegistryScroll() + } + case msg.Type == tea.KeyBackspace: + if len(m.registry.filter) > 0 { + r := []rune(m.registry.filter) + m.registry.filter = string(r[:len(r)-1]) + m.registry.idx = 0 + m.registry.scrollOff = 0 + } + default: + if msg.Type == tea.KeyRunes { + m.registry.filter += msg.String() + m.registry.idx = 0 + m.registry.scrollOff = 0 + } + } + return nil +} + +// handleRegistryDetailKey handles key input in the detail view. +func (m *Model) handleRegistryDetailKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + m.registry.detail = false + m.registry.detailScroll = 0 + case key.Matches(msg, keys.Up): + if m.registry.detailScroll > 0 { + m.registry.detailScroll-- + } + case key.Matches(msg, keys.Down): + m.registry.detailScroll++ + case key.Matches(msg, keys.CopyCurl): + // y copies the suggested `thv run` command for the selected registry item. + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + cmd := buildRunCmd(items[m.registry.idx]) + if err := clipboard.WriteAll(cmd); err != nil { + return m.showNotif("clipboard: "+err.Error(), false) + } + return m.showNotif("✓ run command copied", true) + } + case key.Matches(msg, keys.Restart): + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + return m.openRunForm(items[m.registry.idx]) + } + } + return nil +} + +// clampRegistryScroll adjusts the scroll offset so the selected item is visible. +func (m *Model) clampRegistryScroll() { + visible := m.registryVisibleRows() + if visible < 1 { + return + } + if m.registry.idx < m.registry.scrollOff { + m.registry.scrollOff = m.registry.idx + } + if m.registry.idx >= m.registry.scrollOff+visible { + m.registry.scrollOff = m.registry.idx - visible + 1 + } +} + +// registryVisibleRows returns how many item rows fit in the current overlay. +func (m *Model) registryVisibleRows() int { + // overlay height is ~70% of terminal, minus borders/header/search/footer (~8 lines) + h := m.height*70/100 - 8 + if h < 3 { + return 3 + } + return h +} + +// openRunForm initialises the run-from-registry form for the given item. +func (m *Model) openRunForm(item regtypes.ServerMetadata) tea.Cmd { + m.runForm = runFormState{ + open: true, + item: item, + fields: buildRunFormFields(item), + idx: 0, + scroll: 0, + } + if len(m.runForm.fields) > 0 { + m.runForm.fields[0].input.Focus() + } + return nil +} + +// handleRunFormKey handles key input while the run form is open. +func (m *Model) handleRunFormKey(msg tea.KeyMsg) tea.Cmd { + if m.runForm.running { + return nil + } + switch { + case key.Matches(msg, keys.Escape): + m.runForm.open = false + return nil + case key.Matches(msg, keys.Tab): + m.runFormNextField() + m.clampRunFormScroll() + return nil + case key.Matches(msg, keys.ShiftTab): + m.runFormPrevField() + m.clampRunFormScroll() + return nil + case key.Matches(msg, keys.Enter): + return m.runFormSubmit() + default: + return m.runFormForwardToField(msg) + } +} + +func (m *Model) runFormNextField() { + formNextField(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) runFormPrevField() { + formPrevField(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) blurAllRunFormFields() { + formBlurAll(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) runFormForwardToField(msg tea.KeyMsg) tea.Cmd { + return formForwardKey(m.runForm.fields, m.runForm.idx, msg) +} + +// runFormSubmit validates required fields and launches the run command. +func (m *Model) runFormSubmit() tea.Cmd { + if len(m.runForm.fields) == 0 { + return m.showNotif("✗ no form fields", false) + } + + // Validate required fields. + for _, f := range m.runForm.fields { + if f.required && strings.TrimSpace(f.input.Value()) == "" { + return m.showNotif("✗ "+f.name+" is required", false) + } + } + + workloadName := strings.TrimSpace(m.runForm.fields[0].input.Value()) + + secrets := make(map[string]string) + envs := make(map[string]string) + for _, f := range m.runForm.fields[1:] { + val := strings.TrimSpace(f.input.Value()) + if val == "" { + continue + } + if f.secret { + secrets[f.name] = val + } else { + envs[f.name] = val + } + } + + m.runForm.running = true + m.blurAllRunFormFields() + // Use context.WithoutCancel so the workload outlives the TUI session if + // the user quits while the launch is in progress. + runCtx := context.WithoutCancel(m.ctx) + return runFromRegistry(runCtx, m.manager, m.runForm.item, workloadName, secrets, envs) +} + +// clampRunFormScroll ensures the focused field is visible in the form overlay. +func (m *Model) clampRunFormScroll() { + // Each field takes ~3 lines (label + optional desc + input). + // Visible area is roughly 70% of height minus header/footer. + visibleFields := max((m.height*70/100-8)/3, 2) + if m.runForm.idx < m.runForm.scroll { + m.runForm.scroll = m.runForm.idx + } + if m.runForm.idx >= m.runForm.scroll+visibleFields { + m.runForm.scroll = m.runForm.idx - visibleFields + 1 + } +} diff --git a/pkg/tui/update_search.go b/pkg/tui/update_search.go new file mode 100644 index 0000000000..15f7516d16 --- /dev/null +++ b/pkg/tui/update_search.go @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// searchParams groups the mutable search state and associated viewport data +// needed by the shared search helpers. Callers construct this with pointers to +// the relevant Model fields so the helpers can read and write them in place. +type searchParams struct { + active *bool + query *string + matches *[]int + idx *int + lines []string + vp *viewport.Model + hOff int +} + +// handleSearchKey is the shared key handler for both log and proxy-log search. +func handleSearchKey(msg tea.KeyMsg, p searchParams) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + // Esc clears the search entirely and restores normal log content. + *p.active = false + *p.query = "" + *p.matches = nil + *p.idx = 0 + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + case key.Matches(msg, keys.Enter): + // Enter closes the prompt but keeps highlights and the current match. + *p.active = false + case msg.Type == tea.KeyBackspace: + if len(*p.query) > 0 { + // Remove last rune (not last byte) to handle multi-byte UTF-8. + r := []rune(*p.query) + *p.query = string(r[:len(r)-1]) + rebuildSearch(p) + } + default: + if msg.Type == tea.KeyRunes { + *p.query += msg.String() + rebuildSearch(p) + } + } + return nil +} + +// rebuildSearch recalculates which lines match the current query and +// refreshes the viewport content with highlights. +func rebuildSearch(p searchParams) { + *p.matches = nil + *p.idx = 0 + if *p.query == "" { + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + return + } + lq := strings.ToLower(*p.query) + for i, line := range p.lines { + if strings.Contains(strings.ToLower(line), lq) { + *p.matches = append(*p.matches, i) + } + } + // Clamp current index. + if *p.idx >= len(*p.matches) { + *p.idx = 0 + } + scrollToSearchMatch(p) +} + +// scrollToSearchMatch updates the viewport content with highlights and scrolls +// to the current match. +func scrollToSearchMatch(p searchParams) { + if len(*p.matches) == 0 { + // Re-render without highlights when there are no matches. + if *p.query != "" { + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, nil, 0, p.vp.Width, p.hOff)) + } + return + } + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, *p.matches, *p.idx, p.vp.Width, p.hOff)) + // Scroll the viewport so the current match line is visible. + matchLine := (*p.matches)[*p.idx] + p.vp.SetYOffset(matchLine) +} + +// logSearchParams builds searchParams for the main log panel. +func (m *Model) logSearchParams() searchParams { + return searchParams{ + active: &m.logSearchActive, + query: &m.logSearchQuery, + matches: &m.logSearchMatches, + idx: &m.logSearchIdx, + lines: m.logLines, + vp: &m.logView, + hOff: m.logHScrollOff, + } +} + +// proxyLogSearchParams builds searchParams for the proxy log panel. +func (m *Model) proxyLogSearchParams() searchParams { + return searchParams{ + active: &m.proxyLogSearchActive, + query: &m.proxyLogSearchQuery, + matches: &m.proxyLogSearchMatches, + idx: &m.proxyLogSearchIdx, + lines: m.proxyLogLines, + vp: &m.proxyLogView, + hOff: m.proxyLogHScrollOff, + } +} + +// handleLogSearchKey handles key input while the log search prompt is open. +func (m *Model) handleLogSearchKey(msg tea.KeyMsg) tea.Cmd { + return handleSearchKey(msg, m.logSearchParams()) +} + +// scrollToMatch updates the viewport content with highlights and scrolls to the current match. +func (m *Model) scrollToMatch() { + scrollToSearchMatch(m.logSearchParams()) +} + +// handleProxyLogSearchKey processes key events when proxy log search is active. +func (m *Model) handleProxyLogSearchKey(msg tea.KeyMsg) tea.Cmd { + return handleSearchKey(msg, m.proxyLogSearchParams()) +} + +// scrollToProxyMatch updates the proxy log viewport with highlights and scrolls to the current match. +func (m *Model) scrollToProxyMatch() { + scrollToSearchMatch(m.proxyLogSearchParams()) +} + +// buildHighlightedLogContent builds viewport content like buildHScrollContent but also +// highlights the search query within matching lines. The current focused match +// is highlighted with green; other matches with yellow. +func buildHighlightedLogContent(lines []string, query string, matches []int, currentMatchIdx int, viewW, hOff int) string { + if len(lines) == 0 { + return "" + } + if query == "" { + return buildHScrollContent(lines, viewW, hOff) + } + + // Build a set for fast match lookup. + matchSet := make(map[int]bool, len(matches)) + for _, idx := range matches { + matchSet[idx] = true + } + var currentMatchLine int + if len(matches) > 0 && currentMatchIdx < len(matches) { + currentMatchLine = matches[currentMatchIdx] + } + + lowerQuery := strings.ToLower(query) + + var sb strings.Builder + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + + if !matchSet[i] { + // Non-matching line: apply only h-scroll. + if viewW > 0 { + xansiLine := xansiCutLine(line, hOff, viewW) + sb.WriteString(xansiLine) + } else { + sb.WriteString(line) + } + continue + } + + // Matching line: inject highlights then h-scroll. + highlightBg := ui.ColorYellow + if i == currentMatchLine { + highlightBg = ui.ColorGreen + } + highlighted := highlightSubstring(line, query, lowerQuery, highlightBg) + + if viewW > 0 { + sb.WriteString(xansiCutLine(highlighted, hOff, viewW)) + } else { + sb.WriteString(highlighted) + } + } + return sb.String() +} + +// highlightSubstring wraps all case-insensitive occurrences of query within line +// with a lipgloss background color. It operates on rune indices so that +// multi-byte UTF-8 characters and Unicode case mappings are handled correctly. +func highlightSubstring(line, query, lowerQuery string, bg lipgloss.Color) string { + if query == "" { + return line + } + lineRunes := []rune(line) + lowerLineRunes := []rune(strings.ToLower(line)) + queryRunes := []rune(lowerQuery) + qLen := len(queryRunes) + hlStyle := lipgloss.NewStyle().Background(bg).Foreground(ui.ColorBg) + + var sb strings.Builder + pos := 0 + for pos <= len(lowerLineRunes)-qLen { + idx := runesIndex(lowerLineRunes[pos:], queryRunes) + if idx < 0 { + break + } + abs := pos + idx + sb.WriteString(string(lineRunes[pos:abs])) + sb.WriteString(hlStyle.Render(string(lineRunes[abs : abs+qLen]))) + pos = abs + qLen + } + sb.WriteString(string(lineRunes[pos:])) + return sb.String() +} + +// runesIndex returns the rune index of the first occurrence of sub in s, or -1. +func runesIndex(s, sub []rune) int { + if len(sub) == 0 { + return 0 + } + for i := 0; i <= len(s)-len(sub); i++ { + match := true + for j := range sub { + if s[i+j] != sub[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} + +// xansiCutLine applies ANSI-aware horizontal slicing to a single line. +// It is a thin wrapper around buildHScrollContent for a single line. +func xansiCutLine(line string, hOff, viewW int) string { + result := buildHScrollContent([]string{line}, viewW, hOff) + return result +} diff --git a/pkg/tui/view.go b/pkg/tui/view.go new file mode 100644 index 0000000000..a9c7a6127d --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// View renders the full TUI to a string. +// We build exactly m.height lines by slotting body lines into a fixed array +// and placing the 2-line statusbar at the last two rows. This avoids any +// off-by-one ambiguity from lipgloss Height padding or trailing-newline +// counting differences between lipgloss and BubbleTea's "\n"-split renderer. +// oscSetBg is the OSC 11 sequence that sets the terminal's own default +// background colour. Every cell that has no explicit background (log text, +// tool descriptions, text-input interiors, etc.) will inherit this colour, +// giving the whole TUI a uniform #1e2030 background without having to style +// every individual element. oscResetBg restores the original colour on exit. +const oscSetBg = "\x1b]11;#1e2030\x07" +const oscResetBg = "\x1b]111;\x07" + +// View implements tea.Model and renders the full TUI to a string. +func (m Model) View() string { + if m.quitting { + // Reset terminal background before handing control back to the shell. + return oscResetBg + } + if m.width == 0 || m.height < 2 { + return "Loading…\n" + } + + sidebar := m.renderSidebar() + main := m.renderMain() + + // Divider: exactly m.height-2 rows (no trailing \n) to match sidebar/main. + var dividerStr string + if m.height > 3 { + dividerStr = strings.Repeat("│\n", m.height-3) + "│" + } else { + dividerStr = "│" + } + divider := lipgloss.NewStyle(). + Foreground(ui.ColorDim). + Render(dividerStr) + + body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, divider, main) + statusbar := m.renderStatusBar() + + // Split the statusbar into its two component lines. + sbParts := strings.SplitN(statusbar, "\n", 2) + if len(sbParts) < 2 { + sbParts = append(sbParts, "") + } + + // bgRow fills any unfilled slots with the main background so no row ever + // shows the raw terminal background between the content and the statusbar. + bgRow := lipgloss.NewStyle().Width(m.width).Background(ui.ColorBg).Render("") + + // Build an explicit m.height-line slice so BubbleTea always fills the + // entire terminal window, regardless of any lipgloss rounding. + out := make([]string, m.height) + // Pre-fill every body slot with the background colour. + for i := range m.height - 2 { + out[i] = bgRow + } + bodyLines := strings.Split(body, "\n") + // Drop a trailing empty element that lipgloss may append. + if len(bodyLines) > 0 && bodyLines[len(bodyLines)-1] == "" { + bodyLines = bodyLines[:len(bodyLines)-1] + } + for i, l := range bodyLines { + if i >= m.height-2 { + break + } + out[i] = l + } + out[m.height-2] = sbParts[0] + out[m.height-1] = sbParts[1] + + // Prepend the OSC 11 sequence so the terminal's default background is + // #1e2030 for this frame. Every area with no explicit background colour + // (log lines, tool text, text-input interiors, …) will therefore show + // the same dark tone as the statusbar with no further changes needed. + full := oscSetBg + strings.Join(out, "\n") + + if m.showHelp { + return m.renderHelpOverlay() + } + if m.registry.open { + return m.renderRegistryOverlay() + } + return full +} + +// renderSidebar renders the left server list. +func (m Model) renderSidebar() string { + sw := sidebarW(m.width) + + titleStyle := lipgloss.NewStyle(). + Foreground(ui.ColorPurple). + Bold(true). + Width(sw) + + list := m.filteredWorkloads() + running, stopped := countStatuses(m.workloads) + summary := lipgloss.NewStyle().Foreground(ui.ColorDim). + Render(fmt.Sprintf("%dr · %ds", running, stopped)) + + header := titleStyle.Render("SERVERS") + " " + summary + "\n" + + var sb strings.Builder + sb.WriteString(header) + + for i, w := range list { + dot := ui.RenderStatusDot(w.Status) + name := w.Name + port := fmt.Sprintf(":%d", w.Port) + + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + portStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + + if i == m.selectedIdx { + nameStyle = nameStyle.Background(lipgloss.Color("#2a2e45")).Bold(true) + portStyle = portStyle.Background(lipgloss.Color("#2a2e45")) + } + + line1 := fmt.Sprintf("%s %s%s", + dot, + nameStyle.Render(truncateSidebar(name, sw-8)), + portStyle.Render(port), + ) + sb.WriteString(" " + line1 + "\n") + + // Show group on a second line if present + if w.Group != "" { + groupLine := lipgloss.NewStyle(). + Foreground(ui.ColorDim2). + Render(" " + w.Group) + sb.WriteString(groupLine + "\n") + } + } + + // Filter prompt + if m.filterActive { + prompt := lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.filterQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + sb.WriteString("\n" + prompt + "\n") + } + + sidebarStyle := lipgloss.NewStyle(). + Width(sw). + Height(m.height - 2).MaxHeight(m.height - 2). // body = m.height-2, statusbar = 2, total = m.height + PaddingRight(1) + + return sidebarStyle.Render(sb.String()) +} + +// renderMain renders the main content panel (logs or info). +// +//nolint:gocyclo // builds the full main-area layout; the toolbar sub-sections are tightly coupled to panel state +func (m Model) renderMain() string { + sw := sidebarW(m.width) + mainW := m.width - sw - 1 + if mainW < 10 { + mainW = 10 + } + + sel := m.selected() + + // Title bar + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorBlue).Bold(true) + var titleText string + if sel != nil { + titleText = titleStyle.Render("toolhive") + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" / ") + + lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render(sel.Name) + } else { + titleText = titleStyle.Render("toolhive") + } + + // Tab bar + logsTab := m.renderTab("Logs", panelLogs) + infoTab := m.renderTab("Info", panelInfo) + toolsTab := m.renderTab("Tools", panelTools) + proxyTab := m.renderTab("Proxy Logs", panelProxyLogs) + inspTab := m.renderTab("Inspector", panelInspector) + tabBar := logsTab + " " + infoTab + " " + toolsTab + " " + proxyTab + " " + inspTab + + // Separator + sep := lipgloss.NewStyle().Foreground(ui.ColorDim). + Render(strings.Repeat("─", mainW)) + + // Content + var content string + switch m.panel { + case panelLogs: + content = m.logView.View() + case panelInfo: + if sel != nil { + content = renderInfo(sel, m.runConfig, mainW) + } else { + content = lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + case panelTools: + content = m.renderTools(mainW) + case panelProxyLogs: + content = m.renderProxyLogs(mainW) + case panelInspector: + content = m.renderInspector(mainW) + } + + // Log toolbar (only on logs/proxy logs panels) + toolbar := "" + dimToolbar := lipgloss.NewStyle().Foreground(ui.ColorDim) + if m.panel == panelLogs { + if m.logSearchActive || m.logSearchQuery != "" { + // Search toolbar: show prompt or active query with match count. + queryPart := func() string { + if m.logSearchActive { + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.logSearchQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + } + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.logSearchQuery) + }() + matchPart := func() string { + if len(m.logSearchMatches) == 0 { + return " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("no matches") + } + return " " + lipgloss.NewStyle().Foreground(ui.ColorGreen).Render( + fmt.Sprintf("match %d/%d", m.logSearchIdx+1, len(m.logSearchMatches)), + ) + dimToolbar.Render(" (n=next N=prev esc=clear)") + }() + toolbar = " " + queryPart + matchPart + } else { + followStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + if m.logFollow { + followStyle = followStyle.Foreground(ui.ColorGreen) + } + hScrollHint := dimToolbar.Render(" ←→ scroll") + if m.logHScrollOff > 0 { + hScrollHint = dimToolbar.Render(fmt.Sprintf(" ←→ +%d", m.logHScrollOff)) + } + toolbar = " " + followStyle.Render("follow") + + dimToolbar.Render(" (f to toggle)") + hScrollHint + } + } + if m.panel == panelProxyLogs { + if m.proxyLogSearchActive || m.proxyLogSearchQuery != "" { + queryPart := func() string { + if m.proxyLogSearchActive { + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.proxyLogSearchQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + } + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.proxyLogSearchQuery) + }() + matchPart := func() string { + if len(m.proxyLogSearchMatches) == 0 { + return " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("no matches") + } + return " " + lipgloss.NewStyle().Foreground(ui.ColorGreen).Render( + fmt.Sprintf("match %d/%d", m.proxyLogSearchIdx+1, len(m.proxyLogSearchMatches)), + ) + dimToolbar.Render(" (n=next N=prev esc=clear)") + }() + toolbar = " " + queryPart + matchPart + } else if m.proxyLogFor != "" { + hScrollHint := dimToolbar.Render(" ←→ scroll") + if m.proxyLogHScrollOff > 0 { + hScrollHint = dimToolbar.Render(fmt.Sprintf(" ←→ +%d", m.proxyLogHScrollOff)) + } + toolbar = " " + dimToolbar.Render(fmt.Sprintf("source: toolhive/logs/%s.log", m.proxyLogFor)) + + hScrollHint + } + } + + mainStyle := lipgloss.NewStyle().Width(mainW).Height(m.height - 2).MaxHeight(m.height - 2) + + // Only include toolbar if non-empty to avoid a trailing blank line. + bodyParts := []string{titleText, tabBar, sep, content} + if toolbar != "" { + bodyParts = append(bodyParts, toolbar) + } + body := strings.Join(bodyParts, "\n") + + return mainStyle.Render(body) +} + +// renderTab renders a single tab, highlighted if active. +func (m Model) renderTab(label string, p activePanel) string { + if m.panel == p { + return lipgloss.NewStyle(). + Foreground(ui.ColorBlue). + Bold(true). + Underline(true). + Render("[" + label + "]") + } + return lipgloss.NewStyle(). + Foreground(ui.ColorDim2). + Render("[" + label + "]") +} + +// renderProxyLogs renders the proxy log panel. +func (m Model) renderProxyLogs(width int) string { + _ = width + if m.selected() == nil { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + if len(m.proxyLogLines) == 0 { + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Waiting for proxy logs…") + } + return m.proxyLogView.View() +} diff --git a/pkg/tui/view_helpers.go b/pkg/tui/view_helpers.go new file mode 100644 index 0000000000..b322c17405 --- /dev/null +++ b/pkg/tui/view_helpers.go @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" +) + +// renderFormFieldFromStruct renders a formField with its metadata as a labelled input. +func renderFormFieldFromStruct(f formField, focused bool, width int) []string { + tag := "" + if f.typeName != "" { + tag = "[" + f.typeName + "]" + } else if f.secret { + tag = "(secret)" + } + return renderFormField(f.name, f.desc, tag, f.required, focused, f.input, width) +} + +// renderFormField renders a single labelled form field with optional description, +// required marker, extra tag, and a bordered text input. It returns the rendered +// lines (label, optional description, input) as a slice of strings. +func renderFormField(name, desc, extraTag string, required, focused bool, input textinput.Model, width int) []string { + var lines []string + + reqMark := "" + if required { + reqMark = lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render(" *") + } + tag := "" + if extraTag != "" { + tag = " " + lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(extraTag) + } + label := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render(name) + reqMark + tag + lines = append(lines, label) + + if desc != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" "+truncateSidebar(desc, width-4))) + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorDim). + Width(width - 4) + if focused { + inputStyle = inputStyle.BorderForeground(ui.ColorCyan) + } + lines = append(lines, inputStyle.Render(input.View())) + + return lines +} + +// inspCopyBadge renders a small [KEY] LABEL badge for the inspector headers. +func inspCopyBadge(key, label string) string { + keyPart := lipgloss.NewStyle(). + Background(lipgloss.Color("#2a2f45")). + Foreground(ui.ColorText). + Bold(true). + Render(" " + key + " ") + labelPart := lipgloss.NewStyle(). + Background(lipgloss.Color("#1a1d2e")). + Foreground(ui.ColorDim2). + Render(" " + label + " ") + return keyPart + labelPart +} + +// renderCurlLine applies syntax highlighting to a single line of a curl command. +func renderCurlLine(line string) string { + trimmed := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(trimmed)] + + keyword := lipgloss.NewStyle().Foreground(ui.ColorBlue).Bold(true) + flagStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple) + methodStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow).Bold(true) + urlStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + strStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + + switch { + case strings.HasPrefix(trimmed, "curl "): + // "curl -X POST \" + rest := trimmed[5:] + // rest should be "-X POST \" + parts := strings.Fields(rest) + out := keyword.Render("curl") + " " + if len(parts) >= 2 && parts[0] == "-X" { + out += flagStyle.Render("-X") + " " + methodStyle.Render(parts[1]) + if len(parts) > 2 { + out += " " + dimStyle.Render(strings.Join(parts[2:], " ")) + } + } else { + out += dimStyle.Render(rest) + } + return indent + out + case strings.HasPrefix(trimmed, "'http"): + // URL line: 'http://...' \ + idx := strings.LastIndex(trimmed, "'") + if idx > 0 { + url := trimmed[:idx+1] + suffix := strings.TrimSpace(trimmed[idx+1:]) + out := urlStyle.Render(url) + if suffix != "" { + out += " " + dimStyle.Render(suffix) + } + return indent + out + } + return indent + urlStyle.Render(trimmed) + case strings.HasPrefix(trimmed, "-H "): + // -H 'Header: value' \ + rest := trimmed[3:] + return indent + flagStyle.Render("-H") + " " + strStyle.Render(rest) + case strings.HasPrefix(trimmed, "-d "): + // -d '...' + rest := trimmed[3:] + return indent + flagStyle.Render("-d") + " " + dimStyle.Render(rest) + default: + return indent + dimStyle.Render(trimmed) + } +} + +// wrapText wraps text to fit within maxW runes per line, with a given indent prefix. +func wrapText(text string, maxW int, indent string) []string { + words := strings.Fields(text) + var lines []string + line := indent + for _, w := range words { + candidate := line + w + if line != indent { + candidate = line + " " + w + } + if len([]rune(candidate)) > maxW && line != indent { + lines = append(lines, line) + line = indent + w + } else { + line = candidate + } + } + if line != indent { + lines = append(lines, line) + } + return lines +} + +// runesTruncate truncates s to at most n runes, appending "..." if truncated. +func runesTruncate(s string, n int) string { + if n <= 1 { + return "…" + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +// truncateSidebar shortens s to n runes. +func truncateSidebar(s string, n int) string { + if n <= 0 { + return s + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n-1]) + "…" +} + +// countStatuses counts running vs stopped workloads. +func countStatuses(list []core.Workload) (running, stopped int) { + for _, w := range list { + switch w.Status { + case rt.WorkloadStatusRunning, rt.WorkloadStatusUnauthenticated, rt.WorkloadStatusUnhealthy: + running++ + case rt.WorkloadStatusStopped, rt.WorkloadStatusError, rt.WorkloadStatusStarting, + rt.WorkloadStatusStopping, rt.WorkloadStatusRemoving, rt.WorkloadStatusUnknown, + rt.WorkloadStatusPolicyStopped: + stopped++ + } + } + return +} diff --git a/pkg/tui/view_info.go b/pkg/tui/view_info.go new file mode 100644 index 0000000000..9688ad907b --- /dev/null +++ b/pkg/tui/view_info.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" +) + +// renderInfo renders key-value info for the selected workload, enriched with RunConfig. +func renderInfo(w *core.Workload, cfg *runner.RunConfig, width int) string { + _ = width + styles := infoStyles{ + dim2: lipgloss.NewStyle().Foreground(ui.ColorDim2), + text: lipgloss.NewStyle().Foreground(ui.ColorText), + dim: lipgloss.NewStyle().Foreground(ui.ColorDim), + cyan: lipgloss.NewStyle().Foreground(ui.ColorCyan), + yellow: lipgloss.NewStyle().Foreground(ui.ColorYellow), + green: lipgloss.NewStyle().Foreground(ui.ColorGreen), + } + + var lines []string + lines = append(lines, renderInfoRuntime(w, styles)...) + if cfg == nil { + lines = append(lines, "\n"+styles.dim2.Render(" Loading config…")) + return strings.Join(lines, "\n") + } + lines = append(lines, renderInfoConfig(cfg, styles)...) + return strings.Join(lines, "\n") +} + +type infoStyles struct { + dim2, text, dim, cyan, yellow, green lipgloss.Style +} + +func (s infoStyles) row(key, val string) string { + return s.dim2.Render(fmt.Sprintf(" %-14s", key)) + s.text.Render(val) +} + +func (s infoStyles) section(title string) string { + return "\n" + s.dim.Render(" "+strings.Repeat("─", 30)) + "\n" + + s.dim.Render(fmt.Sprintf(" %s", strings.ToUpper(title))) + "\n" +} + +func renderInfoRuntime(w *core.Workload, s infoStyles) []string { + lines := []string{s.section("Runtime")} + lines = append(lines, s.row("Name", w.Name)) + lines = append(lines, s.row("Status", string(w.Status))) + lines = append(lines, s.row("URL", w.URL)) + lines = append(lines, s.row("Port", fmt.Sprintf("%d", w.Port))) + lines = append(lines, s.row("Transport", string(w.TransportType))) + if w.Group != "" { + lines = append(lines, s.row("Group", w.Group)) + } + if w.Remote { + lines = append(lines, s.row("Remote", "yes")) + } + lines = append(lines, s.row("Created", w.CreatedAt.Format("2006-01-02 15:04:05"))) + return lines +} + +func renderInfoConfig(cfg *runner.RunConfig, s infoStyles) []string { + var lines []string + if cfg.Image != "" { + lines = append(lines, s.section("Image")) + lines = append(lines, s.row("Image", cfg.Image)) + } + if len(cfg.EnvVars) > 0 { + lines = append(lines, s.section("Environment")) + envKeys := make([]string, 0, len(cfg.EnvVars)) + for k := range cfg.EnvVars { + envKeys = append(envKeys, k) + } + slices.Sort(envKeys) + for _, k := range envKeys { + lines = append(lines, s.cyan.Render(fmt.Sprintf(" %-16s", k))+s.dim2.Render(cfg.EnvVars[k])) + } + } + if len(cfg.Volumes) > 0 { + lines = append(lines, s.section("Volumes")) + for _, v := range cfg.Volumes { + lines = append(lines, renderInfoVolumeLine(v, s)) + } + } + if len(cfg.Secrets) > 0 { + lines = append(lines, s.section("Secrets")) + for _, sec := range cfg.Secrets { + lines = append(lines, " "+s.yellow.Render(sec)) + } + } + if cfg.PermissionProfile != nil { + lines = append(lines, renderInfoPermissions(cfg, s)...) + } + return lines +} + +func renderInfoVolumeLine(v string, s infoStyles) string { + parts := strings.SplitN(v, ":", 3) + mode := "" + if len(parts) == 3 { + mode = " " + s.dim.Render("["+parts[2]+"]") + } + host := s.dim2.Render(fmt.Sprintf(" %-24s", parts[0])) + arrow := s.dim.Render("→ ") + var cont string + if len(parts) >= 2 { + cont = s.text.Render(parts[1]) + } + return host + arrow + cont + mode +} + +func renderInfoPermissions(cfg *runner.RunConfig, s infoStyles) []string { + lines := []string{s.section("Permissions")} + outbound := cfg.PermissionProfile.Network.Outbound + prefix := " " + s.dim2.Render("network outbound ") + switch { + case outbound.InsecureAllowAll: + lines = append(lines, prefix+s.yellow.Render("allow all")) + case len(outbound.AllowHost) > 0: + lines = append(lines, prefix+s.green.Render(strings.Join(outbound.AllowHost, ", "))) + default: + lines = append(lines, prefix+s.dim.Render("denied")) + } + return lines +} diff --git a/pkg/tui/view_inspector.go b/pkg/tui/view_inspector.go new file mode 100644 index 0000000000..5ddec4ab30 --- /dev/null +++ b/pkg/tui/view_inspector.go @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// renderInspector renders the 3-column tool inspector panel. +func (m Model) renderInspector(mainW int) string { + toolListW := 22 + remaining := mainW - toolListW - 2 // 2 for separator columns + if remaining < 20 { + remaining = 20 + } + responseW := remaining * 55 / 100 + formW := remaining - responseW - 1 + + // mainStyle Height = m.height-2; title(1)+tabBar(1)+sep(1) = 3 overhead → inspH = m.height-5 + inspH := m.height - 5 + if inspH < 5 { + inspH = 5 + } + + leftCol := m.renderInspToolList(toolListW, inspH) + middleCol := m.renderInspForm(formW, inspH) + rightCol := m.renderInspResponse(responseW, inspH) + + // Full-height vertical separators between columns. + sepStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + vline := sepStyle.Render(strings.Repeat("│\n", inspH-1) + "│") + + base := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, vline, middleCol, vline, rightCol) + + // Tool info modal overlaid on top when active. + if m.insp.showInfo { + return m.renderToolInfoModal(base, mainW, inspH) + } + return base +} + +// renderToolInfoModal renders a centered modal with the selected tool's description. +func (m Model) renderToolInfoModal(base string, w, h int) string { + filtered := m.filteredTools() + if len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + return base + } + tool := filtered[m.insp.toolIdx] + + modalW := min(w-8, 64) + innerW := modalW - 6 // padding 1,3 + + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + + sep := dimStyle.Render(strings.Repeat("─", innerW)) + desc := tool.Description + if desc == "" { + desc = "(no description available)" + } + + var sb strings.Builder + sb.WriteString(titleStyle.Render(tool.Name) + " " + hintStyle.Render("press any key to close") + "\n") + sb.WriteString(sep + "\n") + for _, line := range wrapText(desc, innerW, "") { + sb.WriteString(textStyle.Render(line) + "\n") + } + + modal := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple). + Padding(1, 3). + Width(modalW). + Render(sb.String()) + + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, + modal, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// renderInspToolList renders the left tool-list column of the inspector. +func (m Model) renderInspToolList(width, height int) string { + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", width)) + + filtered := m.filteredTools() + countStr := fmt.Sprintf("(%d)", len(m.tools)) + if m.insp.filterActive || m.insp.filterQuery != "" { + countStr = fmt.Sprintf("(%d/%d)", len(filtered), len(m.tools)) + } + header := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true). + Render("TOOLS " + countStr) + + var sb strings.Builder + sb.WriteString(header + "\n") + sb.WriteString(sep + "\n") + + if m.toolsLoading { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Loading…") + "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + if errors.Is(m.toolsErr, errStdioToolsNotAvailable) { + for _, line := range wrapText(" "+m.toolsErr.Error(), width, " ") { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(line) + "\n") + } + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + if m.toolsErr != nil { + for _, line := range wrapText(" "+m.toolsErr.Error(), width, " ") { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorRed).Render(line) + "\n") + } + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + + // Filter prompt. + if m.insp.filterActive { + prompt := lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.insp.filterQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + sb.WriteString(prompt + "\n") + } else if m.insp.filterQuery != "" { + prompt := lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(m.insp.filterQuery) + sb.WriteString(prompt + "\n") + } + + selBg := lipgloss.Color("#2a2e45") + infoIcon := lipgloss.NewStyle().Foreground(ui.ColorDim).Render("ℹ") + for i, t := range filtered { + // Reserve 2 chars for the ℹ icon on selected row. + name := truncateSidebar(t.Name, width-4) + if i == m.insp.toolIdx { + namePart := lipgloss.NewStyle(). + Foreground(ui.ColorText). + Background(selBg). + Bold(true). + Render(" " + name) + iconPart := lipgloss.NewStyle().Background(selBg).Render(" " + infoIcon) + line := lipgloss.NewStyle().Background(selBg).Width(width). + Render(namePart + iconPart) + sb.WriteString(line + "\n") + } else { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" "+name) + "\n") + } + } + if len(filtered) == 0 && !m.toolsLoading { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" no match") + "\n") + } + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderInspForm renders the middle form column of the inspector. +func (m Model) renderInspForm(width, height int) string { + filtered := m.filteredTools() + if len(filtered) == 0 { + return lipgloss.NewStyle().Width(width).Height(height). + Foreground(ui.ColorDim).Render(" No tools available") + } + if m.insp.toolIdx >= len(filtered) { + return lipgloss.NewStyle().Width(width).Height(height). + Foreground(ui.ColorDim).Render(" No tools available") + } + + tool := filtered[m.insp.toolIdx] + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", width)) + + var sb strings.Builder + // Tool name and description (capped to 2 lines; press 'i' for full description). + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorCyan).Bold(true).Render(tool.Name) + "\n") + if tool.Description != "" { + descLines := wrapText(tool.Description, width-2, "") + const maxDescLines = 2 + if len(descLines) > maxDescLines { + descLines = descLines[:maxDescLines] + descLines[maxDescLines-1] += lipgloss.NewStyle().Foreground(ui.ColorDim).Render("… [i] more") + } + for _, line := range descLines { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(line) + "\n") + } + } + sb.WriteString(sep + "\n") + + // Form fields + for i, f := range m.insp.fields { + for _, line := range renderFormFieldFromStruct(f, i == m.insp.fieldIdx, width) { + sb.WriteString(line + "\n") + } + if i < len(m.insp.fields)-1 { + sb.WriteString(sep + "\n") + } + } + + if len(m.insp.fields) == 0 { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" (no parameters)") + "\n") + } + + sb.WriteString("\n") + + // "↵ Call tool" button — left side. + callBtn := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorBlue). + Foreground(ui.ColorBlue). + Padding(0, 2). + Render("↵ Call tool") + + sb.WriteString(callBtn) + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderInspResponse renders the right response column of the inspector. +// +//nolint:gocyclo // renders all response states; splitting would scatter related view logic +func (m Model) renderInspResponse(width, height int) string { + sel := m.selected() + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + + // REQUEST header — title left, [Y] COPY CURL badge at far right. + reqTitle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render("REQUEST") + copyCurlBadge := inspCopyBadge("y", "COPY CURL") + reqGap := width - 2 - ui.VisibleLen(reqTitle) - ui.VisibleLen(copyCurlBadge) + if reqGap < 1 { + reqGap = 1 + } + sb.WriteString(reqTitle + strings.Repeat(" ", reqGap) + copyCurlBadge + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + + if ft := m.filteredTools(); sel != nil && len(ft) > 0 && m.insp.toolIdx < len(ft) { + tool := ft[m.insp.toolIdx] + // Errors ignored: curl preview is best-effort with whatever fields parse. + args, _, _ := inspFieldValues(m.insp.fields) + curl := buildCurlStr(sel, tool.Name, args) + for _, line := range strings.Split(curl, "\n") { + sb.WriteString(renderCurlLine(line) + "\n") + } + } else { + sb.WriteString(dimStyle.Render(" Type arguments and press ↵ to call") + "\n") + } + + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + sb.WriteString("\n") + + // RESPONSE header — title + status left, [C] COPY JSON badge at far right when result available. + respTitle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render("RESPONSE") + statusBadge := "" + if !m.insp.loading && m.insp.result != "" { + if m.insp.resultOK { + statusBadge = " " + lipgloss.NewStyle().Foreground(ui.ColorGreen). + Render(fmt.Sprintf("✓ SUCCESS %dms", m.insp.resultMs)) + } else { + statusBadge = " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("✗ ERROR") + } + } + copyJSONBadge := "" + if m.insp.result != "" { + copyJSONBadge = inspCopyBadge("c", "COPY JSON") + } + respLeft := respTitle + statusBadge + if copyJSONBadge != "" { + respGap := width - 2 - ui.VisibleLen(respLeft) - ui.VisibleLen(copyJSONBadge) + if respGap < 1 { + respGap = 1 + } + sb.WriteString(respLeft + strings.Repeat(" ", respGap) + copyJSONBadge + "\n") + } else { + sb.WriteString(respLeft + "\n") + } + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + + switch { + case m.insp.loading: + frame := inspSpinFrames[m.insp.spinFrame%len(inspSpinFrames)] + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorCyan).Render(frame+" Calling…") + "\n") + case m.insp.result != "" && m.insp.jsonRoot != nil: + // REQUEST header (1) + sep (1) + curl (~3) + sep (1) + RESPONSE header (1) + sep (1) = ~8 overhead. + // Subtract additional log section height if log lines are present. + const treeHeaderOverhead = 8 + const logSectionHeight = 9 // blank + sep + LOGS + 6 log lines + treeH := height - treeHeaderOverhead + if len(m.insp.logLines) > 0 { + treeH -= logSectionHeight + } + if treeH < 3 { + treeH = 3 + } + sb.WriteString(renderJSONTree(m.insp.treeVis, m.insp.treeCursor, m.insp.treeScroll, width, treeH)) + case m.insp.result != "": + sb.WriteString(m.insp.respView.View()) + default: + sb.WriteString(dimStyle.Render(" Response will appear here") + "\n") + } + + // LOGS section — shown below the response whenever there are TUI log messages. + if len(m.insp.logLines) > 0 { + sb.WriteString("\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorYellow).Bold(true).Render("LOGS") + "\n") + sb.WriteString(m.insp.logView.View()) + } + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderTools renders the tools list for the selected workload using the toolsView viewport. +func (m Model) renderTools(_ int) string { + if m.selected() == nil { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + if m.toolsLoading { + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Loading tools…") + } + if errors.Is(m.toolsErr, errStdioToolsNotAvailable) { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" " + m.toolsErr.Error()) + } + if m.toolsErr != nil { + return lipgloss.NewStyle().Foreground(ui.ColorRed).Render(" Error: " + m.toolsErr.Error()) + } + if len(m.tools) == 0 { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" No tools available") + } + return m.toolsView.View() +} + +// buildToolsContent builds the full scrollable content string for the tools viewport. +// selectedIdx highlights the currently selected tool (-1 for none). +func buildToolsContent(tools []mcp.Tool, width, selectedIdx int) string { + nameW := 28 + descW := width - nameW - 4 + if descW < 20 { + descW = 20 + } + + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + countStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + selBg := lipgloss.NewStyle().Background(lipgloss.Color("#2a2f45")) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + sb.WriteString(" " + countStyle.Render(fmt.Sprintf("%d tools", len(tools)))) + sb.WriteString(" " + hintStyle.Render("↵ open in inspector")) + sb.WriteString("\n\n") + + for i, t := range tools { + name := truncateSidebar(t.Name, nameW) + namePart := " " + ui.PadToWidth(nameStyle.Render(name), nameW+2) + + var lines []string + if t.Description != "" { + lines = wrapText(t.Description, descW, "") + } + + selected := i == selectedIdx + renderLine := func(s string) string { + if selected { + return selBg.Width(width - 2).Render(s) + } + return s + } + + if len(lines) == 0 { + sb.WriteString(renderLine(namePart) + "\n") + continue + } + for j, line := range lines { + if j == 0 { + sb.WriteString(renderLine(namePart+descStyle.Render(line)) + "\n") + } else { + indent := strings.Repeat(" ", nameW+4) + sb.WriteString(renderLine(indent+descStyle.Render(line)) + "\n") + } + } + } + return sb.String() +} diff --git a/pkg/tui/view_registry.go b/pkg/tui/view_registry.go new file mode 100644 index 0000000000..d262f58bcc --- /dev/null +++ b/pkg/tui/view_registry.go @@ -0,0 +1,422 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// registryBoxDims returns the shared overlay dimensions. +func (m Model) registryBoxDims() (boxW, innerW, visibleRows int) { + boxW = max(m.width*80/100, 50) + innerW = boxW - 4 // border (1 each side) + padding (1 each side) + visibleRows = max(m.height*70/100, 6) + return +} + +// renderRegistryOverlay renders the registry browser overlay. +// It delegates to the run form or detail view as appropriate. +func (m Model) renderRegistryOverlay() string { + if m.runForm.open { + return m.renderRunFormOverlay() + } + if m.registry.detail { + return m.renderRegistryDetailOverlay() + } + return m.renderRegistryListOverlay() +} + +// renderRegistryListOverlay renders the searchable list of registry items. +func (m Model) renderRegistryListOverlay() string { + items := m.filteredRegistryItems() + boxW, innerW, visibleRows := m.registryBoxDims() + + // Layout: header(1) + sep(1) + filter(1) + sep(1) + footer-sep(1) + footer(1) + border/pad(4) + const fixedLines = 10 + itemRows := max(visibleRows-fixedLines, 2) + + // Column widths: 2-space indent + name(nameW) + 2-space gap + desc + 2-space gap + tag(tagW) + const tagColW = 10 // " " + up to 8 chars + nameW := max(innerW*28/100, 16) + descW := max(innerW-2-nameW-2-tagColW, 10) // innerW = 2+nameW+2+descW+tagColW + + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + tagStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + selBg := lipgloss.NewStyle().Background(lipgloss.Color("#2a2e45")) + + var sb strings.Builder + + // Header + sb.WriteString(titleStyle.Render("REGISTRY") + + " " + hintStyle.Render("↑↓ navigate enter detail esc close type to filter") + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + + // Filter line + sb.WriteString(dimStyle.Render(" ⌕ ") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.registry.filter) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + + // Items + switch { + case m.registry.loading: + sb.WriteString("\n " + dimStyle.Render("Loading registry…") + "\n") + case m.registry.err != nil: + sb.WriteString("\n " + lipgloss.NewStyle().Foreground(ui.ColorRed). + Render("Error: "+m.registry.err.Error()) + "\n") + case len(items) == 0: + sb.WriteString("\n " + dimStyle.Render("No servers found") + "\n") + default: + end := min(m.registry.scrollOff+itemRows, len(items)) + for i, item := range items[m.registry.scrollOff:end] { + globalIdx := m.registry.scrollOff + i + name := truncateSidebar(item.GetName(), nameW) + desc := runesTruncate(item.GetDescription(), descW) + tagStr := registryTagStr(item.GetTags(), tagStyle) + + line := " " + ui.PadToWidth(nameStyle.Render(name), nameW+2) + + ui.PadToWidth(descStyle.Render(desc), descW+2) + tagStr + if globalIdx == m.registry.idx { + line = selBg.Width(innerW).Render(line) + } + sb.WriteString(line + "\n") + } + if len(items) > itemRows { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d–%d / %d", + m.registry.scrollOff+1, end, len(items))) + "\n") + } + } + + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d servers available", len(m.registry.items)))) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// renderRegistryDetailOverlay renders the full detail view for the selected registry item. +func (m Model) renderRegistryDetailOverlay() string { + boxW, innerW, visibleRows := m.registryBoxDims() + items := m.filteredRegistryItems() + if len(items) == 0 || m.registry.idx >= len(items) { + return m.renderRegistryListOverlay() + } + item := items[m.registry.idx] + + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", innerW)) + lines := buildDetailLines(item, innerW, sep) + + const headerLines = 2 + contentLines := visibleRows - headerLines - 2 + total := len(lines) + scrollOff := min(m.registry.detailScroll, max(total-contentLines, 0)) + end := min(scrollOff+contentLines, total) + + var sb strings.Builder + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + breadcrumb := titleStyle.Render("REGISTRY") + dimStyle.Render(" / ") + + textStyle.Bold(true).Render(item.GetName()) + sb.WriteString(breadcrumb + " " + hintStyle.Render("↑↓ scroll r=run y=copy cmd esc back") + "\n") + sb.WriteString(sep + "\n") + for _, l := range lines[scrollOff:end] { + sb.WriteString(l + "\n") + } + if total > contentLines { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d lines", scrollOff+end-scrollOff, total))) + } + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// registryTagStr formats the first tag for the list column. +func registryTagStr(tags []string, style lipgloss.Style) string { + if len(tags) == 0 { + return "" + } + t := tags[0] + if len([]rune(t)) > 8 { + t = string([]rune(t)[:7]) + "…" + } + return style.Render(t) +} + +// buildDetailLines builds the scrollable content lines for a registry item detail view. +func buildDetailLines(item regtypes.ServerMetadata, innerW int, sep string) []string { + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + labelStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + cyanStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + greenStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + yellowStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow) + + detailRow := func(key, val string) string { + return labelStyle.Render(fmt.Sprintf(" %-14s", key)) + textStyle.Render(val) + } + section := func(title string) string { + return "\n" + sep + "\n" + dimStyle.Render(" "+strings.ToUpper(title)) + } + + var lines []string + lines = append(lines, buildDetailHeader(item, dimStyle, labelStyle, textStyle, greenStyle, yellowStyle, cyanStyle)...) + + if desc := item.GetDescription(); desc != "" { + lines = append(lines, section("Description")) + lines = append(lines, wrapText(desc, innerW-4, " ")...) + } + if repo := item.GetRepositoryURL(); repo != "" { + lines = append(lines, section("Repository")) + lines = append(lines, detailRow("URL", repo)) + } + lines = append(lines, buildDetailServerType(item, section, detailRow)...) + lines = append(lines, buildDetailTools(item, innerW, section, cyanStyle, labelStyle)...) + lines = append(lines, "\n"+sep) + return lines +} + +// buildDetailHeader returns the name/tier/status/transport/meta lines. +func buildDetailHeader( + item regtypes.ServerMetadata, + dimStyle, labelStyle, textStyle, greenStyle, yellowStyle, cyanStyle lipgloss.Style, +) []string { + meta := item.GetMetadata() + starsStr := "" + if meta != nil && meta.Stars > 0 { + starsStr = " " + dimStyle.Render(fmt.Sprintf("★ %d", meta.Stars)) + } + lastUpdStr := "" + if meta != nil && meta.LastUpdated != "" { + lastUpdStr = " " + dimStyle.Render("updated "+meta.LastUpdated[:min(len(meta.LastUpdated), 10)]) + } + + tierStr := func() string { + switch item.GetTier() { + case "Official": + return greenStyle.Render("Official") + case "": + return "" + default: + return yellowStyle.Render(item.GetTier()) + } + }() + + lines := []string{"\n" + textStyle.Bold(true).Render(" "+item.GetName()) + starsStr + lastUpdStr} + badge := buildBadge(tierStr, item.GetStatus(), item.GetTransport(), dimStyle, labelStyle, cyanStyle) + if badge != "" { + lines = append(lines, " "+badge) + } + return lines +} + +// buildBadge joins tier/status/transport with "·" separators. +func buildBadge(tier, status, transport string, dimStyle, labelStyle, cyanStyle lipgloss.Style) string { + dot := dimStyle.Render(" · ") + var parts []string + if tier != "" { + parts = append(parts, tier) + } + if status != "" { + parts = append(parts, labelStyle.Render(status)) + } + if transport != "" { + parts = append(parts, cyanStyle.Render(transport)) + } + return strings.Join(parts, dot) +} + +// buildDetailServerType appends container image or remote URL section if present. +func buildDetailServerType( + item regtypes.ServerMetadata, + section func(string) string, + detailRow func(string, string) string, +) []string { + var lines []string + switch v := item.(type) { + case interface{ GetImage() string }: + if img := v.GetImage(); img != "" { + lines = append(lines, section("Container")) + lines = append(lines, detailRow("Image", img)) + } + case interface{ GetURL() string }: + if u := v.GetURL(); u != "" { + lines = append(lines, section("Endpoint")) + lines = append(lines, detailRow("URL", u)) + } + } + return lines +} + +// buildDetailTools appends the tools section (with descriptions if available). +func buildDetailTools( + item regtypes.ServerMetadata, + innerW int, + section func(string) string, + cyanStyle, labelStyle lipgloss.Style, +) []string { + const toolNameColW = 32 + var lines []string + if toolDefs := item.GetToolDefinitions(); len(toolDefs) > 0 { + lines = append(lines, section(fmt.Sprintf("Tools (%d)", len(toolDefs)))) + for _, t := range toolDefs { + nameRunes := []rune(t.Name) + if len(nameRunes) <= toolNameColW { + desc := runesTruncate(t.Description, innerW-toolNameColW-4) + lines = append(lines, " "+ui.PadToWidth(cyanStyle.Render(t.Name), toolNameColW+2)+labelStyle.Render(desc)) + } else { + // Name is long: put description on the next indented line. + lines = append(lines, " "+cyanStyle.Render(t.Name)) + if t.Description != "" { + lines = append(lines, " "+labelStyle.Render(runesTruncate(t.Description, innerW-6))) + } + } + } + } else if toolNames := item.GetTools(); len(toolNames) > 0 { + lines = append(lines, section(fmt.Sprintf("Tools (%d)", len(toolNames)))) + for _, t := range toolNames { + lines = append(lines, " "+cyanStyle.Render(t)) + } + } + return lines +} + +// renderRunFormOverlay renders the run-from-registry form overlay. +func (m Model) renderRunFormOverlay() string { + boxW, innerW, visibleRows := m.registryBoxDims() + item := m.runForm.item + + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + greenStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + sep := dimStyle.Render(strings.Repeat("─", innerW)) + + var sb strings.Builder + + // Breadcrumb header. + breadcrumb := titleStyle.Render("REGISTRY") + dimStyle.Render(" / ") + + textStyle.Bold(true).Render(item.GetName()) + dimStyle.Render(" / ") + + titleStyle.Render("RUN") + hint := "tab=next enter=run esc=cancel" + if m.runForm.running { + hint = "starting…" + } + sb.WriteString(breadcrumb + " " + hintStyle.Render(hint) + "\n") + sb.WriteString(sep + "\n") + + // Form fields (scrollable). + const linesPerField = 4 // label + desc + input + gap + const headerFooterLines = 5 + maxFields := max((visibleRows-headerFooterLines)/linesPerField, 2) + endIdx := min(m.runForm.scroll+maxFields, len(m.runForm.fields)) + + for i := m.runForm.scroll; i < endIdx; i++ { + f := m.runForm.fields[i] + focused := i == m.runForm.idx + lines := renderFormFieldFromStruct(f, focused, innerW) + for _, l := range lines { + sb.WriteString(l + "\n") + } + sb.WriteString("\n") + } + + if len(m.runForm.fields) > maxFields { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" field %d/%d", m.runForm.idx+1, len(m.runForm.fields))) + "\n") + } + + // Run button. + sb.WriteString(sep + "\n") + btnLabel := " ▶ Run " + item.GetName() + if m.runForm.running { + btnLabel = " ⟳ Starting…" + } + sb.WriteString(greenStyle.Bold(true).Render(btnLabel)) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// buildRunCmd builds a suggested `thv run` command string from registry metadata. +// Required env vars become --secret flags; optional ones are shown as comments. +// Non-default transport and permission profile are included when present. +// All dynamic values are single-quoted and escaped to prevent shell injection +// when the user pastes the copied command into a terminal. +func buildRunCmd(item regtypes.ServerMetadata) string { + const defaultTransport = "streamable-http" + + sq := func(s string) string { + return "'" + shellEscapeSingleQuote(s) + "'" + } + + var sb strings.Builder + sb.WriteString("thv run ") + sb.WriteString(sq(item.GetName())) + + // Transport only when non-default. + if t := item.GetTransport(); t != "" && t != defaultTransport { + sb.WriteString(" --transport ") + sb.WriteString(sq(t)) + } + + // Permission profile from ImageMetadata (Permissions is a direct field, not on the interface). + if img, ok := item.(*regtypes.ImageMetadata); ok && img != nil && img.Permissions != nil { + if name := img.Permissions.Name; name != "" && name != "none" { + sb.WriteString(" --permission-profile ") + sb.WriteString(sq(name)) + } + } + + // Required env vars → --secret (references a named secret already + // stored in the secrets manager); optional → comment line. + // Note: the run form uses --env for literal values entered by the user; + // this clipboard command is intended for users who manage secrets via + // `thv secret` and want a ready-to-paste shell invocation. + var optional []string + for _, ev := range item.GetEnvVars() { + if ev == nil { + continue + } + if ev.Required { + sb.WriteString(" --secret ") + sb.WriteString(sq(ev.Name)) + } else { + optional = append(optional, ev.Name) + } + } + for _, name := range optional { + sb.WriteString("\n# optional: --env ") + sb.WriteString(sq(name)) + sb.WriteString("=") + } + + return sb.String() +} diff --git a/pkg/tui/view_statusbar.go b/pkg/tui/view_statusbar.go new file mode 100644 index 0000000000..e12daccf5d --- /dev/null +++ b/pkg/tui/view_statusbar.go @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// renderStatusBar renders the bottom 2-line help bar (separator + key hints). +// +//nolint:gocyclo // renders all status-bar states per panel; helper extraction done in separate funcs +func (m Model) renderStatusBar() string { + const statusBg = lipgloss.Color("#1e2030") + const badgeBg = lipgloss.Color("#2a2f45") + + // badge renders a key name with a contrasting background box. + // We use manual spaces instead of Padding to keep measurement predictable. + badge := func(k string) string { + return lipgloss.NewStyle(). + Background(badgeBg). + Foreground(ui.ColorText). + Render(" " + k + " ") + } + hint := func(k, desc string) string { + b := badge(k) + d := lipgloss.NewStyle().Foreground(ui.ColorDim2).Background(statusBg).Render(" " + desc) + return b + d + } + spacer := " " // plain spaces between hints (carry the statusBg from outer render) + + // Separator line — Width(m.width) ensures background fills the entire row. + sepLine := lipgloss.NewStyle(). + Width(m.width). + Background(statusBg). + Foreground(ui.ColorDim2). + Render(strings.Repeat("─", m.width)) + + // Confirmation prompt takes over the content line. + if m.confirmDelete { + if sel := m.selected(); sel != nil { + warn := lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render("Delete " + sel.Name + "?") + info := lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Press d to confirm, any other key to cancel") + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg).Render(" " + warn + info) + return sepLine + "\n" + contentLine + } + } + + // When log search prompt is open, show dedicated search hints. + if m.logSearchActive || m.proxyLogSearchActive { + parts := []string{ + hint("↵", "confirm"), + hint("n", "next"), + hint("N", "prev"), + hint("esc", "clear"), + } + hints := " " + strings.Join(parts, spacer) + gap := m.width - ui.VisibleLen(hints) + if gap < 1 { + gap = 1 + } + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg). + Render(hints + strings.Repeat(" ", gap)) + return sepLine + "\n" + contentLine + } + + // Context-sensitive hints based on active panel. + var parts []string + switch m.panel { + case panelInspector: + upDownDesc := func() string { + if m.insp.jsonRoot != nil { + return "tree" + } + if m.insp.result != "" { + return "scroll" + } + return "tool" + }() + parts = []string{ + hint("↑↓", upDownDesc), + hint("tab", "panel"), + hint("↵", "field/call"), + hint("y", "copy curl"), + hint("esc", "back"), + hint("q", "quit"), + } + if m.insp.filterActive { + parts = []string{ + hint("↑↓", "navigate"), + hint("↵", "confirm"), + hint("esc", "clear filter"), + } + } else if m.insp.jsonRoot != nil { + parts = []string{ + hint("↑↓", "tree"), + hint("space", "fold"), + hint("c", "copy JSON"), + hint("y", "copy curl"), + hint("/", "filter tools"), + hint("tab", "panel"), + hint("↵", "field/call"), + hint("esc", "back"), + hint("q", "quit"), + } + } else { + parts = append(parts, hint("i", "tool info")) + parts = append(parts, hint("/", "filter tools")) + } + case panelLogs: + parts = m.renderStatusBarLogHints(hint) + case panelProxyLogs: + parts = m.renderStatusBarProxyLogHints(hint) + case panelInfo, panelTools: + parts = renderStatusBarDefaultHints(hint) + } + + hints := " " + strings.Join(parts, spacer) + + // Notification — right-aligned, shown only when non-empty. + notif := "" + if m.notifMsg != "" { + notifColor := ui.ColorGreen + if !m.notifOK { + notifColor = ui.ColorRed + } + notif = lipgloss.NewStyle(). + Foreground(notifColor). + Background(statusBg). + Render(m.notifMsg + " ") + } + + // Pad hints to fill the gap so notif lands at the far right. + hintsLen := ui.VisibleLen(hints) + notifLen := ui.VisibleLen(notif) + gap := m.width - hintsLen - notifLen + if gap < 1 { + gap = 1 + } + content := hints + strings.Repeat(" ", gap) + notif + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg).Render(content) + return sepLine + "\n" + contentLine +} + +// renderStatusBarDefaultHints returns the default status bar hints for panels +// that do not have specialized key bindings (Info, Tools). +func renderStatusBarDefaultHints(hint func(k, desc string) string) []string { + return []string{ + hint("↑↓", "navigate"), + hint("tab", "panel"), + hint("s", "stop"), + hint("r", "restart"), + hint("d", "delete"), + hint("u", "copy URL"), + hint("R", "registry"), + hint("/", "filter"), + hint("?", "help"), + hint("q", "quit"), + } +} + +// renderStatusBarLogHints returns the status bar hints for the Logs panel, +// switching to search-navigation hints when a search is active. +func (m Model) renderStatusBarLogHints(hint func(k, desc string) string) []string { + if m.logSearchQuery != "" { + return []string{ + hint("n", "next match"), + hint("N", "prev match"), + hint("esc", "clear search"), + hint("/", "new search"), + hint("q", "quit"), + } + } + return renderStatusBarDefaultHints(hint) +} + +// renderStatusBarProxyLogHints returns the status bar hints for the Proxy Logs panel, +// switching to search-navigation hints when a search is active. +func (m Model) renderStatusBarProxyLogHints(hint func(k, desc string) string) []string { + if m.proxyLogSearchQuery != "" { + return []string{ + hint("n", "next match"), + hint("N", "prev match"), + hint("esc", "clear search"), + hint("/", "new search"), + hint("q", "quit"), + } + } + return renderStatusBarDefaultHints(hint) +} + +// renderHelpOverlay renders the help modal centred on the terminal. +func (m Model) renderHelpOverlay() string { + helpContent := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple). + Padding(1, 3). + Width(60). + Render(helpText()) + + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + helpContent, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) + "\n(press any key to close)" +} + +func helpText() string { + bind := func(k, desc string) string { + key := lipgloss.NewStyle().Foreground(ui.ColorCyan).Render(fmt.Sprintf("%-16s", k)) + d := lipgloss.NewStyle().Foreground(ui.ColorText).Render(desc) + return key + d + } + heading := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true).Render + + lines := []string{ + heading("Navigation"), + bind("↑/k ↓/j", "select server"), + bind("tab", "switch panel (Logs/Info/Tools/Proxy/Inspector)"), + bind("/", "filter server list"), + "", + heading("Actions"), + bind("s", "stop selected server"), + bind("r", "restart selected server"), + bind("d d", "delete (press d twice to confirm)"), + bind("u", "copy server URL to clipboard"), + bind("R", "open registry browser"), + "", + heading("Logs panel"), + bind("f", "toggle follow mode"), + bind("/", "open inline search"), + bind("n / N", "next / previous search match"), + bind("esc", "clear search highlights"), + bind("← →", "scroll horizontally"), + "", + heading("Proxy Logs panel"), + bind("/", "open inline search"), + bind("n / N", "next / previous search match"), + bind("esc", "clear search highlights"), + bind("← →", "scroll horizontally"), + "", + heading("Inspector panel"), + bind("↑/↓", "navigate tools / JSON tree"), + bind("/", "filter tools by name"), + bind("↵", "call selected tool"), + bind("space", "collapse / expand JSON node"), + bind("c", "copy response to clipboard"), + bind("y", "copy curl command to clipboard"), + "", + heading("Other"), + bind("?", "toggle this help"), + bind("q / ctrl+c", "quit"), + } + return strings.Join(lines, "\n") +} diff --git a/pkg/updates/client.go b/pkg/updates/client.go index 674b793788..7a4750f205 100644 --- a/pkg/updates/client.go +++ b/pkg/updates/client.go @@ -55,6 +55,12 @@ const ( buildTypeLocalBuild = "local_build" ) +// EnvVarSkipUpdateCheck disables update checks when set to "true" +// (case-insensitive). Disabling update checks also disables update-derived +// usage metrics, because usagemetrics.ShouldEnableMetrics gates on +// ShouldSkipUpdateChecks. +const EnvVarSkipUpdateCheck = "TOOLHIVE_SKIP_UPDATE_CHECK" + // ciEnvVars contains environment variables that indicate CI environments var ciEnvVars = []string{ "GITHUB_ACTIONS", @@ -153,8 +159,12 @@ func (d *defaultVersionClient) GetComponent() string { } // ShouldSkipUpdateChecks returns true if update checks should be skipped. -// This includes CI environments and other scenarios where automated update checking is undesirable. +// This includes CI environments, an explicit opt-out via TOOLHIVE_SKIP_UPDATE_CHECK, +// and other scenarios where automated update checking is undesirable. func ShouldSkipUpdateChecks() bool { + if strings.EqualFold(os.Getenv(EnvVarSkipUpdateCheck), "true") { + return true + } // Check if running in any known CI environment for _, envVar := range ciEnvVars { if os.Getenv(envVar) != "" { diff --git a/pkg/updates/client_test.go b/pkg/updates/client_test.go index 11a26b9e7d..e73096487e 100644 --- a/pkg/updates/client_test.go +++ b/pkg/updates/client_test.go @@ -67,3 +67,31 @@ func TestNewVersionClientForComponent(t *testing.T) { }) } } + +func TestShouldSkipUpdateChecks_SkipEnvVar(t *testing.T) { + // Not parallel: mutates process environment via t.Setenv, including the + // CI variables that other tests in this binary may also rely on. + tests := []struct { + name string + value string + wantSet bool + }{ + {name: "unset", value: "", wantSet: false}, + {name: "true", value: "true", wantSet: true}, + {name: "TRUE uppercase", value: "TRUE", wantSet: true}, + {name: "false does not skip", value: "false", wantSet: false}, + {name: "unrecognized value does not skip", value: "maybe", wantSet: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear CI vars so the opt-out path is exercised in isolation, + // independent of whether the test runs under GitHub Actions. + for _, v := range ciEnvVars { + t.Setenv(v, "") + } + t.Setenv(EnvVarSkipUpdateCheck, tt.value) + assert.Equal(t, tt.wantSet, ShouldSkipUpdateChecks()) + }) + } +} diff --git a/pkg/vmcp/aggregator/advertised_backendid_test.go b/pkg/vmcp/aggregator/advertised_backendid_test.go new file mode 100644 index 0000000000..6931e55332 --- /dev/null +++ b/pkg/vmcp/aggregator/advertised_backendid_test.go @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package aggregator + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/mocks" +) + +// This file pins the BackendID population contract committed to by RFC THV-0076: +// every advertised Tool, Resource, and Prompt must carry a non-empty BackendID, +// across all conflict-resolution strategies and advertising-filter modes. BackendID +// is the logical, safe-to-expose backend identifier that decorators see and that +// LookupTool/CallTool rely on to route renamed/prefixed names back to their backend. +// +// These are contract tests: production code already populates BackendID at every +// resolver and merge site, and the backend client populates it on resources/prompts. +// The assertions below guard against a future regression silently shipping an empty +// BackendID, which would break logical-backend routing and decorator visibility. + +// assertAdvertisedBackendIDs asserts the BackendID invariant on every advertised +// capability returned by the aggregator. +func assertAdvertisedBackendIDs(t *testing.T, result *AggregatedCapabilities) { + t.Helper() + for _, tool := range result.Tools { + assert.NotEmptyf(t, tool.BackendID, + "advertised tool %q must carry a non-empty BackendID", tool.Name) + } + for _, res := range result.Resources { + assert.NotEmptyf(t, res.BackendID, + "advertised resource %q must carry a non-empty BackendID", res.URI) + } + for _, prompt := range result.Prompts { + assert.NotEmptyf(t, prompt.BackendID, + "advertised prompt %q must carry a non-empty BackendID", prompt.Name) + } +} + +// resolverForStrategy builds the conflict resolver matching the given strategy. +// For the manual strategy, the same workload configs that drive the per-backend +// overrides are fed to the resolver constructor. +func resolverForStrategy( + t *testing.T, + strategy vmcp.ConflictResolutionStrategy, + priorityOrder []string, + wlConfigs []*config.WorkloadToolConfig, +) ConflictResolver { + t.Helper() + switch strategy { + case vmcp.ConflictStrategyPrefix: + return NewPrefixConflictResolver("{workload}_") + case vmcp.ConflictStrategyPriority: + r, err := NewPriorityConflictResolver(priorityOrder) + require.NoError(t, err) + return r + case vmcp.ConflictStrategyManual: + r, err := NewManualConflictResolver(wlConfigs) + require.NoError(t, err) + return r + default: + t.Fatalf("unknown conflict strategy %q", strategy) + return nil + } +} + +// twoBackendCaps returns two backends that share a conflicting tool ("fetch"), +// each with a unique tool plus a resource and a prompt. This forces conflict +// resolution to rename/prefix/drop while exercising resources and prompts. +func twoBackendCaps() ([]vmcp.Backend, map[string]*vmcp.CapabilityList) { + backends := []vmcp.Backend{ + newTestBackend("backend1", withBackendName("Backend 1")), + newTestBackend("backend2", withBackendName("Backend 2")), + } + capsByID := map[string]*vmcp.CapabilityList{ + "backend1": newTestCapabilityList( + withTools(newTestTool("fetch", "backend1"), newTestTool("tool_a", "backend1")), + withResources(newTestResource("res://a", "backend1")), + withPrompts(newTestPrompt("prompt_a", "backend1")), + ), + "backend2": newTestCapabilityList( + withTools(newTestTool("fetch", "backend2"), newTestTool("tool_b", "backend2")), + withResources(newTestResource("res://b", "backend2")), + withPrompts(newTestPrompt("prompt_b", "backend2")), + ), + } + return backends, capsByID +} + +// expectListCapabilities wires the mock to return per-backend capabilities keyed +// by the queried backend ID (QueryAllCapabilities runs queries in parallel, so the +// call order is non-deterministic). +func expectListCapabilities(mockClient *mocks.MockBackendClient, capsByID map[string]*vmcp.CapabilityList) { + mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, target *vmcp.BackendTarget) (*vmcp.CapabilityList, error) { + caps, ok := capsByID[target.WorkloadID] + if !ok { + return nil, fmt.Errorf("unexpected backend %q", target.WorkloadID) + } + return caps, nil + }).Times(len(capsByID)) +} + +func findAdvertisedTool(tools []vmcp.Tool, name string) (vmcp.Tool, bool) { + for _, tool := range tools { + if tool.Name == name { + return tool, true + } + } + return vmcp.Tool{}, false +} + +// renameExpectation describes a renamed/prefixed advertised tool and how its +// advertised name must resolve back to the backend's original capability name. +type renameExpectation struct { + backendID string // expected BackendID on the advertised tool + routing target + backendCapabilityName string // expected GetBackendCapabilityName(advertisedName) +} + +func TestDefaultAggregator_AdvertisedCapabilitiesCarryBackendID(t *testing.T) { + t.Parallel() + + // Manual strategy resolves the "fetch" conflict via per-backend overrides + // applied before conflict resolution; the same configs drive the resolver. + manualConfigs := []*config.WorkloadToolConfig{ + {Workload: "backend1", Overrides: map[string]*config.ToolOverride{"fetch": {Name: "custom_fetch_b1"}}}, + {Workload: "backend2", Overrides: map[string]*config.ToolOverride{"fetch": {Name: "custom_fetch_b2"}}}, + } + + tests := []struct { + name string + strategy vmcp.ConflictResolutionStrategy + priorityOrder []string + wlConfigs []*config.WorkloadToolConfig + // renameChecks asserts the renamed/prefixed routing path used by + // LookupTool/CallTool: advertised name -> backend's original name. + renameChecks map[string]renameExpectation + }{ + { + name: "prefix strategy prefixes conflicting and unique tools", + strategy: vmcp.ConflictStrategyPrefix, + renameChecks: map[string]renameExpectation{ + "backend1_fetch": {backendID: "backend1", backendCapabilityName: "fetch"}, + "backend2_fetch": {backendID: "backend2", backendCapabilityName: "fetch"}, + }, + }, + { + name: "priority strategy keeps the highest-priority backend", + strategy: vmcp.ConflictStrategyPriority, + priorityOrder: []string{"backend1", "backend2"}, + // Priority does not rename; "fetch" resolves to itself on backend1. + renameChecks: map[string]renameExpectation{ + "fetch": {backendID: "backend1", backendCapabilityName: "fetch"}, + }, + }, + { + name: "manual strategy renames conflicts via overrides", + strategy: vmcp.ConflictStrategyManual, + wlConfigs: manualConfigs, + renameChecks: map[string]renameExpectation{ + "custom_fetch_b1": {backendID: "backend1", backendCapabilityName: "fetch"}, + "custom_fetch_b2": {backendID: "backend2", backendCapabilityName: "fetch"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + backends, capsByID := twoBackendCaps() + mockClient := mocks.NewMockBackendClient(ctrl) + expectListCapabilities(mockClient, capsByID) + + // aggregationConfig carries the manual overrides so that + // processBackendTools renames tools and actualBackendCapabilityName + // can reverse the rename for routing. nil for prefix/priority. + var aggCfg *config.AggregationConfig + if len(tt.wlConfigs) > 0 { + aggCfg = &config.AggregationConfig{Tools: tt.wlConfigs} + } + resolver := resolverForStrategy(t, tt.strategy, tt.priorityOrder, tt.wlConfigs) + + agg := NewDefaultAggregator(mockClient, resolver, aggCfg, nil) + result, err := agg.AggregateCapabilities(context.Background(), backends) + require.NoError(t, err) + require.NotNil(t, result) + + // Core invariant: every advertised capability carries a BackendID. + assertAdvertisedBackendIDs(t, result) + + // Resources and prompts are never filtered, so all are advertised. + require.Len(t, result.Resources, 2) + require.Len(t, result.Prompts, 2) + + // Renamed/prefixed names must resolve back to the backend's original + // capability name, and the advertised tool's BackendID must agree with + // the routing target's backend. + for advertisedName, want := range tt.renameChecks { + tool, ok := findAdvertisedTool(result.Tools, advertisedName) + require.Truef(t, ok, "expected advertised tool %q", advertisedName) + assert.Equal(t, want.backendID, tool.BackendID, + "advertised tool %q BackendID", advertisedName) + + target := result.RoutingTable.Tools[advertisedName] + require.NotNilf(t, target, "routing target for %q", advertisedName) + assert.Equal(t, want.backendID, target.WorkloadID, + "routing target %q WorkloadID", advertisedName) + assert.Equal(t, want.backendCapabilityName, target.GetBackendCapabilityName(advertisedName), + "GetBackendCapabilityName(%q) must resolve to the backend's original name", advertisedName) + } + }) + } +} + +func TestDefaultAggregator_AdvertisingFilterPreservesBackendID(t *testing.T) { + t.Parallel() + + // Single backend with two tools plus a resource and prompt. Prefix strategy + // is used so advertised tool names are deterministic ("be_"). + backendID := "be" + newCaps := func() map[string]*vmcp.CapabilityList { + return map[string]*vmcp.CapabilityList{ + backendID: newTestCapabilityList( + withTools(newTestTool("tool_a", backendID), newTestTool("tool_b", backendID)), + withResources(newTestResource("res://x", backendID)), + withPrompts(newTestPrompt("prompt_x", backendID)), + ), + } + } + + tests := []struct { + name string + aggCfg *config.AggregationConfig + wantAdvertised []string // resolved (prefixed) advertised tool names + }{ + { + name: "global ExcludeAllTools hides every tool", + aggCfg: &config.AggregationConfig{ExcludeAllTools: true}, + wantAdvertised: nil, + }, + { + name: "per-workload ExcludeAll hides the workload's tools", + aggCfg: &config.AggregationConfig{ + Tools: []*config.WorkloadToolConfig{{Workload: backendID, ExcludeAll: true}}, + }, + wantAdvertised: nil, + }, + { + name: "per-workload Filter advertises only matching tools", + aggCfg: &config.AggregationConfig{ + Tools: []*config.WorkloadToolConfig{{Workload: backendID, Filter: []string{"tool_a"}}}, + }, + wantAdvertised: []string{"be_tool_a"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + capsByID := newCaps() + mockClient := mocks.NewMockBackendClient(ctrl) + expectListCapabilities(mockClient, capsByID) + + agg := NewDefaultAggregator(mockClient, NewPrefixConflictResolver("{workload}_"), tt.aggCfg, nil) + result, err := agg.AggregateCapabilities(context.Background(), []vmcp.Backend{ + newTestBackend(backendID, withBackendName("Backend")), + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Every advertised capability (the post-filter subset) carries a BackendID. + assertAdvertisedBackendIDs(t, result) + + advertisedNames := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + advertisedNames = append(advertisedNames, tool.Name) + } + assert.ElementsMatch(t, tt.wantAdvertised, advertisedNames, + "advertised tool set must match the filter mode") + + // The advertising filter affects advertising only, not routing: every + // tool stays in the routing table with its backend identity intact. + require.NotNil(t, result.RoutingTable) + assert.Len(t, result.RoutingTable.Tools, 2, + "routing table must retain all tools regardless of advertising filter") + for name, target := range result.RoutingTable.Tools { + assert.Equalf(t, backendID, target.WorkloadID, + "routing target %q must retain its backend identity", name) + } + + // Resources and prompts are not gated by the advertising filter. + require.Len(t, result.Resources, 1) + require.Len(t, result.Prompts, 1) + }) + } +} + +func TestDefaultAggregator_ProcessPreQueriedCapabilitiesCarryBackendID(t *testing.T) { + t.Parallel() + + newTarget := func(id string) *vmcp.BackendTarget { + return &vmcp.BackendTarget{ + WorkloadID: id, + WorkloadName: id + "-name", + BaseURL: "http://" + id + ":8080", + TransportType: "streamable-http", + HealthStatus: vmcp.BackendHealthy, + } + } + + manualConfigs := []*config.WorkloadToolConfig{ + {Workload: "backend1", Overrides: map[string]*config.ToolOverride{"fetch": {Name: "custom_fetch_b1"}}}, + {Workload: "backend2", Overrides: map[string]*config.ToolOverride{"fetch": {Name: "custom_fetch_b2"}}}, + } + + tests := []struct { + name string + strategy vmcp.ConflictResolutionStrategy + priorityOrder []string + wlConfigs []*config.WorkloadToolConfig + aggCfg *config.AggregationConfig + }{ + { + name: "prefix strategy", + strategy: vmcp.ConflictStrategyPrefix, + }, + { + name: "priority strategy", + strategy: vmcp.ConflictStrategyPriority, + priorityOrder: []string{"backend1", "backend2"}, + }, + { + name: "manual strategy", + strategy: vmcp.ConflictStrategyManual, + wlConfigs: manualConfigs, + aggCfg: &config.AggregationConfig{Tools: manualConfigs}, + }, + { + name: "prefix strategy with global ExcludeAllTools", + strategy: vmcp.ConflictStrategyPrefix, + aggCfg: &config.AggregationConfig{ExcludeAllTools: true}, + }, + { + name: "prefix strategy with per-workload Filter", + strategy: vmcp.ConflictStrategyPrefix, + aggCfg: &config.AggregationConfig{ + Tools: []*config.WorkloadToolConfig{{Workload: "backend1", Filter: []string{"tool_a"}}}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + toolsByBackend := map[string][]vmcp.Tool{ + "backend1": {newTestTool("fetch", "backend1"), newTestTool("tool_a", "backend1")}, + "backend2": {newTestTool("fetch", "backend2"), newTestTool("tool_b", "backend2")}, + } + targets := map[string]*vmcp.BackendTarget{ + "backend1": newTarget("backend1"), + "backend2": newTarget("backend2"), + } + + resolver := resolverForStrategy(t, tt.strategy, tt.priorityOrder, tt.wlConfigs) + agg := NewDefaultAggregator(nil, resolver, tt.aggCfg, nil) + + advertised, allResolved, routingTable, err := agg.ProcessPreQueriedCapabilities( + context.Background(), toolsByBackend, targets, + ) + require.NoError(t, err) + + // allResolvedTools is the full set regardless of advertising filter; + // every entry must carry a BackendID for composite-tool schema lookup. + require.NotEmpty(t, allResolved) + for _, tool := range allResolved { + assert.NotEmptyf(t, tool.BackendID, + "resolved tool %q must carry a non-empty BackendID", tool.Name) + } + // Every advertised (post-filter) tool must carry a BackendID, and its + // routing target must agree on the backend identity. + for _, tool := range advertised { + assert.NotEmptyf(t, tool.BackendID, + "advertised tool %q must carry a non-empty BackendID", tool.Name) + target := routingTable[tool.Name] + require.NotNilf(t, target, "routing target for %q", tool.Name) + assert.Equal(t, tool.BackendID, target.WorkloadID, + "advertised tool %q BackendID must match its routing target", tool.Name) + } + }) + } +} diff --git a/pkg/vmcp/aggregator/discoverer.go b/pkg/vmcp/aggregator/discoverer.go index 4a26b9937a..682fa4d003 100644 --- a/pkg/vmcp/aggregator/discoverer.go +++ b/pkg/vmcp/aggregator/discoverer.go @@ -21,6 +21,7 @@ import ( "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt" "github.com/stacklok/toolhive/pkg/vmcp/workloads" workloadsmgr "github.com/stacklok/toolhive/pkg/workloads" ) @@ -33,6 +34,15 @@ type backendDiscoverer struct { authConfig *config.OutgoingAuthConfig staticBackends []config.StaticBackendConfig // Pre-configured backends for static mode groupRef string // Group reference for static mode metadata + + // headerForwardByBackend is keyed by the NORMALIZED backend name (the + // suffix the operator emits in TOOLHIVE_HEADER_FORWARD_). The + // canonical backend name from the static config is normalized on + // lookup via wirefmt.NormalizeForEnvVar so the keying matches + // the operator-side env-var encoding. Nil/empty when no entry declared + // headerForward. Only populated in static mode — dynamic mode reads + // headerForward directly from the MCPServerEntry CRD. + headerForwardByBackend map[string]*vmcp.HeaderForwardConfig } // NewUnifiedBackendDiscoverer creates a unified backend discoverer that works with both @@ -55,17 +65,24 @@ func NewUnifiedBackendDiscoverer( // NewUnifiedBackendDiscovererWithStaticBackends creates a backend discoverer for static mode // with pre-configured backends, eliminating the need for K8s API access. +// +// headerForwardByBackend carries per-backend HeaderForwardConfig (keyed by +// backend name) constructed at startup from the operator-emitted env vars. +// Pass nil when no entry in the group declares headerForward — the discoverer +// will simply leave Backend.HeaderForward nil for every backend. func NewUnifiedBackendDiscovererWithStaticBackends( staticBackends []config.StaticBackendConfig, authConfig *config.OutgoingAuthConfig, groupRef string, + headerForwardByBackend map[string]*vmcp.HeaderForwardConfig, ) BackendDiscoverer { return &backendDiscoverer{ - workloadsManager: nil, // Not needed in static mode - groupsManager: nil, // Not needed in static mode - authConfig: authConfig, - staticBackends: staticBackends, - groupRef: groupRef, + workloadsManager: nil, // Not needed in static mode + groupsManager: nil, // Not needed in static mode + authConfig: authConfig, + staticBackends: staticBackends, + groupRef: groupRef, + headerForwardByBackend: headerForwardByBackend, } } @@ -105,16 +122,6 @@ func NewBackendDiscoverer( return NewUnifiedBackendDiscoverer(workloadDiscoverer, groupsManager, authConfig), nil } -// NewBackendDiscovererWithManager creates a unified BackendDiscoverer with a pre-configured -// WorkloadDiscoverer. This is useful for testing or when you already have a workload manager. -func NewBackendDiscovererWithManager( - workloadManager workloads.Discoverer, - groupsManager groups.Manager, - authConfig *config.OutgoingAuthConfig, -) BackendDiscoverer { - return NewUnifiedBackendDiscoverer(workloadManager, groupsManager, authConfig) -} - // Discover finds all backend workloads in the specified group. // Returns all accessible backends with their health status marked based on workload status. // The groupRef is the group name (e.g., "engineering-team"). @@ -272,6 +279,7 @@ func (d *backendDiscoverer) discoverFromStaticConfig() []vmcp.Backend { Type: vmcp.BackendType(staticBackend.Type), CABundlePath: staticBackend.CABundlePath, HealthStatus: vmcp.BackendHealthy, // Assume healthy, actual health check happens later + HeaderForward: d.headerForwardByBackend[wirefmt.NormalizeForEnvVar(staticBackend.Name)], Metadata: staticBackend.Metadata, } diff --git a/pkg/vmcp/aggregator/discoverer_test.go b/pkg/vmcp/aggregator/discoverer_test.go index 6321662fa5..1e3bc50369 100644 --- a/pkg/vmcp/aggregator/discoverer_test.go +++ b/pkg/vmcp/aggregator/discoverer_test.go @@ -1186,6 +1186,7 @@ func TestStaticBackendDiscoverer_EmptyBackendList(t *testing.T) { []config.StaticBackendConfig{}, // Empty slice, not nil nil, // No auth config "test-group", + nil, // No headerForward map ) // This should return empty list without panicking @@ -1281,6 +1282,7 @@ func TestStaticBackendDiscoverer_MetadataGroupOverride(t *testing.T) { tt.staticBackends, nil, // No auth config needed for this test tt.groupRef, + nil, // No headerForward map for this test ) backends, err := discoverer.Discover(ctx, tt.groupRef) @@ -1400,6 +1402,7 @@ func TestStaticBackendDiscoverer_EntryBackendFields(t *testing.T) { tt.staticBackends, nil, // No auth config needed for this test tt.groupRef, + nil, // No headerForward map for this test ) backends, err := discoverer.Discover(ctx, tt.groupRef) @@ -1466,6 +1469,7 @@ func TestBackendDiscoverer_Discover_DeterministicOrdering(t *testing.T) { tc.staticBackends, nil, // No auth config needed for this test "test-group", + nil, // No headerForward map for this test ) backends, err := discoverer.Discover(ctx, "test-group") diff --git a/pkg/vmcp/auth/converters/aws_sts.go b/pkg/vmcp/auth/converters/aws_sts.go new file mode 100644 index 0000000000..f49276125a --- /dev/null +++ b/pkg/vmcp/auth/converters/aws_sts.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// AwsStsConverter converts MCPExternalAuthConfig AWSSts to vMCP aws_sts strategy. +type AwsStsConverter struct{} + +// StrategyType returns the vMCP strategy type identifier for AWS STS auth. +func (*AwsStsConverter) StrategyType() string { + return authtypes.StrategyTypeAwsSts +} + +// ConvertToStrategy converts an MCPExternalAuthConfig with type "awsSts" to a BackendAuthStrategy. +func (*AwsStsConverter) ConvertToStrategy( + externalAuth *mcpv1beta1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + if externalAuth.Spec.AWSSts == nil { + return nil, fmt.Errorf("aws sts config is nil") + } + + src := externalAuth.Spec.AWSSts + + roleMappings := make([]authtypes.RoleMapping, len(src.RoleMappings)) + for i, m := range src.RoleMappings { + roleMappings[i] = authtypes.RoleMapping{ + Claim: m.Claim, + Matcher: m.Matcher, + RoleArn: m.RoleArn, + } + if m.Priority != nil { + // CRD uses *int32 (Kubernetes API convention); the runtime type + // uses *int to align with awssts.RoleMapping.Priority. + p := int(*m.Priority) + roleMappings[i].Priority = &p + } + } + + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: src.Region, + Service: src.Service, + FallbackRoleArn: src.FallbackRoleArn, + RoleMappings: roleMappings, + RoleClaim: src.RoleClaim, + SessionDuration: src.SessionDuration, + SessionNameClaim: src.SessionNameClaim, + SubjectProviderName: src.SubjectProviderName, + }, + }, nil +} + +// ResolveSecrets is a no-op for AWS STS strategy since credentials are obtained +// at runtime via the pod's IAM role (IRSA or instance profile); no K8s secrets are needed. +func (*AwsStsConverter) ResolveSecrets( + _ context.Context, + _ *mcpv1beta1.MCPExternalAuthConfig, + _ client.Client, + _ string, + strategy *authtypes.BackendAuthStrategy, +) (*authtypes.BackendAuthStrategy, error) { + return strategy, nil +} diff --git a/pkg/vmcp/auth/converters/aws_sts_test.go b/pkg/vmcp/auth/converters/aws_sts_test.go new file mode 100644 index 0000000000..a557b3b1b2 --- /dev/null +++ b/pkg/vmcp/auth/converters/aws_sts_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +func TestAwsStsConverter(t *testing.T) { + t.Parallel() + + t.Run("StrategyType returns aws_sts", func(t *testing.T) { + t.Parallel() + + c := &AwsStsConverter{} + assert.Equal(t, authtypes.StrategyTypeAwsSts, c.StrategyType()) + }) + + t.Run("ConvertToStrategy maps all fields", func(t *testing.T) { + t.Parallel() + + priority := int32(10) + sessionDuration := int32(1800) + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ + Region: "us-east-1", + Service: "execute-api", + FallbackRoleArn: "arn:aws:iam::123456789012:role/fallback", + RoleMappings: []mcpv1beta1.RoleMapping{ + { + Claim: "admins", + RoleArn: "arn:aws:iam::123456789012:role/admin", + Priority: &priority, + }, + { + Matcher: `"devs" in claims["groups"]`, + RoleArn: "arn:aws:iam::123456789012:role/dev", + }, + }, + RoleClaim: "groups", + SessionDuration: &sessionDuration, + SessionNameClaim: "sub", + }, + }, + } + + c := &AwsStsConverter{} + strategy, err := c.ConvertToStrategy(authConfig) + require.NoError(t, err) + require.NotNil(t, strategy) + + assert.Equal(t, authtypes.StrategyTypeAwsSts, strategy.Type) + require.NotNil(t, strategy.AwsSts) + + cfg := strategy.AwsSts + assert.Equal(t, "us-east-1", cfg.Region) + assert.Equal(t, "execute-api", cfg.Service) + assert.Equal(t, "arn:aws:iam::123456789012:role/fallback", cfg.FallbackRoleArn) + assert.Equal(t, "groups", cfg.RoleClaim) + require.NotNil(t, cfg.SessionDuration) + assert.Equal(t, int32(1800), *cfg.SessionDuration) + assert.Equal(t, "sub", cfg.SessionNameClaim) + + require.Len(t, cfg.RoleMappings, 2) + assert.Equal(t, "admins", cfg.RoleMappings[0].Claim) + assert.Equal(t, "arn:aws:iam::123456789012:role/admin", cfg.RoleMappings[0].RoleArn) + require.NotNil(t, cfg.RoleMappings[0].Priority) + assert.Equal(t, 10, *cfg.RoleMappings[0].Priority) + assert.Equal(t, `"devs" in claims["groups"]`, cfg.RoleMappings[1].Matcher) + assert.Equal(t, "arn:aws:iam::123456789012:role/dev", cfg.RoleMappings[1].RoleArn) + }) + + t.Run("ConvertToStrategy returns error when AWSSts is nil", func(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: nil, + }, + } + + c := &AwsStsConverter{} + strategy, err := c.ConvertToStrategy(authConfig) + assert.Error(t, err) + assert.Nil(t, strategy) + assert.Contains(t, err.Error(), "nil") + }) + + t.Run("ConvertToStrategy copies RoleMappings slice", func(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ + Region: "us-west-2", + RoleMappings: []mcpv1beta1.RoleMapping{ + {Claim: "original", RoleArn: "arn:aws:iam::123456789012:role/original"}, + }, + }, + }, + } + + c := &AwsStsConverter{} + strategy, err := c.ConvertToStrategy(authConfig) + require.NoError(t, err) + + // Mutate source slice + authConfig.Spec.AWSSts.RoleMappings[0].Claim = "mutated" + + // Converted result must be unaffected (independent copy) + assert.Equal(t, "original", strategy.AwsSts.RoleMappings[0].Claim) + }) + + t.Run("ResolveSecrets is a no-op", func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + inputStrategy := &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{Region: "us-east-1"}, + } + + c := &AwsStsConverter{} + result, err := c.ResolveSecrets(context.Background(), nil, k8sClient, "default", inputStrategy) + require.NoError(t, err) + assert.Same(t, inputStrategy, result) + }) +} diff --git a/pkg/vmcp/auth/converters/interface.go b/pkg/vmcp/auth/converters/interface.go index ff78c0cb5a..334cb905d6 100644 --- a/pkg/vmcp/auth/converters/interface.go +++ b/pkg/vmcp/auth/converters/interface.go @@ -74,6 +74,8 @@ func NewRegistry() *Registry { r.Register(mcpv1beta1.ExternalAuthTypeHeaderInjection, &HeaderInjectionConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeUnauthenticated, &UnauthenticatedConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeAWSSts, &AwsStsConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeOBO, &OBOConverter{}) return r } diff --git a/pkg/vmcp/auth/converters/obo.go b/pkg/vmcp/auth/converters/obo.go new file mode 100644 index 0000000000..01a45f5519 --- /dev/null +++ b/pkg/vmcp/auth/converters/obo.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth/obo" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// OBOConverter is the default StrategyConverter for ExternalAuthTypeOBO. +// Every method returns an error wrapping obo.ErrEnterpriseRequired. An +// out-of-tree build replaces it by calling DefaultRegistry().Register(...) +// once during init(). +type OBOConverter struct{} + +// StrategyType returns the vMCP strategy identifier for OBO. +func (*OBOConverter) StrategyType() string { return authtypes.StrategyTypeOBO } + +// ConvertToStrategy returns an error wrapping obo.ErrEnterpriseRequired. +func (*OBOConverter) ConvertToStrategy( + _ *mcpv1beta1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + return nil, fmt.Errorf("vMCP OBO converter: %w", obo.ErrEnterpriseRequired) +} + +// ResolveSecrets returns an error wrapping obo.ErrEnterpriseRequired. +func (*OBOConverter) ResolveSecrets( + _ context.Context, + _ *mcpv1beta1.MCPExternalAuthConfig, + _ client.Client, + _ string, + _ *authtypes.BackendAuthStrategy, +) (*authtypes.BackendAuthStrategy, error) { + return nil, fmt.Errorf("vMCP OBO converter: %w", obo.ErrEnterpriseRequired) +} diff --git a/pkg/vmcp/auth/converters/obo_test.go b/pkg/vmcp/auth/converters/obo_test.go new file mode 100644 index 0000000000..daecaf0440 --- /dev/null +++ b/pkg/vmcp/auth/converters/obo_test.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth/obo" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +func TestOBOConverter_StrategyType(t *testing.T) { + t.Parallel() + + assert.Equal(t, authtypes.StrategyTypeOBO, (&OBOConverter{}).StrategyType()) +} + +func TestOBOConverter_ConvertToStrategy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalCfg *mcpv1beta1.MCPExternalAuthConfig + }{ + {name: "nil config", externalCfg: nil}, + { + name: "obo-typed config", + externalCfg: &mcpv1beta1.MCPExternalAuthConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + strategy, err := (&OBOConverter{}).ConvertToStrategy(tt.externalCfg) + require.Error(t, err) + assert.Nil(t, strategy, "stub must not return a strategy on the error path") + assert.ErrorIs(t, err, obo.ErrEnterpriseRequired) + }) + } +} + +func TestOBOConverter_ResolveSecrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalCfg *mcpv1beta1.MCPExternalAuthConfig + strategy *authtypes.BackendAuthStrategy + }{ + {name: "nil inputs", externalCfg: nil, strategy: nil}, + { + name: "non-nil strategy", + externalCfg: &mcpv1beta1.MCPExternalAuthConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, + }, + }, + strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeOBO}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolved, err := (&OBOConverter{}).ResolveSecrets( + context.Background(), tt.externalCfg, nil, "default", tt.strategy, + ) + require.Error(t, err) + assert.Nil(t, resolved, "stub must not return a strategy on the error path") + assert.ErrorIs(t, err, obo.ErrEnterpriseRequired) + }) + } +} + +func TestOBOConverter_RegisteredInDefaultRegistry(t *testing.T) { + t.Parallel() + + converter, err := NewRegistry().GetConverter(mcpv1beta1.ExternalAuthTypeOBO) + require.NoError(t, err) + require.NotNil(t, converter) + assert.IsType(t, &OBOConverter{}, converter) + + // DefaultRegistry should also have it. + converter, err = DefaultRegistry().GetConverter(mcpv1beta1.ExternalAuthTypeOBO) + require.NoError(t, err) + assert.IsType(t, &OBOConverter{}, converter) +} + +// TestDiscoverAndResolveAuth_OBO_SentinelSurvivesWrap proves that the +// OBO sentinel propagates through DiscoverAndResolveAuth's +// `"failed to convert to strategy: %w"` wrap at interface.go:191 and +// remains recognizable via errors.Is. The vMCP integration path is +// structurally fragile: matching by the wrap-prefix string would silently +// degrade if the message ever changed. This test is the regression guard. +func TestDiscoverAndResolveAuth_OBO_SentinelSurvivesWrap(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + cfg := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "obo-config", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cfg). + Build() + + strategy, err := DiscoverAndResolveAuth( + t.Context(), + &mcpv1beta1.ExternalAuthConfigRef{Name: cfg.Name}, + cfg.Namespace, + fakeClient, + ) + + require.Error(t, err) + assert.Nil(t, strategy) + assert.ErrorIs(t, err, obo.ErrEnterpriseRequired, + "errors.Is must match the sentinel through the convert-to-strategy wrap") + + // Sanity: the wrap prefix is present, but recognition above is via + // errors.Is, not by string matching. + assert.Contains(t, err.Error(), "failed to convert to strategy") + + // Generic-error guards: the dispatch path must not leak generic strings. + assert.NotContains(t, err.Error(), "unsupported external auth type") + assert.NotContains(t, err.Error(), "unknown middleware type") +} diff --git a/pkg/vmcp/auth/converters/registry_test.go b/pkg/vmcp/auth/converters/registry_test.go index d793167865..b800b54f1c 100644 --- a/pkg/vmcp/auth/converters/registry_test.go +++ b/pkg/vmcp/auth/converters/registry_test.go @@ -94,6 +94,19 @@ func TestDefaultRegistry(t *testing.T) { require.NoError(t, err) require.NotNil(t, upstreamInjectConverter) assert.Equal(t, "upstream_inject", upstreamInjectConverter.StrategyType()) + + // Test AWS STS converter + awsStsConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeAWSSts) + require.NoError(t, err) + require.NotNil(t, awsStsConverter) + assert.Equal(t, "aws_sts", awsStsConverter.StrategyType()) + + // Test OBO converter stub + oboConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeOBO) + require.NoError(t, err) + require.NotNil(t, oboConverter) + assert.Equal(t, "obo", oboConverter.StrategyType()) + assert.IsType(t, &OBOConverter{}, oboConverter) }) } @@ -123,6 +136,14 @@ func TestNewRegistry(t *testing.T) { upstreamInjectConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUpstreamInject) require.NoError(t, err) assert.NotNil(t, upstreamInjectConverter) + + awsStsConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeAWSSts) + require.NoError(t, err) + assert.NotNil(t, awsStsConverter) + + oboConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeOBO) + require.NoError(t, err) + assert.NotNil(t, oboConverter) }) t.Run("creates independent instances", func(t *testing.T) { @@ -149,6 +170,8 @@ func TestNewRegistry(t *testing.T) { {mcpv1beta1.ExternalAuthTypeHeaderInjection, "header_injection"}, {mcpv1beta1.ExternalAuthTypeUnauthenticated, "unauthenticated"}, {mcpv1beta1.ExternalAuthTypeUpstreamInject, "upstream_inject"}, + {mcpv1beta1.ExternalAuthTypeAWSSts, "aws_sts"}, + {mcpv1beta1.ExternalAuthTypeOBO, "obo"}, } for _, tc := range testCases { diff --git a/pkg/vmcp/auth/factory/authz_not_wired_test.go b/pkg/vmcp/auth/factory/authz_not_wired_test.go index a72dce369e..503d667ce4 100644 --- a/pkg/vmcp/auth/factory/authz_not_wired_test.go +++ b/pkg/vmcp/auth/factory/authz_not_wired_test.go @@ -6,6 +6,7 @@ package factory import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -47,7 +48,7 @@ func TestNewIncomingAuthMiddleware_AuthzEnforced(t *testing.T) { }, } - authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, nil, nil, nil) + authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, "test-server", nil, nil, nil) require.NoError(t, err, "middleware creation should succeed") require.NotNil(t, authMw, "auth middleware should not be nil") require.NotNil(t, authzMw, "authz middleware should not be nil") @@ -105,7 +106,7 @@ func TestNewIncomingAuthMiddleware_AuthzEnforced(t *testing.T) { }, } - authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, nil, nil, nil) + authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, "test-server", nil, nil, nil) require.NoError(t, err, "middleware creation should succeed") require.NotNil(t, authMw, "auth middleware should not be nil") require.NotNil(t, authzMw, "authz middleware should not be nil") @@ -165,7 +166,7 @@ func TestNewIncomingAuthMiddleware_AuthzApproveAndBlock(t *testing.T) { }, } - authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, nil, nil, nil) + authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, "test-server", nil, nil, nil) require.NoError(t, err, "middleware creation should succeed") require.NotNil(t, authMw, "auth middleware should not be nil") require.NotNil(t, authzMw, "authz middleware should not be nil") @@ -350,3 +351,126 @@ func TestNewIncomingAuthMiddleware_AuthzApproveAndBlock(t *testing.T) { "response should be 403 Forbidden when no policy permits the request") }) } + +// TestNewIncomingAuthMiddleware_ServerNameThreaded verifies that the serverName +// parameter is used as the Cedar resource entity name, not a hard-coded constant. +// Regression test for: VirtualMCPServer Cedar authz uses hard-coded "vmcp" resource name. +// +// The policy uses `resource in MCP::""` (membership traversal) because the +// resource entity's UID is the tool type (e.g. Tool::"some_tool"), not the MCP +// server entity itself. The `in` operator traverses the parent chain to reach the +// MCP parent entity that carries the server name. +func TestNewIncomingAuthMiddleware_ServerNameThreaded(t *testing.T) { + t.Parallel() + + const actualServerName = "my-production-vmcp" + + // The MCP parent entity must be present in the entity store for Cedar's `in` + // operator to traverse the parent chain and match the policy. + mcpEntityJSON := fmt.Sprintf(`[{"uid":{"type":"MCP","id":"%s"},"parents":[],"attrs":{}}]`, actualServerName) + + // Policy uses `resource in MCP::""` — the tool resource is a child of + // the MCP server entity, so `in` traversal reaches MCP::"my-production-vmcp". + // If the middleware hard-codes "vmcp" instead of using actualServerName, + // the resource entity will not have the right MCP parent and Cedar's + // default-deny will fire, returning 403 even though the principal should be permitted. + cfg := &config.IncomingAuthConfig{ + Type: "anonymous", + Authz: &config.AuthzConfig{ + Type: "cedar", + Policies: []string{ + fmt.Sprintf(`permit(principal, action == Action::"call_tool", resource in MCP::"%s");`, actualServerName), + }, + EntitiesJSON: mcpEntityJSON, + }, + } + + authMw, authzMw, _, err := NewIncomingAuthMiddleware(t.Context(), cfg, actualServerName, nil, nil, nil) + require.NoError(t, err, "middleware creation should succeed") + require.NotNil(t, authMw, "auth middleware should not be nil") + require.NotNil(t, authzMw, "authz middleware should not be nil") + + t.Run("correct_server_name_permits_call_tool", func(t *testing.T) { + t.Parallel() + + handlerCalled := false + testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + wrapped := authMw(authzMw(testHandler)) + + mcpRequest := map[string]any{ + "jsonrpc": "2.0", + "method": "tools/call", + "id": 1, + "params": map[string]any{ + "name": "some_tool", + "arguments": map[string]any{}, + }, + } + body, err := json.Marshal(mcpRequest) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + wrapped.ServeHTTP(recorder, req) + + assert.True(t, handlerCalled, + "handler SHOULD be called — serverName must match the Cedar resource entity name") + assert.Equal(t, http.StatusOK, recorder.Code, + "call_tool should be permitted when serverName matches the Cedar resource") + }) + + t.Run("wrong_server_name_denies_call_tool", func(t *testing.T) { + t.Parallel() + + wrongCfg := &config.IncomingAuthConfig{ + Type: "anonymous", + Authz: &config.AuthzConfig{ + Type: "cedar", + Policies: []string{ + fmt.Sprintf(`permit(principal, action == Action::"call_tool", resource in MCP::"%s");`, actualServerName), + }, + EntitiesJSON: mcpEntityJSON, + }, + } + + wrongAuthMw, wrongAuthzMw, _, err := NewIncomingAuthMiddleware(t.Context(), wrongCfg, "wrong-server", nil, nil, nil) + require.NoError(t, err, "middleware creation should succeed") + + handlerCalled := false + testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + wrapped := wrongAuthMw(wrongAuthzMw(testHandler)) + + mcpRequest := map[string]any{ + "jsonrpc": "2.0", + "method": "tools/call", + "id": 1, + "params": map[string]any{ + "name": "some_tool", + "arguments": map[string]any{}, + }, + } + body, err := json.Marshal(mcpRequest) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + wrapped.ServeHTTP(recorder, req) + + assert.False(t, handlerCalled, + "handler should NOT be called — wrong serverName means resource has a different MCP parent") + assert.Equal(t, http.StatusForbidden, recorder.Code, + "call_tool should be denied when serverName does not match the Cedar resource policy") + }) +} diff --git a/pkg/vmcp/auth/factory/incoming.go b/pkg/vmcp/auth/factory/incoming.go index 74b7d1edc8..f8a1560e08 100644 --- a/pkg/vmcp/auth/factory/incoming.go +++ b/pkg/vmcp/auth/factory/incoming.go @@ -35,6 +35,10 @@ import ( // All middleware types now directly create and inject Identity into the context, // eliminating the need for a separate conversion layer. // +// The serverName parameter is the VirtualMCPServer name and is used as the Cedar +// resource entity name in authorization policy evaluation. It must match the +// resource name used when compiling Cedar policies for this server. +// // The passThroughTools parameter is optional (pass nil for none). Tool names in // this set bypass the response filter's policy check in tools/list responses. // This is used when the optimizer is enabled: its meta-tools (find_tool, call_tool) @@ -50,6 +54,7 @@ import ( func NewIncomingAuthMiddleware( ctx context.Context, cfg *config.IncomingAuthConfig, + serverName string, passThroughTools map[string]struct{}, upstreamReader upstreamtoken.TokenReader, keyProvider keys.PublicKeyProvider, @@ -86,11 +91,11 @@ func NewIncomingAuthMiddleware( // Cedar policies access to discovered tool annotations. var authzMiddleware func(http.Handler) http.Handler if cfg.Authz != nil && cfg.Authz.Type == "cedar" && len(cfg.Authz.Policies) > 0 { - authzMiddleware, err = newCedarAuthzMiddleware(cfg.Authz, passThroughTools) + authzMiddleware, err = newCedarAuthzMiddleware(cfg.Authz, serverName, passThroughTools) if err != nil { return nil, nil, nil, fmt.Errorf("failed to create authorization middleware: %w", err) } - slog.Info("authorization middleware enabled with Cedar policies") + slog.Debug("authorization middleware enabled with Cedar policies", "server_name", serverName) } // Auth middleware composes auth + parser. @@ -105,25 +110,42 @@ func NewIncomingAuthMiddleware( } // newCedarAuthzMiddleware creates Cedar authorization middleware from vMCP config. +// serverName is forwarded to CreateMiddlewareFromConfig as the Cedar resource entity name. func newCedarAuthzMiddleware( - authzCfg *config.AuthzConfig, passThroughTools map[string]struct{}, + authzCfg *config.AuthzConfig, serverName string, passThroughTools map[string]struct{}, ) (func(http.Handler) http.Handler, error) { if authzCfg == nil || len(authzCfg.Policies) == 0 { return nil, fmt.Errorf("cedar authorization requires at least one policy") } + if serverName == "" { + return nil, fmt.Errorf("serverName must not be empty: Cedar resource-scoped policies require a non-empty server name") + } slog.Info("creating Cedar authorization middleware", "policies", len(authzCfg.Policies)) + // Default EntitiesJSON to "[]" when the operator/CLI did not set it. Cedar + // requires a valid JSON array; an empty string would fail to parse. + entitiesJSON := authzCfg.EntitiesJSON + if entitiesJSON == "" { + entitiesJSON = "[]" + } + // Build the Cedar config structure expected by the authorizer factory. // PrimaryUpstreamProvider is forwarded so Cedar evaluates claims from the // upstream IDP token when the embedded auth server is active. + // GroupClaimName, RoleClaimName, and GroupEntityType plumb the enterprise + // JWT-to-entity mapping (groups/roles claims → Cedar parent UIDs) through + // to the authorizer. cedarConfig := cedar.Config{ Version: "1.0", Type: cedar.ConfigType, Options: &cedar.ConfigOptions{ Policies: authzCfg.Policies, - EntitiesJSON: "[]", + EntitiesJSON: entitiesJSON, PrimaryUpstreamProvider: authzCfg.PrimaryUpstreamProvider, + GroupClaimName: authzCfg.GroupClaimName, + RoleClaimName: authzCfg.RoleClaimName, + GroupEntityType: authzCfg.GroupEntityType, }, } @@ -134,7 +156,7 @@ func newCedarAuthzMiddleware( } // Create the middleware using the existing factory - middlewareFn, err := authz.CreateMiddlewareFromConfig(authzConfig, "vmcp", passThroughTools) + middlewareFn, err := authz.CreateMiddlewareFromConfig(authzConfig, serverName, passThroughTools) if err != nil { return nil, fmt.Errorf("failed to create Cedar middleware: %w", err) } diff --git a/pkg/vmcp/auth/factory/incoming_test.go b/pkg/vmcp/auth/factory/incoming_test.go index e27d36024b..24217455fa 100644 --- a/pkg/vmcp/auth/factory/incoming_test.go +++ b/pkg/vmcp/auth/factory/incoming_test.go @@ -135,7 +135,7 @@ func TestNewIncomingAuthMiddleware(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - authMw, authzMw, authInfo, err := NewIncomingAuthMiddleware(t.Context(), tt.cfg, nil, nil, nil) + authMw, authzMw, authInfo, err := NewIncomingAuthMiddleware(t.Context(), tt.cfg, "test-server", nil, nil, nil) if tt.wantErr { require.Error(t, err) @@ -171,7 +171,7 @@ func TestNewCedarAuthzMiddleware_PropagatesPrimaryUpstreamProvider(t *testing.T) PrimaryUpstreamProvider: providerName, } - mw, err := newCedarAuthzMiddleware(authzCfg, nil) + mw, err := newCedarAuthzMiddleware(authzCfg, "test-server", nil) require.NoError(t, err) require.NotNil(t, mw, "middleware function should not be nil") diff --git a/pkg/vmcp/auth/factory/outgoing.go b/pkg/vmcp/auth/factory/outgoing.go index a818e6ed5e..9510bedbae 100644 --- a/pkg/vmcp/auth/factory/outgoing.go +++ b/pkg/vmcp/auth/factory/outgoing.go @@ -26,15 +26,16 @@ import ( // NewOutgoingAuthRegistry creates an OutgoingAuthRegistry with all available strategies. // -// All strategies are registered upfront since they're cheap and mostly stateless -// (except token_exchange which has internal caching). This simplifies the factory -// and eliminates the need for on-demand strategy registration based on configuration. +// All strategies are registered upfront. Most are stateless; token_exchange and +// aws_sts maintain an internal per-config cache initialized on first use. This +// simplifies the factory and eliminates on-demand strategy registration. // // Registered Strategies: // - "unauthenticated": Default fallback for backends without auth // - "header_injection": Custom HTTP header injection // - "token_exchange": RFC-8693 OAuth 2.0 token exchange // - "upstream_inject": Per-upstream token injection from stored credentials +// - "aws_sts": AWS STS AssumeRoleWithWebIdentity + SigV4 request signing // // Parameters: // - ctx: Context for any initialization that requires it @@ -49,7 +50,7 @@ func NewOutgoingAuthRegistry( ) (auth.OutgoingAuthRegistry, error) { registry := auth.NewDefaultOutgoingAuthRegistry() - // Always register all strategies - they're cheap and stateless + // Register all strategies upfront. if err := registry.RegisterStrategy( authtypes.StrategyTypeUnauthenticated, strategies.NewUnauthenticatedStrategy(), @@ -74,6 +75,12 @@ func NewOutgoingAuthRegistry( ); err != nil { return nil, err } + if err := registry.RegisterStrategy( + authtypes.StrategyTypeAwsSts, + strategies.NewAwsStsStrategy(), + ); err != nil { + return nil, err + } return registry, nil } diff --git a/pkg/vmcp/auth/factory/outgoing_test.go b/pkg/vmcp/auth/factory/outgoing_test.go index a89253a5a0..09c7f79011 100644 --- a/pkg/vmcp/auth/factory/outgoing_test.go +++ b/pkg/vmcp/auth/factory/outgoing_test.go @@ -27,12 +27,13 @@ func TestNewOutgoingAuthRegistry(t *testing.T) { require.NoError(t, err) require.NotNil(t, registry) - // Verify all three strategies are registered + // Verify all strategies are registered strategyTypes := []string{ authtypes.StrategyTypeUnauthenticated, authtypes.StrategyTypeHeaderInjection, authtypes.StrategyTypeTokenExchange, authtypes.StrategyTypeUpstreamInject, + authtypes.StrategyTypeAwsSts, } for _, strategyType := range strategyTypes { @@ -164,6 +165,7 @@ func TestNewOutgoingAuthRegistry(t *testing.T) { {authtypes.StrategyTypeHeaderInjection, "header_injection"}, {authtypes.StrategyTypeTokenExchange, "token_exchange"}, {authtypes.StrategyTypeUpstreamInject, "upstream_inject"}, + {authtypes.StrategyTypeAwsSts, "aws_sts"}, } for _, tc := range testCases { diff --git a/pkg/vmcp/auth/strategies/aws_sts.go b/pkg/vmcp/auth/strategies/aws_sts.go new file mode 100644 index 0000000000..ecdfaff3ff --- /dev/null +++ b/pkg/vmcp/auth/strategies/aws_sts.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies + +import ( + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/auth/awssts" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +// awsStsContext holds the per-config roleMapper, exchanger, signer, and session duration. +type awsStsContext struct { + roleMapper *awssts.RoleMapper + exchanger *awssts.Exchanger + signer *awssts.RequestSigner + sessionDuration int32 +} + +// AwsStsStrategy authenticates backend requests using AWS STS token exchange and SigV4 signing. +// +// For each authenticated request, the strategy: +// 1. Extracts the bearer token and JWT claims from the incoming identity +// 2. Selects the appropriate IAM role using a CEL-based role mapper +// 3. Exchanges the identity token for temporary AWS credentials via AssumeRoleWithWebIdentity +// 4. Signs the outgoing request with SigV4 using the temporary credentials +// +// Required configuration fields (in BackendAuthStrategy.AwsSts): +// - Region: AWS region for STS endpoint and SigV4 signing +// +// At least one of the following must also be configured: +// - FallbackRoleArn: IAM role ARN to assume when no role mappings match +// - RoleMappings: CEL-based rules for mapping JWT claims to IAM roles +// +// This strategy is appropriate when: +// - The backend is an AWS-managed MCP server requiring SigV4 authentication +// - Role selection should be derived from the incoming caller's JWT claims +// +// The strategy is safe for concurrent use. It maintains a per-config cache of +// roleMapper and exchanger instances, keyed by a SHA-256 hash over all fields of +// the AwsStsConfig (region, service, role mappings including Claim/Matcher/Priority, +// fallback ARN, and session claims). Cache entries are created on first use (via +// Validate or Authenticate) and shared across all requests with the same configuration. +type AwsStsStrategy struct { + mu sync.RWMutex + cached map[string]*awsStsContext +} + +// NewAwsStsStrategy creates a new AwsStsStrategy instance. +func NewAwsStsStrategy() *AwsStsStrategy { + return &AwsStsStrategy{ + cached: make(map[string]*awsStsContext), + } +} + +// Name returns the strategy identifier. +func (*AwsStsStrategy) Name() string { + return authtypes.StrategyTypeAwsSts +} + +// Authenticate performs AWS STS token exchange and SigV4 signing for the request. +// +// This method: +// 1. Skips authentication for health check requests (no user identity to use) +// 2. Builds an awssts.Config from the strategy configuration +// 3. Delegates to authenticateWithConfig to perform the STS exchange and signing +// +// Parameters: +// - ctx: Request context containing the authenticated identity (or health check marker) +// - req: The HTTP request to authenticate; modified in place with SigV4 headers +// - strategy: Backend auth strategy containing AwsSts configuration +// +// Returns an error if: +// - The AwsSts configuration is nil or missing a required field +// - No identity is found in the context +// - Role selection fails (no matching mapping and no fallback) +// - The STS exchange fails +// - SigV4 signing fails +func (s *AwsStsStrategy) Authenticate( + ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy, +) error { + // Health checks have no user identity — skip authentication. + if healthcontext.IsHealthCheck(ctx) { + return nil + } + + if strategy == nil || strategy.AwsSts == nil { + return fmt.Errorf("aws_sts configuration required") + } + + cfg := toAwsStsConfig(strategy.AwsSts) + stsCtx, err := s.getOrCreateContext(ctx, cfg) + if err != nil { + return err + } + + return authenticateWithCached(ctx, req, cfg, stsCtx) +} + +// authenticateWithCached performs the STS token exchange and SigV4 signing +// for an outgoing request using pre-built components from awsStsContext. +func authenticateWithCached( + ctx context.Context, + req *http.Request, + cfg *awssts.Config, + stsCtx *awsStsContext, +) error { + identity, ok := auth.IdentityFromContext(ctx) + if !ok { + return fmt.Errorf("no identity found in context") + } + + if identity.Claims == nil { + return fmt.Errorf("no claims in identity") + } + + roleArn, err := selectRole(stsCtx.roleMapper, identity.Claims) + if err != nil { + return err + } + + var bearerToken string + if cfg.SubjectProviderName != "" { + bearerToken = identity.UpstreamTokens[cfg.SubjectProviderName] // nil map safe in Go + if bearerToken == "" { + return fmt.Errorf("provider %q: %w", cfg.SubjectProviderName, authtypes.ErrUpstreamTokenNotFound) + } + } else { + // Fall back to the original incoming token captured in the identity. + // req is the outgoing backend request being constructed and is not + // guaranteed to carry the caller's Authorization header. + if identity.Token == "" { + return fmt.Errorf("identity has no token") + } + slog.Debug("aws_sts: SubjectProviderName empty, falling back to identity.Token") + bearerToken = identity.Token + } + + sessionName, err := resolveSessionName(cfg, identity.Claims) + if err != nil { + return err + } + + creds, err := stsCtx.exchanger.ExchangeToken(ctx, bearerToken, roleArn, sessionName, stsCtx.sessionDuration) + if err != nil { + return fmt.Errorf("STS token exchange failed: %w", err) + } + + if err := stsCtx.signer.SignRequest(ctx, req, creds); err != nil { + return fmt.Errorf("failed to sign request: %w", err) + } + + return nil +} + +// selectRole uses the provided role mapper to return the IAM role ARN for the given claims. +func selectRole(roleMapper *awssts.RoleMapper, claims map[string]any) (string, error) { + roleArn, err := roleMapper.SelectRole(claims) + if err != nil { + return "", fmt.Errorf("failed to select IAM role: %w", err) + } + return roleArn, nil +} + +// resolveSessionName extracts and validates the STS session name from JWT claims. +func resolveSessionName(cfg *awssts.Config, claims map[string]any) (string, error) { + claimKey := cfg.SessionNameClaim + if claimKey == "" { + claimKey = "sub" + } + sessionName, err := awssts.ExtractSessionName(claims, claimKey) + if err != nil { + return "", fmt.Errorf("failed to extract session name: %w", err) + } + if err := awssts.ValidateSessionName(sessionName); err != nil { + return "", fmt.Errorf("invalid session name: %w", err) + } + return sessionName, nil +} + +// Validate checks if the required strategy configuration fields are present and valid, +// and warms the per-config cache entry for this backend. +// +// This method verifies that: +// - The AwsSts configuration block is present +// - Region is non-empty (required for STS endpoint and SigV4 signing) +// - The configuration is structurally valid (delegates to awssts.ValidateConfig) +func (s *AwsStsStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { + if strategy == nil || strategy.AwsSts == nil { + return fmt.Errorf("aws_sts configuration required") + } + + if strategy.AwsSts.Region == "" { + return fmt.Errorf("region required in aws_sts configuration") + } + + cfg := toAwsStsConfig(strategy.AwsSts) + if err := awssts.ValidateConfig(cfg); err != nil { + return err + } + + _, err := s.getOrCreateContext(context.Background(), cfg) + return err +} + +// getOrCreateContext retrieves or creates a cached awsStsContext for the given config. +// +// Thread-safe: uses double-checked locking so that concurrent callers with the +// same config key build the roleMapper/exchanger/signer only once. +// ValidateConfig is called on cache miss to ensure structurally invalid configs +// are rejected even when Authenticate is called without prior Validate. +func (s *AwsStsStrategy) getOrCreateContext(ctx context.Context, cfg *awssts.Config) (*awsStsContext, error) { + cacheKey := buildAwsStsCacheKey(cfg) + + // Fast path: read lock. + s.mu.RLock() + if cached, exists := s.cached[cacheKey]; exists { + s.mu.RUnlock() + return cached, nil + } + s.mu.RUnlock() + + // Slow path: write lock. + s.mu.Lock() + defer s.mu.Unlock() + + // Double-check in case another goroutine created it. + if cached, exists := s.cached[cacheKey]; exists { + return cached, nil + } + + if err := awssts.ValidateConfig(cfg); err != nil { + return nil, err + } + + roleMapper, err := awssts.NewRoleMapper(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build role mapper: %w", err) + } + + exchanger, err := awssts.NewExchanger(ctx, cfg.Region) + if err != nil { + return nil, fmt.Errorf("failed to build STS exchanger: %w", err) + } + + signer, err := awssts.NewRequestSigner(cfg.Region, cfg.GetService()) + if err != nil { + return nil, fmt.Errorf("failed to build request signer: %w", err) + } + + entry := &awsStsContext{ + roleMapper: roleMapper, + exchanger: exchanger, + signer: signer, + sessionDuration: cfg.GetSessionDuration(), + } + s.cached[cacheKey] = entry + return entry, nil +} + +// buildAwsStsCacheKey computes a SHA-256 hash over all fields that differentiate +// backend configurations: Region, Service, FallbackRoleArn, every RoleMapping's +// Claim/Matcher/RoleArn/Priority (sorted by RoleArn for stability), RoleClaim, +// SessionNameClaim, SubjectProviderName, and the resolved SessionDuration. +// SessionDuration is included because it is baked into the cached awsStsContext; +// omitting it would let two backends differing only in session duration share a +// cache entry and silently use the wrong value. Using a hash avoids structural +// ambiguity from colons embedded in ARN strings and ensures configs that share +// role ARNs but differ in matching logic (Claim, Matcher) produce distinct keys. +func buildAwsStsCacheKey(cfg *awssts.Config) string { + // Sort role mappings by RoleArn for a stable ordering. Use a stable sort so + // that mappings sharing a RoleArn but differing in Claim or Matcher keep + // their input order — otherwise logically identical configs could hash to + // different keys across calls and cause spurious cache misses. + mappings := make([]awssts.RoleMapping, len(cfg.RoleMappings)) + copy(mappings, cfg.RoleMappings) + slices.SortStableFunc(mappings, func(a, b awssts.RoleMapping) int { + return cmp.Compare(a.RoleArn, b.RoleArn) + }) + + var sb strings.Builder + sb.WriteString(cfg.Region) + sb.WriteByte(0) + sb.WriteString(cfg.Service) + sb.WriteByte(0) + sb.WriteString(cfg.FallbackRoleArn) + sb.WriteByte(0) + sb.WriteString(cfg.RoleClaim) + sb.WriteByte(0) + sb.WriteString(cfg.SessionNameClaim) + sb.WriteByte(0) + sb.WriteString(cfg.SubjectProviderName) + sb.WriteByte(0) + fmt.Fprintf(&sb, "%d", cfg.GetSessionDuration()) + sb.WriteByte(0) + for _, rm := range mappings { + sb.WriteString(rm.RoleArn) + sb.WriteByte(0) + sb.WriteString(rm.Claim) + sb.WriteByte(0) + sb.WriteString(rm.Matcher) + sb.WriteByte(0) + if rm.Priority != nil { + fmt.Fprintf(&sb, "%d", *rm.Priority) + } + sb.WriteByte(0) + } + + sum := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +// toAwsStsConfig converts an authtypes.AwsStsConfig to an awssts.Config. +// The two types mirror each other; this function bridges the vmcp types +// package (which must remain a leaf with no awssts dependency) to the +// awssts implementation package. +func toAwsStsConfig(in *authtypes.AwsStsConfig) *awssts.Config { + cfg := &awssts.Config{ + Region: in.Region, + Service: in.Service, + FallbackRoleArn: in.FallbackRoleArn, + RoleClaim: in.RoleClaim, + SessionNameClaim: in.SessionNameClaim, + SubjectProviderName: in.SubjectProviderName, + } + + if in.SessionDuration != nil { + cfg.SessionDuration = *in.SessionDuration + } + + if len(in.RoleMappings) > 0 { + cfg.RoleMappings = make([]awssts.RoleMapping, len(in.RoleMappings)) + for i, rm := range in.RoleMappings { + cfg.RoleMappings[i] = awssts.RoleMapping{ + Claim: rm.Claim, + Matcher: rm.Matcher, + RoleArn: rm.RoleArn, + Priority: rm.Priority, + } + } + } + + return cfg +} diff --git a/pkg/vmcp/auth/strategies/aws_sts_test.go b/pkg/vmcp/auth/strategies/aws_sts_test.go new file mode 100644 index 0000000000..ab91e251c8 --- /dev/null +++ b/pkg/vmcp/auth/strategies/aws_sts_test.go @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth/awssts" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +func TestAwsStsStrategy_Name(t *testing.T) { + t.Parallel() + + s := NewAwsStsStrategy() + assert.Equal(t, authtypes.StrategyTypeAwsSts, s.Name()) +} + +func TestAwsStsStrategy_Authenticate(t *testing.T) { + t.Parallel() + + validStrategy := &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + FallbackRoleArn: "arn:aws:iam::123456789012:role/test", + }, + } + + tests := []struct { + name string + ctx context.Context + strategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "skips auth for health check requests", + ctx: healthcontext.WithHealthCheckMarker(context.Background()), + strategy: validStrategy, + wantErr: false, + }, + { + name: "returns error when strategy is nil", + ctx: context.Background(), + strategy: nil, + wantErr: true, + errContains: "aws_sts configuration required", + }, + { + name: "returns error when AwsSts config is nil", + ctx: context.Background(), + strategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: nil, + }, + wantErr: true, + errContains: "aws_sts configuration required", + }, + { + // Without Validate having been called the cache is empty, so + // Authenticate builds the context on demand. The request then + // fails because there is no identity in the context — but that + // confirms the code path past the nil-config guard is reached. + name: "returns error when no identity in context (cache miss builds on demand)", + ctx: context.Background(), + strategy: validStrategy, + wantErr: true, + errContains: "no identity found in context", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := NewAwsStsStrategy() + req := httptest.NewRequest("GET", "http://backend.example.com/mcp", nil) + + err := s.Authenticate(tt.ctx, req, tt.strategy) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAwsStsStrategy_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "returns error when strategy is nil", + strategy: nil, + wantErr: true, + errContains: "aws_sts configuration required", + }, + { + name: "returns error when AwsSts config is nil", + strategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: nil, + }, + wantErr: true, + errContains: "aws_sts configuration required", + }, + { + name: "returns error when region is empty", + strategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + FallbackRoleArn: "arn:aws:iam::123456789012:role/test", + }, + }, + wantErr: true, + errContains: "region required", + }, + { + name: "returns error when neither fallback role nor mappings are configured", + strategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + }, + }, + wantErr: true, + // ValidateConfig enforces at least one of FallbackRoleArn or RoleMappings + }, + { + // Validate builds a roleMapper, an STS exchanger, and a SigV4 signer. + // NewExchanger uses aws.AnonymousCredentials{} so it makes no network + // calls and runs without AWS credentials in CI. + name: "valid region and fallback role succeeds", + strategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + FallbackRoleArn: "arn:aws:iam::123456789012:role/test", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := NewAwsStsStrategy() + err := s.Validate(tt.strategy) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestBuildAwsStsCacheKey(t *testing.T) { + t.Parallel() + + base := awssts.Config{ + Region: "us-east-1", + FallbackRoleArn: "arn:aws:iam::123456789012:role/ops", + } + + // Same role ARN, different Claim — must produce different keys. + withClaim := base + withClaim.RoleMappings = []awssts.RoleMapping{ + {Claim: "admins", RoleArn: "arn:aws:iam::123456789012:role/ops"}, + } + withMatcher := base + withMatcher.RoleMappings = []awssts.RoleMapping{ + {Matcher: `"devs" in claims["groups"]`, RoleArn: "arn:aws:iam::123456789012:role/ops"}, + } + + keyBase := buildAwsStsCacheKey(&base) + keyWithClaim := buildAwsStsCacheKey(&withClaim) + keyWithMatcher := buildAwsStsCacheKey(&withMatcher) + + assert.NotEqual(t, keyBase, keyWithClaim, "base and claim-mapped configs should have different keys") + assert.NotEqual(t, keyBase, keyWithMatcher, "base and matcher-mapped configs should have different keys") + assert.NotEqual(t, keyWithClaim, keyWithMatcher, "claim-mapped and matcher-mapped configs should have different keys") + + // Identical configs must produce identical keys (determinism). + assert.Equal(t, keyWithClaim, buildAwsStsCacheKey(&withClaim), "same config should produce same key") + + // Different regions must differ. + otherRegion := base + otherRegion.Region = "eu-west-1" + assert.NotEqual(t, keyBase, buildAwsStsCacheKey(&otherRegion), "different regions should have different keys") +} + +func TestAwsStsStrategy_multiBackendCache(t *testing.T) { + t.Parallel() + + // Two backends with different regions and role ARNs. + backendA := &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + FallbackRoleArn: "arn:aws:iam::111111111111:role/backend-a", + }, + } + backendB := &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "eu-west-1", + FallbackRoleArn: "arn:aws:iam::222222222222:role/backend-b", + }, + } + + s := NewAwsStsStrategy() + + // Validate both backends; each should produce a distinct cache entry. + // (Validate fails at NewExchanger in unit tests without AWS creds, but + // cache-key isolation is verified by confirming two entries exist after + // the field-validation stage returns an error for the same reason on both.) + // + // In practice this test verifies that Validate for backend A does not + // overwrite backend B's cached context — we confirm both calls fail at + // the same stage (NewExchanger), not due to interference from each other. + errA := s.Validate(backendA) + errB := s.Validate(backendB) + + // Both should fail at the same point (NewExchanger, no AWS credentials in + // the test environment) and NOT at field validation, which would indicate + // one backend's config corrupted the other's. + if errA != nil { + assert.NotContains(t, errA.Error(), "region required", "backend A field validation should pass") + assert.NotContains(t, errA.Error(), "aws_sts configuration required", "backend A config should not be nil") + } + if errB != nil { + assert.NotContains(t, errB.Error(), "region required", "backend B field validation should pass") + assert.NotContains(t, errB.Error(), "aws_sts configuration required", "backend B config should not be nil") + } +} diff --git a/pkg/vmcp/auth/strategies/tokenexchange.go b/pkg/vmcp/auth/strategies/tokenexchange.go index ed754fd972..ec9351ad8b 100644 --- a/pkg/vmcp/auth/strategies/tokenexchange.go +++ b/pkg/vmcp/auth/strategies/tokenexchange.go @@ -16,7 +16,7 @@ import ( "github.com/stacklok/toolhive-core/env" "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/auth/tokenexchange" + "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" ) diff --git a/pkg/vmcp/auth/strategies/tokenexchange_test.go b/pkg/vmcp/auth/strategies/tokenexchange_test.go index 9041bb9c67..078f605377 100644 --- a/pkg/vmcp/auth/strategies/tokenexchange_test.go +++ b/pkg/vmcp/auth/strategies/tokenexchange_test.go @@ -311,8 +311,10 @@ func TestTokenExchangeStrategy_Authenticate(t *testing.T) { strategy: func(server *httptest.Server) *authtypes.BackendAuthStrategy { return createTokenExchangeStrategy(server.URL) }, - expectError: true, - errorContains: "empty access_token", + expectError: true, + // oauth.ParseTokenResponse enforces RFC 6749 Section 5.1 centrally; + // the tokenexchange call site no longer duplicates the check. + errorContains: "missing access_token", }, { name: "exchanges upstream token when SubjectProviderName is set", diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index 4b1c905748..27fa75891b 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -42,6 +42,16 @@ const ( // This strategy injects an upstream IDP token obtained by the embedded // authorization server into requests to the backend service. StrategyTypeUpstreamInject = "upstream_inject" + + // StrategyTypeAwsSts identifies the AWS STS authentication strategy. + // This strategy exchanges incoming tokens for AWS STS temporary credentials + // and signs requests using SigV4. + StrategyTypeAwsSts = "aws_sts" + + // StrategyTypeOBO identifies the on-behalf-of (OBO) authentication strategy. + // The default upstream implementation returns ErrEnterpriseRequired from + // every method; an out-of-tree build registers a real converter. + StrategyTypeOBO = "obo" ) // BackendAuthStrategy defines how to authenticate to a specific backend. @@ -51,7 +61,7 @@ const ( // +kubebuilder:object:generate=true // +gendoc type BackendAuthStrategy struct { - // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject" + // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo" Type string `json:"type" yaml:"type"` // HeaderInjection contains configuration for header injection auth strategy. @@ -65,6 +75,10 @@ type BackendAuthStrategy struct { // UpstreamInject contains configuration for upstream inject auth strategy. // Used when Type = "upstream_inject". UpstreamInject *UpstreamInjectConfig `json:"upstreamInject,omitempty" yaml:"upstreamInject,omitempty"` + + // AwsSts contains configuration for AWS STS auth strategy. + // Used when Type = "aws_sts". + AwsSts *AwsStsConfig `json:"awsSts,omitempty" yaml:"awsSts,omitempty"` } // HeaderInjectionConfig configures the header injection auth strategy. @@ -135,3 +149,57 @@ type UpstreamInjectConfig struct { // embedded authorization server. Must match an entry in AuthServer.Upstreams. ProviderName string `json:"providerName" yaml:"providerName"` } + +// RoleMapping defines a rule for mapping JWT claims to IAM roles. +// Mappings are evaluated in priority order (lower number = higher priority). +// +kubebuilder:object:generate=true +// +gendoc +type RoleMapping struct { + // Claim is a simple claim value to match against the RoleClaim field. + Claim string `json:"claim,omitempty" yaml:"claim,omitempty"` + + // Matcher is a CEL expression for complex matching against JWT claims. + Matcher string `json:"matcher,omitempty" yaml:"matcher,omitempty"` + + // RoleArn is the IAM role ARN to assume when this mapping matches. + RoleArn string `json:"roleArn" yaml:"roleArn"` + + // Priority determines evaluation order (lower values = higher priority). + // Mirrors awssts.RoleMapping.Priority, which is *int because the role mapper + // uses math.MaxInt for nil-priority semantics in effectivePriority. + Priority *int `json:"priority,omitempty" yaml:"priority,omitempty"` +} + +// AwsStsConfig configures AWS STS authentication with SigV4 request signing. +// This strategy exchanges incoming tokens for AWS STS temporary credentials. +// +kubebuilder:object:generate=true +// +gendoc +type AwsStsConfig struct { + // Region is the AWS region for the STS endpoint and service. + Region string `json:"region" yaml:"region"` + + // Service is the AWS service name for SigV4 signing. + Service string `json:"service,omitempty" yaml:"service,omitempty"` + + // FallbackRoleArn is the IAM role ARN to assume when no role mappings match. + FallbackRoleArn string `json:"fallbackRoleArn,omitempty" yaml:"fallbackRoleArn,omitempty"` + + // RoleMappings defines claim-based role selection rules. + // +listType=atomic + RoleMappings []RoleMapping `json:"roleMappings,omitempty" yaml:"roleMappings,omitempty"` + + // RoleClaim is the JWT claim to use for role mapping evaluation. + RoleClaim string `json:"roleClaim,omitempty" yaml:"roleClaim,omitempty"` + + // SessionDuration is the duration in seconds for the STS session. + SessionDuration *int32 `json:"sessionDuration,omitempty" yaml:"sessionDuration,omitempty"` + + // SessionNameClaim is the JWT claim to use for the role session name. + SessionNameClaim string `json:"sessionNameClaim,omitempty" yaml:"sessionNameClaim,omitempty"` + + // SubjectProviderName selects which upstream provider's token to use as the + // web identity token for AssumeRoleWithWebIdentity. When set, the token is + // looked up from Identity.UpstreamTokens instead of the request's + // Authorization header. + SubjectProviderName string `json:"subjectProviderName,omitempty" yaml:"subjectProviderName,omitempty"` +} diff --git a/pkg/vmcp/auth/types/zz_generated.deepcopy.go b/pkg/vmcp/auth/types/zz_generated.deepcopy.go index bcb6a0b716..dc67f0f5e5 100644 --- a/pkg/vmcp/auth/types/zz_generated.deepcopy.go +++ b/pkg/vmcp/auth/types/zz_generated.deepcopy.go @@ -22,6 +22,33 @@ package types import () +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AwsStsConfig) DeepCopyInto(out *AwsStsConfig) { + *out = *in + if in.RoleMappings != nil { + in, out := &in.RoleMappings, &out.RoleMappings + *out = make([]RoleMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SessionDuration != nil { + in, out := &in.SessionDuration, &out.SessionDuration + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AwsStsConfig. +func (in *AwsStsConfig) DeepCopy() *AwsStsConfig { + if in == nil { + return nil + } + out := new(AwsStsConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BackendAuthStrategy) DeepCopyInto(out *BackendAuthStrategy) { *out = *in @@ -40,6 +67,11 @@ func (in *BackendAuthStrategy) DeepCopyInto(out *BackendAuthStrategy) { *out = new(UpstreamInjectConfig) **out = **in } + if in.AwsSts != nil { + in, out := &in.AwsSts, &out.AwsSts + *out = new(AwsStsConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthStrategy. @@ -67,6 +99,26 @@ func (in *HeaderInjectionConfig) DeepCopy() *HeaderInjectionConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { + *out = *in + if in.Priority != nil { + in, out := &in.Priority, &out.Priority + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleMapping. +func (in *RoleMapping) DeepCopy() *RoleMapping { + if in == nil { + return nil + } + out := new(RoleMapping) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TokenExchangeConfig) DeepCopyInto(out *TokenExchangeConfig) { *out = *in diff --git a/pkg/vmcp/cli/header_forward_env.go b/pkg/vmcp/cli/header_forward_env.go new file mode 100644 index 0000000000..1e5a84a285 --- /dev/null +++ b/pkg/vmcp/cli/header_forward_env.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "encoding/json" + "log/slog" + "strings" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt" +) + +// readHeaderForwardFromEnv reconstructs the per-backend +// vmcp.HeaderForwardConfig map by walking environment variables emitted by +// the operator on the vMCP pod. +// +// The operator emits one JSON-encoded manifest env var per backend named +// "TOOLHIVE_HEADER_FORWARD_". The JSON value carries +// every configured header for that backend with original (un-normalized) +// names preserved: +// +// { +// "addPlaintextHeaders": {"X-MCP-Toolsets":"projects,issues"}, +// "addHeadersFromSecret": {"X-Api-Key":"HEADER_FORWARD_X_API_KEY_"} +// } +// +// AddHeadersFromSecret values are secret IDENTIFIERS, not values. Secret +// values resolve later inside resolveHeaderForward via +// secrets.EnvironmentProvider, which reads TOOLHIVE_SECRET_ +// env vars (delivered separately via valueFrom.secretKeyRef so the value +// never enters the operator's view of the world). +// +// The map key is the normalized entry segment from the env-var suffix — +// the SAME value the operator's GenerateHeaderForwardManifestEnvVarName +// produces for that backend's name. Callers look up by passing the +// original backend name through ctrlutil.NormalizeHeaderForEnvVar before +// indexing. +func readHeaderForwardFromEnv(envEntries []string) map[string]*vmcp.HeaderForwardConfig { + out := make(map[string]*vmcp.HeaderForwardConfig) + for _, entry := range envEntries { + name, value, ok := strings.Cut(entry, "=") + if !ok || !strings.HasPrefix(name, wirefmt.ManifestEnvVarPrefix) { + continue + } + ownerSegment := strings.TrimPrefix(name, wirefmt.ManifestEnvVarPrefix) + + var cfg vmcp.HeaderForwardConfig + if err := json.Unmarshal([]byte(value), &cfg); err != nil { + // A malformed manifest is a programmer error in the operator; + // log and skip rather than fail the whole startup. The backend + // will simply have no headerForward attached. + slog.Warn("invalid headerForward manifest from env, skipping", + "envvar", name, "error", err) + continue + } + // wirefmt.NormalizeForEnvVar maps two distinct entry names with the + // same uppercased+sanitized form to the same env-var suffix (e.g. + // "github-copilot" and "github_copilot"). DNS-1123 forbids + // underscores in entry names, so this collision is unreachable in + // production today — but a future relaxation, a migration that + // allows mixed casing, or a third-party producing manifests can all + // land us here. Surface the collision loudly: the second config + // silently overwriting the first would mask a misconfiguration that + // is otherwise extremely hard to debug. + if _, dup := out[ownerSegment]; dup { + slog.Warn("duplicate headerForward manifest env var; later value overrides earlier", + "envvar", name, "ownerSegment", ownerSegment) + } + out[ownerSegment] = &cfg + } + return out +} diff --git a/pkg/vmcp/cli/header_forward_env_test.go b/pkg/vmcp/cli/header_forward_env_test.go new file mode 100644 index 0000000000..4683c08335 --- /dev/null +++ b/pkg/vmcp/cli/header_forward_env_test.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// TestReadHeaderForwardFromEnv covers reconstructing the per-backend +// HeaderForwardConfig from the JSON-encoded TOOLHIVE_HEADER_FORWARD_ +// env vars the operator emits on the vMCP pod. Original header casing / +// punctuation is preserved by the JSON value, so the runtime reconstructs +// "X-MCP-Toolsets" rather than "X_MCP_TOOLSETS". +// +// The map is keyed by the normalized entry segment from the env-var +// suffix; the discoverer normalizes Backend.Name through +// ctrlutil.NormalizeHeaderForEnvVar before indexing. +func TestReadHeaderForwardFromEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env []string + validate func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) + }{ + { + name: "no env vars yields empty map", + env: []string{"HOME=/root", "PATH=/bin"}, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + assert.Empty(t, m) + }, + }, + { + name: "plaintext header decodes preserving original casing and hyphens", + env: []string{ + `TOOLHIVE_HEADER_FORWARD_GITHUB_COPILOT={"addPlaintextHeaders":{"X-MCP-Toolsets":"projects,issues"}}`, + "PATH=/bin", + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + cfg := m["GITHUB_COPILOT"] + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{"X-MCP-Toolsets": "projects,issues"}, cfg.AddPlaintextHeaders) + assert.Empty(t, cfg.AddHeadersFromSecret) + }, + }, + { + name: "secret header decodes to identifier (value not in manifest)", + env: []string{ + `TOOLHIVE_HEADER_FORWARD_STRIPE={"addHeadersFromSecret":{"X-Api-Key":"HEADER_FORWARD_X_API_KEY_STRIPE"}}`, + // The secret-value env var carries the resolved value via + // secretKeyRef; the walker must NOT treat it as a manifest. + "TOOLHIVE_SECRET_HEADER_FORWARD_X_API_KEY_STRIPE=resolved-secret-value", + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + assert.NotContains(t, m, "X_API_KEY_STRIPE", "secret env var must not be parsed as a manifest") + cfg := m["STRIPE"] + require.NotNil(t, cfg) + assert.Empty(t, cfg.AddPlaintextHeaders) + assert.Equal(t, + map[string]string{"X-Api-Key": "HEADER_FORWARD_X_API_KEY_STRIPE"}, + cfg.AddHeadersFromSecret, + ) + }, + }, + { + name: "malformed manifest is skipped without failing other backends", + env: []string{ + `TOOLHIVE_HEADER_FORWARD_BROKEN=not-json`, + `TOOLHIVE_HEADER_FORWARD_DEMO={"addPlaintextHeaders":{"X-Trace":"ok"}}`, + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + assert.NotContains(t, m, "BROKEN") + cfg := m["DEMO"] + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{"X-Trace": "ok"}, cfg.AddPlaintextHeaders) + }, + }, + { + name: "mixed plaintext + secret in one manifest scoped per backend", + env: []string{ + `TOOLHIVE_HEADER_FORWARD_ALPHA={"addPlaintextHeaders":{"X-Trace":"alpha-trace"},"addHeadersFromSecret":{"X-Token":"HEADER_FORWARD_X_TOKEN_ALPHA"}}`, + `TOOLHIVE_HEADER_FORWARD_BETA={"addPlaintextHeaders":{"X-Trace":"beta-trace"}}`, + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + + alpha := m["ALPHA"] + require.NotNil(t, alpha) + assert.Equal(t, map[string]string{"X-Trace": "alpha-trace"}, alpha.AddPlaintextHeaders) + assert.Equal(t, + map[string]string{"X-Token": "HEADER_FORWARD_X_TOKEN_ALPHA"}, + alpha.AddHeadersFromSecret, + ) + + beta := m["BETA"] + require.NotNil(t, beta) + assert.Equal(t, map[string]string{"X-Trace": "beta-trace"}, beta.AddPlaintextHeaders) + assert.Empty(t, beta.AddHeadersFromSecret) + }, + }, + { + name: "malformed env entry without '=' is skipped", + env: []string{ + "NO_EQUALS_HERE", + `TOOLHIVE_HEADER_FORWARD_DEMO={"addPlaintextHeaders":{"X-Trace":"ok"}}`, + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + cfg := m["DEMO"] + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{"X-Trace": "ok"}, cfg.AddPlaintextHeaders) + }, + }, + { + // DNS-1123 forbids underscores in MCPServerEntry names today, so + // real entries with the same NormalizeForEnvVar output cannot + // coexist. We pass two manifests that share the same suffix + // directly to verify the runtime keeps "last wins" behavior and + // surfaces a warning rather than silently dropping the first. + name: "duplicate manifest suffix retains last value", + env: []string{ + `TOOLHIVE_HEADER_FORWARD_DUP={"addPlaintextHeaders":{"X-Trace":"first"}}`, + `TOOLHIVE_HEADER_FORWARD_DUP={"addPlaintextHeaders":{"X-Trace":"second"}}`, + }, + validate: func(t *testing.T, m map[string]*vmcp.HeaderForwardConfig) { + t.Helper() + cfg := m["DUP"] + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{"X-Trace": "second"}, cfg.AddPlaintextHeaders, + "duplicate manifest must keep the later value (last-write-wins)") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.validate(t, readHeaderForwardFromEnv(tt.env)) + }) + } +} diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 7714e35163..2fef2450fa 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -69,6 +69,10 @@ type ServeConfig struct { // the loaded config does not already define an audit section. EnableAudit bool + // SessionTTL is the inactivity timeout for vMCP sessions. + // Zero uses the server default (30m). Negative values fail validation. + SessionTTL time.Duration + // Optimizer tier selection (Phase 4 — flag-driven). // EnableOptimizer enables Tier 1 FTS5 keyword search (find_tool / call_tool). EnableOptimizer bool @@ -113,6 +117,9 @@ func Serve(ctx context.Context, cfg ServeConfig) error { if err := cfg.validateQuickModeHost(); err != nil { return err } + if cfg.SessionTTL < 0 { + return fmt.Errorf("session-ttl must be non-negative, got %s", cfg.SessionTTL) + } // Load and validate configuration — file path takes precedence over group quick mode. vmcpCfg, err := func() (*config.Config, error) { @@ -245,6 +252,15 @@ func Serve(ctx context.Context, cfg ServeConfig) error { slog.Info(fmt.Sprintf("Setting up incoming authentication (type: %s)", vmcpCfg.IncomingAuth.Type)) + if vmcpCfg.IncomingAuth.Type == config.IncomingAuthTypeAnonymous { + slog.Warn( + "vMCP is configured with anonymous incoming auth; all anonymous sessions share a single sentinel binding, "+ + "so possession of a session ID is sufficient to act as that session from any source. "+ + "Anonymous mode is intended for development only.", + "incoming_auth_type", config.IncomingAuthTypeAnonymous, + ) + } + // Configure health monitoring if enabled. var healthMonitorConfig *health.MonitorConfig if vmcpCfg.Operational != nil && @@ -329,16 +345,11 @@ func Serve(ctx context.Context, cfg ServeConfig) error { } envReader := &env.OSReader{} - sessionFactory, err := createSessionFactory( - envReader.Getenv("VMCP_SESSION_HMAC_SECRET"), - runtime.IsKubernetesRuntimeWithEnv(envReader), - outgoingRegistry, - agg, - backendTimeoutSessionOptions(vmcpCfg)..., - ) - if err != nil { - return err + if hmacSecret := envReader.Getenv("VMCP_SESSION_HMAC_SECRET"); hmacSecret != "" { + slog.Debug("VMCP_SESSION_HMAC_SECRET is set but no longer used after #5306; ignoring", + "env_var", "VMCP_SESSION_HMAC_SECRET") } + sessionFactory := createSessionFactory(outgoingRegistry, agg, backendTimeoutSessionOptions(vmcpCfg)...) // When the optimizer is enabled, its meta-tools must pass through the authz // response filter so they appear in tools/list. @@ -361,7 +372,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { } authMiddleware, authzMiddleware, authInfoHandler, err := - factory.NewIncomingAuthMiddleware(ctx, vmcpCfg.IncomingAuth, passThroughTools, upstreamReader, keyProvider) + factory.NewIncomingAuthMiddleware(ctx, vmcpCfg.IncomingAuth, vmcpCfg.Name, passThroughTools, upstreamReader, keyProvider) if err != nil { return fmt.Errorf("failed to create authentication middleware: %w", err) } @@ -374,6 +385,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { GroupRef: vmcpCfg.Group, Host: cfg.Host, Port: cfg.Port, + SessionTTL: sessionTTLFromConfig(cfg.SessionTTL, vmcpCfg), AuthMiddleware: authMiddleware, AuthzMiddleware: authzMiddleware, AuthInfoHandler: authInfoHandler, @@ -387,7 +399,6 @@ func Serve(ctx context.Context, cfg ServeConfig) error { OptimizerConfig: optCfg, SessionFactory: sessionFactory, SessionStorage: vmcpCfg.SessionStorage, - SessionTTL: time.Duration(vmcpCfg.SessionTTL), WriteTimeout: serverWriteTimeoutFromConfig(vmcpCfg), SessionOwnerAdvertiseURL: resolveSessionOwnerAdvertiseURL( envReader.Getenv(envVMCPSessionOwnerURL), @@ -574,10 +585,21 @@ func discoverBackends( if len(cfg.Backends) > 0 { // Static mode: use pre-configured backends from config. slog.Info(fmt.Sprintf("Static mode: using %d pre-configured backends", len(cfg.Backends))) + + // Reconstruct per-backend HeaderForwardConfig from env vars the + // operator emitted on this pod. Plaintext header values are inline + // in the JSON manifest; secret-backed headers carry only identifiers + // here and resolve later via secrets.EnvironmentProvider at request + // time. Map keys are the normalized entry segment from the env-var + // suffix; the discoverer normalizes Backend.Name through + // ctrlutil.NormalizeHeaderForEnvVar to look up the matching entry. + // Returns an empty map when no entry in the group declared + // headerForward — the common case. discoverer = aggregator.NewUnifiedBackendDiscovererWithStaticBackends( cfg.Backends, cfg.OutgoingAuth, cfg.Group, + readHeaderForwardFromEnv(os.Environ()), ) } else { // Dynamic mode: discover backends at runtime from the active workload manager (K8s or local). @@ -634,53 +656,18 @@ func runDiscovery( return backends, backendClient, outgoingRegistry, nil } -// createSessionFactory creates a MultiSessionFactory with HMAC-SHA256 token binding. -// The HMAC secret and Kubernetes detection are passed in as parameters (typically sourced -// from the VMCP_SESSION_HMAC_SECRET environment variable and runtime environment detection -// by the caller). -// -// Behavior: -// - If hmacSecret is non-empty: validates length and creates factory with the secret. -// - If running in Kubernetes without secret: returns error (production safety requirement). -// - Otherwise: logs warning and creates factory with default insecure secret. +// createSessionFactory creates a MultiSessionFactory backed by the provided outgoing +// auth registry and optional aggregator. When agg is non-nil, sessions gain access +// to aggregated backend metadata; pass nil for single-backend deployments. func createSessionFactory( - hmacSecret string, - isKubernetes bool, outgoingRegistry vmcpauth.OutgoingAuthRegistry, agg aggregator.Aggregator, extraOpts ...vmcpsession.MultiSessionFactoryOption, -) (vmcpsession.MultiSessionFactory, error) { - const minRecommendedSecretLen = 32 - - opts := []vmcpsession.MultiSessionFactoryOption{} +) vmcpsession.MultiSessionFactory { + var opts []vmcpsession.MultiSessionFactoryOption if agg != nil { opts = append(opts, vmcpsession.WithAggregator(agg)) } opts = append(opts, extraOpts...) - - if hmacSecret != "" { - if secretLen := len(hmacSecret); secretLen < minRecommendedSecretLen { - // G706: Safe - only logging integer length, not the secret itself. - slog.Warn( //nolint:gosec - "HMAC secret is shorter than recommended length - consider using a longer secret", - "actual_length", secretLen, - "recommended_length", minRecommendedSecretLen, - ) - } - slog.Info("using provided HMAC secret for session token binding") - opts = append(opts, vmcpsession.WithHMACSecret([]byte(hmacSecret))) - return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil - } - - // No secret provided — fail fast in Kubernetes (production environment). - if isKubernetes { - return nil, fmt.Errorf( - "an HMAC secret is required when running in Kubernetes (set VMCP_SESSION_HMAC_SECRET). " + - "Generate a secure secret with: openssl rand -base64 32", - ) - } - - // Development mode: use default insecure secret with warning. - slog.Warn("no HMAC secret provided - using default insecure secret (NOT recommended for production)") - return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil + return vmcpsession.NewSessionFactory(outgoingRegistry, opts...) } diff --git a/pkg/vmcp/cli/serve_test.go b/pkg/vmcp/cli/serve_test.go index 667b285779..c486e22a5c 100644 --- a/pkg/vmcp/cli/serve_test.go +++ b/pkg/vmcp/cli/serve_test.go @@ -17,6 +17,7 @@ import ( authserverconfig "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks" "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -165,45 +166,27 @@ func newSessionFactoryMocks(t *testing.T) (*clientmocks.MockOutgoingAuthRegistry return clientmocks.NewMockOutgoingAuthRegistry(ctrl), aggregatormocks.NewMockAggregator(ctrl) } -func TestCreateSessionFactory_WithHMACSecret(t *testing.T) { +func TestCreateSessionFactory(t *testing.T) { t.Parallel() - registry, agg := newSessionFactoryMocks(t) - factory, err := createSessionFactory("a-sufficiently-long-hmac-secret-value-32b", false, registry, agg) - require.NoError(t, err) - require.NotNil(t, factory) -} - -func TestCreateSessionFactory_HMACSecretExactly32Bytes(t *testing.T) { - t.Parallel() - registry, agg := newSessionFactoryMocks(t) - factory, err := createSessionFactory("12345678901234567890123456789012", false, registry, agg) - require.NoError(t, err) - require.NotNil(t, factory) -} - -func TestCreateSessionFactory_ShortHMACSecret(t *testing.T) { - t.Parallel() - registry, agg := newSessionFactoryMocks(t) - factory, err := createSessionFactory("short", false, registry, agg) - require.NoError(t, err) - require.NotNil(t, factory) -} - -func TestCreateSessionFactory_NoSecretNonKubernetes(t *testing.T) { - t.Parallel() - registry, agg := newSessionFactoryMocks(t) - factory, err := createSessionFactory("", false, registry, agg) - require.NoError(t, err) - require.NotNil(t, factory) -} - -func TestCreateSessionFactory_NoSecretKubernetes(t *testing.T) { - t.Parallel() - registry, agg := newSessionFactoryMocks(t) - factory, err := createSessionFactory("", true, registry, agg) - require.Error(t, err) - require.ErrorContains(t, err, "an HMAC secret is required when running in Kubernetes") - require.Nil(t, factory) + tests := []struct { + name string + useAgg bool + }{ + {name: "with aggregator", useAgg: true}, + {name: "without aggregator", useAgg: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + var aggArg aggregator.Aggregator + if tc.useAgg { + aggArg = agg + } + factory := createSessionFactory(registry, aggArg) + require.NotNil(t, factory) + }) + } } // TestRunDiscovery_KubernetesGroupNotFound exercises the Kubernetes-specific branch diff --git a/pkg/vmcp/cli/serve_timeouts.go b/pkg/vmcp/cli/serve_timeouts.go index be6dd4c4da..efc93957a1 100644 --- a/pkg/vmcp/cli/serve_timeouts.go +++ b/pkg/vmcp/cli/serve_timeouts.go @@ -86,3 +86,16 @@ func resolveSessionOwnerAdvertiseURL(explicit, podIP string, port int) string { } return fmt.Sprintf("http://%s/mcp", net.JoinHostPort(podIP, fmt.Sprintf("%d", port))) } + +// sessionTTLFromConfig returns the vMCP session TTL. The --session-ttl flag +// (upstream) wins when set; otherwise the fork's config-file sessionTTL is +// used. Zero lets the server apply its default. +func sessionTTLFromConfig(flagTTL time.Duration, cfg *config.Config) time.Duration { + if flagTTL > 0 { + return flagTTL + } + if cfg == nil { + return 0 + } + return time.Duration(cfg.SessionTTL) +} diff --git a/pkg/vmcp/client/client.go b/pkg/vmcp/client/client.go index 50b0b04a17..ceb1989262 100644 --- a/pkg/vmcp/client/client.go +++ b/pkg/vmcp/client/client.go @@ -27,11 +27,13 @@ import ( "go.opentelemetry.io/otel/propagation" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/versions" "github.com/stacklok/toolhive/pkg/vmcp" vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" "github.com/stacklok/toolhive/pkg/vmcp/conversion" + "github.com/stacklok/toolhive/pkg/vmcp/headerforward" "github.com/stacklok/toolhive/pkg/vmcp/health" healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" ) @@ -65,6 +67,11 @@ type httpBackendClient struct { // registry manages authentication strategies for outgoing requests to backend MCP servers. // Must not be nil - use UnauthenticatedStrategy for no authentication. registry vmcpauth.OutgoingAuthRegistry + + // secretsProvider resolves TOOLHIVE_SECRET_ env vars at client-creation + // time for per-backend header-forward injection. Nil when no backends declare + // headerForward.AddHeadersFromSecret — plaintext-only backends do not require it. + secretsProvider secrets.Provider } // NewHTTPBackendClient creates a new HTTP-based backend client. @@ -81,7 +88,8 @@ func NewHTTPBackendClient(registry vmcpauth.OutgoingAuthRegistry) (vmcp.BackendC } c := &httpBackendClient{ - registry: registry, + registry: registry, + secretsProvider: secrets.NewEnvironmentProvider(), } c.clientFactory = c.defaultClientFactory return c, nil @@ -170,31 +178,79 @@ func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -// identityPropagatingRoundTripper propagates identity and health-check markers to backend HTTP requests. -// This ensures that identity information from the vMCP handler is available for authentication -// strategies that need it (e.g., token exchange). +// identityPropagatingRoundTripper propagates the health-check marker and a +// fallback identity to backend HTTP requests. +// +// Identity invariant: an identity already present on req.Context() is NEVER +// overridden. The per-request identity placed on the request context by +// auth.TokenValidator.Middleware carries the freshest upstream tokens (the +// middleware refreshes them transparently on every incoming request via +// upstreamtoken.InProcessService.GetAllValidTokens). Overriding it with a +// snapshot captured at session-init time would silently re-inject stale +// upstream access tokens on every backend call, forcing users to re-auth +// once the captured access token expired (see issue #5323). +// +// The fallback identity is used only when req.Context() carries no identity +// at all. This covers mcp-go's streamable-HTTP Close() path, which constructs +// its DELETE request from context.Background() and therefore loses any +// identity attached to the original tool-call context. Without a fallback, +// auth strategies (UpstreamInjectStrategy, TokenExchangeStrategy, AWSSTSStrategy) +// would reject the teardown DELETE with "no identity found in context" — the +// teardown would still complete locally, but the upstream backend would see +// an unauthenticated DELETE. The fallback may itself be stale, which is +// acceptable for a session-close: the request is best-effort and any 401 +// from the upstream does not affect functional behavior. // -// The health-check marker is stored at transport creation time and re-injected into every -// outgoing request, including the DELETE that mcp-go sends when closing a streamable-HTTP -// session. Without this, mcp-go's Close() creates a fresh context.Background()-based request -// that loses the health-check marker, causing auth strategies (UpstreamInjectStrategy, -// TokenExchangeStrategy) to fail with "no identity found in context". +// The health-check marker is similarly captured at transport creation time and +// re-injected into every outgoing request — including the Close() DELETE — so +// that auth strategies correctly skip authentication for health probes even +// when the request context has been replaced with context.Background(). +// +// This is the canonical location for the #5323 fallback-only identity +// invariant. A near-clone exists at identityRoundTripper in +// pkg/vmcp/session/internal/backend/mcp_session.go (without isHealthCheck +// support, since health probes do not flow through the session-backed +// connector). Keep the invariant in sync across both implementations until +// #5333 lands a shared transport. type identityPropagatingRoundTripper struct { - base http.RoundTripper - identity *auth.Identity - isHealthCheck bool + base http.RoundTripper + // fallbackIdentity is injected only when req.Context() carries no identity. + // It must never override a non-nil identity present on the request context. + // + // Shared across all concurrent RoundTrip invocations on this transport; the + // pointed-to *auth.Identity (including UpstreamTokens) MUST be treated as + // immutable — see pkg/auth/identity.go. + fallbackIdentity *auth.Identity + isHealthCheck bool } -// RoundTrip implements http.RoundTripper by adding identity and health-check marker to the request context. +// RoundTrip implements http.RoundTripper. +// +// It preserves any identity already present on req.Context() (the fresh, +// per-request identity placed there by TokenValidator.Middleware). When the +// request context carries no identity — for example, mcp-go's Close() DELETE +// is constructed from context.Background() — the captured fallback identity +// is injected so session-teardown requests can still authenticate. The +// health-check marker is always re-injected when configured. func (i *identityPropagatingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { ctx := req.Context() - if i.identity != nil { - ctx = auth.WithIdentity(ctx, i.identity) + mutated := false + + // Inject the fallback identity ONLY when the request context carries no + // identity. We must never overwrite a non-nil identity already on ctx — + // doing so would clobber the freshly refreshed upstream tokens that the + // auth middleware places on every incoming request (see #5323). + if _, hasIdentity := auth.IdentityFromContext(ctx); !hasIdentity && i.fallbackIdentity != nil { + ctx = auth.WithIdentity(ctx, i.fallbackIdentity) + mutated = true } + if i.isHealthCheck { ctx = healthcontext.WithHealthCheckMarker(ctx) + mutated = true } - if i.identity != nil || i.isHealthCheck { + + if mutated { req = req.Clone(ctx) } return i.base.RoundTrip(req) @@ -313,16 +369,50 @@ func (h *httpBackendClient) defaultClientFactory(ctx context.Context, target *vm target: target, } - // Extract identity and health-check marker from context and propagate them to backend - // requests. The health-check marker must be carried through to the DELETE request that - // mcp-go emits when closing a streamable-HTTP session: mcp-go creates that request with - // context.Background(), which loses both the identity and the health-check marker that - // were present on the original ListCapabilities call context. - identity, _ := auth.IdentityFromContext(ctx) + // Inject per-backend HTTP headers from MCPServerEntry.spec.headerForward. + // Resolves plaintext + secret-backed headers once here; auth (inner) always + // wins over user-supplied headers because it runs after this tripper. + baseTransport, err = headerforward.BuildHeaderForwardTripper( + ctx, baseTransport, target.HeaderForward, h.secretsProvider, target.WorkloadID, + ) + if err != nil { + return nil, fmt.Errorf("failed to build header-forward transport: %w", err) + } + + // Capture a fallback identity and the health-check marker from the construction + // context. These are used ONLY when the per-request context lacks them. + // + // Scope note: httpBackendClient calls this factory on every CallTool / + // ReadResource / GetPrompt / ListCapabilities with the per-call context, so + // the captured "fallback" is per-call, not per-session, in this path. The + // always-stale capture-at-session-init behavior that caused #5323 lives in + // the persistent-session path (pkg/vmcp/session/internal/backend); this + // transport uses the same RoundTripper out of consistency, not because it + // suffered the same bug. + // + // Fallback injection scope: the round-tripper injects the captured fallback + // identity on ANY outgoing request whose context lacks an identity — not + // only the teardown DELETE. In the current code paths the only known caller + // that drops the per-request identity is mcp-go's streamable-HTTP Close() + // (which builds its DELETE from context.Background()), so in practice the + // fallback only fires for teardown. If a future code path wraps the client + // with a context that doesn't carry identity (audit, telemetry, background + // reconciliation), it will be silently authenticated using the captured + // snapshot. Add an explicit method/URL gate if that becomes undesirable. + // + // - Normal backend requests inherit the fresh, per-request identity placed on + // the request context by auth.TokenValidator.Middleware. The transport + // preserves it untouched so upstream-token refresh is not bypassed (#5323). + // + // - mcp-go's streamable-HTTP Close() builds its DELETE from context.Background(), + // which loses both the identity and the health-check marker. Re-injecting them + // here keeps the teardown DELETE authenticated and tagged as a health-check + // probe when the original session was a probe (#4613). + fallbackIdentity, _ := auth.IdentityFromContext(ctx) baseTransport = &identityPropagatingRoundTripper{ - base: baseTransport, - identity: identity, - isHealthCheck: healthcontext.IsHealthCheck(ctx), + base: baseTransport, + fallbackIdentity: fallbackIdentity, + isHealthCheck: healthcontext.IsHealthCheck(ctx), } // Mark inter-vMCP health probe traffic on the wire so the receiving vMCP can @@ -452,6 +542,17 @@ func wrapBackendError(err error, backendID string, operation string) error { return fmt.Errorf("%w: failed to %s for backend %s: %v", vmcp.ErrAuthenticationFailed, operation, backendID, err) } + // transport.ErrAuthorizationRequired is returned (wrapped in *transport.Error + // and *transport.AuthorizationRequiredError) for 401 responses with a + // WWW-Authenticate header. transport.ErrOAuthAuthorizationRequired is the + // companion sentinel from the OAuth-handler path. Both must map to + // ErrAuthenticationFailed so health monitoring engages the auth-aware + // branch (#4935) instead of treating the probe as unhealthy (#5223). + if errors.Is(err, transport.ErrAuthorizationRequired) || + errors.Is(err, transport.ErrOAuthAuthorizationRequired) { + return fmt.Errorf("%w: failed to %s for backend %s: %v", + vmcp.ErrAuthenticationFailed, operation, backendID, err) + } // ErrLegacySSEServer is returned for any 4xx (except 401) on initialize POST. // This includes 403 (auth rejection) and 404/405 (endpoint not found/method not allowed). // We cannot distinguish auth failures from routing errors without the raw status code, diff --git a/pkg/vmcp/client/client_test.go b/pkg/vmcp/client/client_test.go index a11ef37736..8d4022b622 100644 --- a/pkg/vmcp/client/client_test.go +++ b/pkg/vmcp/client/client_test.go @@ -1048,6 +1048,34 @@ func TestWrapBackendError(t *testing.T) { err: fmt.Errorf("request failed with status 504: Gateway Timeout"), wantSentinel: vmcp.ErrTimeout, }, + { + // mcp-go v0.49.0 added ErrAuthorizationRequired for 401 responses + // with a WWW-Authenticate header. Issue #5223: probe of GitHub + // Copilot's MCP returns this; without recognizing the sentinel here + // the error falls through to ErrBackendUnavailable and the health + // monitor never engages the auth-aware branch (#4935). + name: "ErrAuthorizationRequired maps to ErrAuthenticationFailed", + err: transport.ErrAuthorizationRequired, + wantSentinel: vmcp.ErrAuthenticationFailed, + }, + { + // Production chain mcp-go produces: transport.Error wrapping + // *AuthorizationRequiredError. Error() formats as + // "transport error: authorization required" — byte-for-byte + // the string seen in North's WARN logs (issue #5223). Both + // layers Unwrap() to the sentinel so errors.Is must succeed. + name: "wrapped AuthorizationRequiredError (production chain) maps to ErrAuthenticationFailed", + err: transport.NewError(&transport.AuthorizationRequiredError{}), + wantSentinel: vmcp.ErrAuthenticationFailed, + }, + { + // Companion sentinel for the OAuth-handler path. Less hot in + // practice but belongs in the same matcher block; the existing + // transport.ErrUnauthorized check would not catch it. + name: "ErrOAuthAuthorizationRequired maps to ErrAuthenticationFailed", + err: transport.ErrOAuthAuthorizationRequired, + wantSentinel: vmcp.ErrAuthenticationFailed, + }, } for _, tt := range tests { @@ -1073,13 +1101,98 @@ func TestWrapBackendError(t *testing.T) { // --------------------------------------------------------------------------- // identityPropagatingRoundTripper // --------------------------------------------------------------------------- +// +// See identityPropagatingRoundTripper in pkg/vmcp/client/client.go for the +// canonical description of the #5323 fallback-only identity invariant. +// +// Tests below are grouped to reflect this hierarchy: +// 1. Per-request identity (normal path) — *_PerRequestIdentity_* +// 2. Fallback identity (no-identity-on-context) — *_FallbackIdentity_* +// 3. Health-check marker propagation — *_HealthCheck* + +// --- Per-request identity (normal path) ----------------------------------- + +// TestIdentityPropagatingRoundTripper_PerRequestIdentity_FlowsThroughUnchanged +// verifies the normal per-request flow: identity is on req.Context() (placed by +// TokenValidator.Middleware), no fallback is configured, and the transport leaves +// the identity untouched so the freshly refreshed upstream tokens reach the +// downstream auth strategies. This is the path taken on every authenticated +// tool call. +func TestIdentityPropagatingRoundTripper_PerRequestIdentity_FlowsThroughUnchanged(t *testing.T) { + t.Parallel() + + base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: nil} + + fresh := &pkgauth.Identity{ + PrincipalInfo: pkgauth.PrincipalInfo{Subject: "fresh-user"}, + UpstreamTokens: map[string]string{"provider": "fresh-token"}, + } + ctx := pkgauth.WithIdentity(context.Background(), fresh) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://backend.example.com/mcp", nil) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + require.NotNil(t, base.capturedReq) + got, ok := pkgauth.IdentityFromContext(base.capturedReq.Context()) + require.True(t, ok) + assert.Equal(t, "fresh-user", got.Subject) + assert.Equal(t, "fresh-token", got.UpstreamTokens["provider"]) +} + +// TestIdentityPropagatingRoundTripper_PerRequestIdentity_NotOverriddenByFallback +// is the regression test for issue #5323: a fresh identity already on +// req.Context() (placed there by TokenValidator.Middleware on every incoming +// request) must survive the transport untouched, even when a stale fallback +// identity is captured. Overriding it would silently re-inject stale upstream +// tokens on every backend call and is exactly the bug this PR fixes. +func TestIdentityPropagatingRoundTripper_PerRequestIdentity_NotOverriddenByFallback(t *testing.T) { + t.Parallel() + + base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} + stale := &pkgauth.Identity{ + PrincipalInfo: pkgauth.PrincipalInfo{Subject: "stale-user"}, + UpstreamTokens: map[string]string{ + "provider": "stale-token", + }, + } + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: stale} + + fresh := &pkgauth.Identity{ + PrincipalInfo: pkgauth.PrincipalInfo{Subject: "fresh-user"}, + UpstreamTokens: map[string]string{ + "provider": "fresh-token", + }, + } + ctx := pkgauth.WithIdentity(context.Background(), fresh) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://backend.example.com/mcp", nil) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) -func TestIdentityPropagatingRoundTripper_WithIdentity_PropagatesIdentityInContext(t *testing.T) { + require.NotNil(t, base.capturedReq) + got, ok := pkgauth.IdentityFromContext(base.capturedReq.Context()) + require.True(t, ok, "identity from req.Context() must be present downstream") + assert.Equal(t, "fresh-user", got.Subject, + "identity on req.Context() must not be overridden by the captured fallback (#5323)") + assert.Equal(t, "fresh-token", got.UpstreamTokens["provider"], + "fresh upstream tokens must reach auth strategies unchanged (#5323)") +} + +// --- Fallback identity (teardown / no-identity-on-context path) ------------ + +// TestIdentityPropagatingRoundTripper_FallbackIdentity_InjectedWhenContextLacksIdentity +// verifies that the fallback identity is injected when req.Context() carries no +// identity (e.g. mcp-go's Close() DELETE built from context.Background()). +func TestIdentityPropagatingRoundTripper_FallbackIdentity_InjectedWhenContextLacksIdentity(t *testing.T) { t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} - identity := &pkgauth.Identity{PrincipalInfo: pkgauth.PrincipalInfo{Subject: "user-1"}} - rt := &identityPropagatingRoundTripper{base: base, identity: identity} + fallback := &pkgauth.Identity{PrincipalInfo: pkgauth.PrincipalInfo{Subject: "fallback-user"}} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: fallback} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://backend.example.com/mcp", nil) require.NoError(t, err) @@ -1089,15 +1202,15 @@ func TestIdentityPropagatingRoundTripper_WithIdentity_PropagatesIdentityInContex require.NotNil(t, base.capturedReq) got, ok := pkgauth.IdentityFromContext(base.capturedReq.Context()) - require.True(t, ok, "identity should be in downstream request context") - assert.Equal(t, "user-1", got.Subject) + require.True(t, ok, "fallback identity should be in downstream request context") + assert.Equal(t, "fallback-user", got.Subject) } -func TestIdentityPropagatingRoundTripper_NilIdentity_NoIdentityInContext(t *testing.T) { +func TestIdentityPropagatingRoundTripper_FallbackIdentity_NilFallback_NoIdentityInContext(t *testing.T) { t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} - rt := &identityPropagatingRoundTripper{base: base, identity: nil} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: nil} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://backend.example.com/mcp", nil) require.NoError(t, err) @@ -1107,14 +1220,16 @@ func TestIdentityPropagatingRoundTripper_NilIdentity_NoIdentityInContext(t *test require.NotNil(t, base.capturedReq) _, ok := pkgauth.IdentityFromContext(base.capturedReq.Context()) - assert.False(t, ok, "no identity should be in downstream context when nil identity configured") + assert.False(t, ok, "no identity should be in downstream context when no fallback and no req-ctx identity") } +// --- Health-check marker propagation --------------------------------------- + func TestIdentityPropagatingRoundTripper_HealthCheck_PropagatesMarker(t *testing.T) { t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} - rt := &identityPropagatingRoundTripper{base: base, identity: nil, isHealthCheck: true} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: nil, isHealthCheck: true} // Simulate mcp-go Close(): request created with context.Background(), no health check marker. req, err := http.NewRequestWithContext(context.Background(), http.MethodDelete, "http://backend.example.com/mcp", nil) @@ -1132,7 +1247,7 @@ func TestIdentityPropagatingRoundTripper_NonHealthCheck_NoMarkerAdded(t *testing t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} - rt := &identityPropagatingRoundTripper{base: base, identity: nil, isHealthCheck: false} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: nil, isHealthCheck: false} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://backend.example.com/mcp", nil) require.NoError(t, err) @@ -1145,12 +1260,12 @@ func TestIdentityPropagatingRoundTripper_NonHealthCheck_NoMarkerAdded(t *testing "health check marker should not be injected for non-health-check transports") } -func TestIdentityPropagatingRoundTripper_HealthCheckWithIdentity_PropagatesBoth(t *testing.T) { +func TestIdentityPropagatingRoundTripper_HealthCheckWithFallbackIdentity_PropagatesBoth(t *testing.T) { t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} identity := &pkgauth.Identity{PrincipalInfo: pkgauth.PrincipalInfo{Subject: "svc-account"}} - rt := &identityPropagatingRoundTripper{base: base, identity: identity, isHealthCheck: true} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: identity, isHealthCheck: true} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://backend.example.com/mcp", nil) require.NoError(t, err) @@ -1174,7 +1289,7 @@ func TestIdentityPropagatingRoundTripper_HealthCheckClose_OriginalRequestContext t.Parallel() base := &mockRoundTripper{response: &http.Response{StatusCode: http.StatusOK}} - rt := &identityPropagatingRoundTripper{base: base, identity: nil, isHealthCheck: true} + rt := &identityPropagatingRoundTripper{base: base, fallbackIdentity: nil, isHealthCheck: true} originalCtx := context.Background() // no health check marker — simulates mcp-go Close() req, err := http.NewRequestWithContext(originalCtx, http.MethodDelete, "http://backend.example.com/mcp", nil) diff --git a/pkg/vmcp/client/header_forward_integration_test.go b/pkg/vmcp/client/header_forward_integration_test.go new file mode 100644 index 0000000000..45dacc88ba --- /dev/null +++ b/pkg/vmcp/client/header_forward_integration_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" +) + +// TestHeaderForward_EndToEnd_ThroughHTTPBackendClient drives the full +// NewHTTPBackendClient → defaultClientFactory wiring. It verifies that: +// +// 1. The plaintext header reaches the backend +// 2. A secret-backed header is resolved through the EnvironmentProvider +// using the TOOLHIVE_SECRET_ convention and reaches the backend +// +// Anything below this is exercised by the round-tripper unit tests, but only +// an end-to-end test confirms the *factory wiring itself* (provider lookup, +// transport stacking, ctx threading) is correct in production code paths. +func TestHeaderForward_EndToEnd_ThroughHTTPBackendClient(t *testing.T) { + // t.Setenv forbids t.Parallel — fine here, this test is fast. + const ( + identifier = "HEADER_FORWARD_X_API_KEY_BACKEND_E2E" + envVarName = secrets.EnvVarPrefix + identifier + secretVal = "super-secret-resolved-from-env" + ) + t.Setenv(envVarName, secretVal) + + captured := newCapturingMCPServer(t) + t.Cleanup(captured.server.Close) + + registry := auth.NewDefaultOutgoingAuthRegistry() + require.NoError(t, registry.RegisterStrategy("unauthenticated", &strategies.UnauthenticatedStrategy{})) + + backendClient, err := vmcpclient.NewHTTPBackendClient(registry) + require.NoError(t, err) + + target := &vmcp.BackendTarget{ + WorkloadID: "backend-e2e", + WorkloadName: "Backend E2E", + BaseURL: captured.server.URL, + TransportType: "streamable-http", + HeaderForward: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + "X-Tenant": "acme", + }, + AddHeadersFromSecret: map[string]string{ + "X-API-Key": identifier, + }, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // CallTool drives the full Initialize → tools/list flow through the + // streamable-HTTP transport. We don't care about the call result — only + // that the request reached the test server with the configured headers. + _, _ = backendClient.CallTool(ctx, target, "anything", map[string]any{}, nil) + + captured.mu.Lock() + defer captured.mu.Unlock() + require.NotEmpty(t, captured.headers, "test server received no requests") + assert.Equal(t, "acme", captured.headers.Get("X-Tenant"), + "plaintext header must be forwarded by the e2e factory wiring") + assert.Equal(t, secretVal, captured.headers.Get("X-Api-Key"), + "secret-backed header must be resolved via EnvironmentProvider and forwarded") +} + +// capturingMCPServer is a minimal HTTP server that records the headers of +// every inbound request and returns a stub MCP initialize response. It does +// not implement the full MCP protocol — just enough for the streamable-HTTP +// client to send at least one request whose headers we can inspect. +type capturingMCPServer struct { + server *httptest.Server + mu sync.Mutex + headers http.Header +} + +func newCapturingMCPServer(t *testing.T) *capturingMCPServer { + t.Helper() + c := &capturingMCPServer{} + c.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + // Only capture the FIRST request — that's the one carrying the headers + // resolved at client-construction time. Subsequent requests on the same + // connection would see the same headers but recording every one makes + // the test read confusing. + if c.headers == nil { + c.headers = r.Header.Clone() + } + c.mu.Unlock() + + // Drain the body so the underlying transport can reuse the connection. + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + // Return a minimal JSON-RPC error response so the client unwinds + // cleanly. We don't need a full Initialize because we already have + // what we need (the headers) before the client gives up. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "error": map[string]any{ + "code": -32601, + "message": "method not implemented in test server", + }, + }) + })) + // Sanity check — the test server URL must be plain HTTP (no TLS) so the + // transport doesn't try to verify a self-signed cert against system roots. + require.True(t, strings.HasPrefix(c.server.URL, "http://"), + "capturingMCPServer must serve plain HTTP") + return c +} diff --git a/pkg/vmcp/composer/elicitation_handler.go b/pkg/vmcp/composer/elicitation_handler.go index 156283318c..78f1086bd3 100644 --- a/pkg/vmcp/composer/elicitation_handler.go +++ b/pkg/vmcp/composer/elicitation_handler.go @@ -4,8 +4,6 @@ // Package composer provides composite tool workflow execution for Virtual MCP Server. package composer -//go:generate mockgen -destination=mocks/mock_sdk_elicitation_requester.go -package=mocks github.com/stacklok/toolhive/pkg/vmcp/composer SDKElicitationRequester - import ( "context" "encoding/json" @@ -14,7 +12,7 @@ import ( "log/slog" "time" - "github.com/mark3labs/mcp-go/mcp" + "github.com/stacklok/toolhive/pkg/vmcp" ) const ( @@ -67,21 +65,12 @@ var ( // ErrContentTooLarge is returned when response content exceeds size limits. ErrContentTooLarge = errors.New("response content too large") -) -// SDKElicitationRequester is an abstraction for the underlying MCP SDK's elicitation functionality. -// -// This interface wraps the SDK's RequestElicitation method, enabling: -// - Migration from mark3labs SDK to official SDK without changing workflow code -// - Mocking for unit tests -// - Custom implementations for testing -// -// The SDK handles JSON-RPC ID correlation internally - our wrapper doesn't need to track IDs. -type SDKElicitationRequester interface { - // RequestElicitation sends an elicitation request via the SDK and blocks for response. - // This wraps the SDK's synchronous RequestElicitation method. - RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) -} + // ErrElicitationNotConfigured is returned when a workflow reaches an elicitation + // step but the handler was constructed without a requester (nil). It converts + // what would be a nil-pointer dereference into a clean, recoverable error. + ErrElicitationNotConfigured = errors.New("elicitation requester not configured") +) // DefaultElicitationHandler implements ElicitationProtocolHandler as a thin wrapper around the MCP SDK. // @@ -98,19 +87,20 @@ type SDKElicitationRequester interface { // // Thread-safety: Safe for concurrent calls. The underlying SDK session must be thread-safe. type DefaultElicitationHandler struct { - // sdkRequester wraps the MCP SDK's elicitation functionality. - // This abstraction enables migration to official SDK without changing our code. - sdkRequester SDKElicitationRequester + // requester drives elicitation through the domain seam. All translation + // to/from the underlying SDK happens inside the requester implementation, + // so this handler never references SDK types. + requester vmcp.ElicitationRequester } // NewDefaultElicitationHandler creates a new SDK-agnostic elicitation handler. // -// The sdkRequester parameter wraps the underlying MCP SDK's RequestElicitation functionality. -// For mark3labs SDK, this would be the MCPServer instance. -// For a future official SDK, this would be replaced without changing workflow code. -func NewDefaultElicitationHandler(sdkRequester SDKElicitationRequester) *DefaultElicitationHandler { +// The requester parameter is the domain ElicitationRequester; the transport +// adapter (pkg/vmcp/server) implements it and is the sole point that translates +// to/from the underlying MCP SDK. Swapping SDKs requires no change here. +func NewDefaultElicitationHandler(requester vmcp.ElicitationRequester) *DefaultElicitationHandler { return &DefaultElicitationHandler{ - sdkRequester: sdkRequester, + requester: requester, } } @@ -118,10 +108,10 @@ func NewDefaultElicitationHandler(sdkRequester SDKElicitationRequester) *Default // // This is a synchronous blocking call that: // 1. Validates configuration and enforces security limits (timeout, schema size/depth, content size) -// 2. Applies timeout constraints (default 5min, max 1hour) -// 3. Delegates to SDK's RequestElicitation (which handles JSON-RPC ID correlation) +// 2. Applies timeout constraints (default 5min, max 10min) +// 3. Delegates to the domain requester (the adapter handles JSON-RPC ID correlation via the SDK) // 4. Validates response content size -// 5. Transforms SDK response to domain type +// 5. Transforms the requester result into the composer's response type // // Per security review: Enforces max timeout (10 minutes), schema size (100KB), schema depth (10 levels), // and response content size (1MB) to prevent resource exhaustion attacks. @@ -138,6 +128,12 @@ func (h *DefaultElicitationHandler) RequestElicitation( ) (*ElicitationResponse, error) { slog.Debug("requesting elicitation", "workflow", workflowID, "step", stepID) + // A nil requester means elicitation was never wired (e.g. the core was built + // with a nil ElicitationRequester). Fail cleanly instead of dereferencing nil. + if h.requester == nil { + return nil, fmt.Errorf("%w: step %s", ErrElicitationNotConfigured, stepID) + } + // Validate configuration if err := validateConfig(config); err != nil { return nil, err @@ -163,20 +159,19 @@ func (h *DefaultElicitationHandler) RequestElicitation( reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Create MCP elicitation request per 2025-06-18 spec - // The SDK will assign JSON-RPC ID and handle correlation internally - mcpReq := mcp.ElicitationRequest{ - Params: mcp.ElicitationParams{ - Message: config.Message, - RequestedSchema: config.Schema, - }, + // Build the domain elicitation request (form-mode only). + // The adapter translates this to the SDK request and the SDK assigns the + // JSON-RPC ID and handles correlation internally. + req := vmcp.ElicitationRequest{ + Message: config.Message, + RequestedSchema: config.Schema, } slog.Debug("sending elicitation request", "step", stepID) - // Call SDK (synchronous - blocks until response received or timeout) - // The SDK handles all JSON-RPC ID correlation internally - result, err := h.sdkRequester.RequestElicitation(reqCtx, mcpReq) + // Call the requester (synchronous - blocks until response received or timeout). + // The underlying SDK handles all JSON-RPC ID correlation internally. + result, err := h.requester.RequestElicitation(reqCtx, req) if err != nil { // Check if timeout if errors.Is(err, context.DeadlineExceeded) { @@ -198,7 +193,7 @@ func (h *DefaultElicitationHandler) RequestElicitation( slog.Debug("received elicitation response", "step", stepID, "action", result.Action) - // Transform SDK response to domain type + // Transform the requester result into the composer's response type. // Note: result.Content is of type 'any', convert to map[string]any if present var content map[string]any if result.Content != nil { @@ -211,7 +206,7 @@ func (h *DefaultElicitationHandler) RequestElicitation( } response := &ElicitationResponse{ - Action: string(result.Action), + Action: result.Action, Content: content, ReceivedAt: time.Now(), } diff --git a/pkg/vmcp/composer/elicitation_handler_test.go b/pkg/vmcp/composer/elicitation_handler_test.go index 44efe0cf81..dbbc1eec82 100644 --- a/pkg/vmcp/composer/elicitation_handler_test.go +++ b/pkg/vmcp/composer/elicitation_handler_test.go @@ -10,12 +10,12 @@ import ( "testing" "time" - "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "github.com/stacklok/toolhive/pkg/vmcp/composer/mocks" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/mocks" ) func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { @@ -24,11 +24,12 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { tests := []struct { name string config *ElicitationConfig - mockSetup func(*mocks.MockSDKElicitationRequester) + mockSetup func(*mocks.MockElicitationRequester) wantErr bool errType error errContains string wantAction string + wantContent map[string]any }{ { name: "success_accept", @@ -42,16 +43,15 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { }, Timeout: 1 * time.Minute, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { - m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"confirmed": true}, - }, + mockSetup: func(m *mocks.MockElicitationRequester) { + m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "accept", + Content: map[string]any{"confirmed": true}, }, nil) }, - wantErr: false, - wantAction: "accept", + wantErr: false, + wantAction: "accept", + wantContent: map[string]any{"confirmed": true}, }, { name: "success_decline", @@ -59,11 +59,9 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { Message: "Proceed?", Schema: map[string]any{"type": "object"}, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { - m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionDecline, - }, + mockSetup: func(m *mocks.MockElicitationRequester) { + m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "decline", }, nil) }, wantErr: false, @@ -75,11 +73,9 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { Message: "Continue?", Schema: map[string]any{"type": "object"}, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { - m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionCancel, - }, + mockSetup: func(m *mocks.MockElicitationRequester) { + m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "cancel", }, nil) }, wantErr: false, @@ -88,7 +84,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { { name: "nil_config", config: nil, - mockSetup: func(_ *mocks.MockSDKElicitationRequester) {}, + mockSetup: func(_ *mocks.MockElicitationRequester) {}, wantErr: true, errContains: "elicitation config cannot be nil", }, @@ -97,7 +93,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { config: &ElicitationConfig{ Schema: map[string]any{"type": "object"}, }, - mockSetup: func(_ *mocks.MockSDKElicitationRequester) {}, + mockSetup: func(_ *mocks.MockElicitationRequester) {}, wantErr: true, errContains: "elicitation message is required", }, @@ -106,7 +102,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { config: &ElicitationConfig{ Message: "Confirm?", }, - mockSetup: func(_ *mocks.MockSDKElicitationRequester) {}, + mockSetup: func(_ *mocks.MockElicitationRequester) {}, wantErr: true, errContains: "elicitation schema is required", }, @@ -116,7 +112,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { Message: "Confirm?", Schema: map[string]any{"type": "object"}, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { + mockSetup: func(m *mocks.MockElicitationRequester) { m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(nil, errors.New("network error")) }, wantErr: true, @@ -129,29 +125,29 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { Schema: map[string]any{"type": "object"}, Timeout: 100 * time.Millisecond, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { + mockSetup: func(m *mocks.MockElicitationRequester) { m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(nil, context.DeadlineExceeded) }, wantErr: true, errType: ErrElicitationTimeout, }, { - name: "timeout_capped_to_max", + // Non-map Content must be treated as nil (warn-logged) so the + // composer never hands a non-map value to template expansion. + name: "accept_with_non_map_content", config: &ElicitationConfig{ Message: "Confirm?", Schema: map[string]any{"type": "object"}, - Timeout: 1 * time.Hour, // Exceeds max (10 minutes) }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { - // Mock should be called with 10 minute timeout context - m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - }, + mockSetup: func(m *mocks.MockElicitationRequester) { + m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "accept", + Content: "not-a-map", }, nil) }, - wantErr: false, - wantAction: "accept", + wantErr: false, + wantAction: "accept", + wantContent: nil, }, { name: "schema_too_large", @@ -167,7 +163,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { }, }, }, - mockSetup: func(_ *mocks.MockSDKElicitationRequester) {}, + mockSetup: func(_ *mocks.MockElicitationRequester) {}, wantErr: true, errType: ErrSchemaTooLarge, errContains: "schema too large", @@ -178,12 +174,10 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { Message: "Confirm?", Schema: map[string]any{"type": "object"}, }, - mockSetup: func(m *mocks.MockSDKElicitationRequester) { - m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"huge": strings.Repeat("A", 2*1024*1024)}, // 2MB - }, + mockSetup: func(m *mocks.MockElicitationRequester) { + m.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "accept", + Content: map[string]any{"huge": strings.Repeat("A", 2*1024*1024)}, // 2MB }, nil) }, wantErr: true, @@ -196,7 +190,7 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mockSDK := mocks.NewMockSDKElicitationRequester(ctrl) + mockSDK := mocks.NewMockElicitationRequester(ctrl) tt.mockSetup(mockSDK) handler := NewDefaultElicitationHandler(mockSDK) @@ -218,11 +212,65 @@ func TestDefaultElicitationHandler_RequestElicitation(t *testing.T) { if tt.wantAction != "" { assert.Equal(t, tt.wantAction, response.Action) } + assert.Equal(t, tt.wantContent, response.Content) } }) } } +// TestDefaultElicitationHandler_TimeoutCappedToMax verifies that a configured +// timeout exceeding maxElicitationTimeout is capped (timeout-bomb protection), +// not honored verbatim. It captures the context the handler passes to the +// requester and asserts the deadline is ~now+maxElicitationTimeout, well below +// the requested 1h. This would fail if the capping logic were removed. +func TestDefaultElicitationHandler_TimeoutCappedToMax(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockRequester := mocks.NewMockElicitationRequester(ctrl) + + mockRequester.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ vmcp.ElicitationRequest) (*vmcp.ElicitationResult, error) { + deadline, ok := ctx.Deadline() + require.True(t, ok, "handler must apply a deadline") + assert.WithinDuration(t, time.Now().Add(maxElicitationTimeout), deadline, 5*time.Second) + assert.Less(t, deadline, time.Now().Add(1*time.Hour), "deadline must be capped below the requested timeout") + return &vmcp.ElicitationResult{Action: "accept"}, nil + }, + ) + + handler := NewDefaultElicitationHandler(mockRequester) + + config := &ElicitationConfig{ + Message: "Confirm?", + Schema: map[string]any{"type": "object"}, + Timeout: 1 * time.Hour, // Exceeds max (10 minutes) + } + + response, err := handler.RequestElicitation(context.Background(), "workflow-1", "step-1", config) + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "accept", response.Action) +} + +func TestDefaultElicitationHandler_NilRequester(t *testing.T) { + t.Parallel() + + // A handler built without a requester (e.g. a core constructed with a nil + // ElicitationRequester) must fail cleanly rather than nil-dereference when a + // workflow reaches an elicitation step. + handler := NewDefaultElicitationHandler(nil) + + resp, err := handler.RequestElicitation(context.Background(), "workflow-1", "step-1", &ElicitationConfig{ + Message: "Confirm?", + Schema: map[string]any{"type": "object"}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.ErrorIs(t, err, ErrElicitationNotConfigured) + assert.Contains(t, err.Error(), "step-1") +} + func TestValidateSchemaSize(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/composer/elicitation_integration_test.go b/pkg/vmcp/composer/elicitation_integration_test.go index b7fe25af69..ca2f84f278 100644 --- a/pkg/vmcp/composer/elicitation_integration_test.go +++ b/pkg/vmcp/composer/elicitation_integration_test.go @@ -9,27 +9,24 @@ import ( "testing" "time" - "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/vmcp" - "github.com/stacklok/toolhive/pkg/vmcp/composer/mocks" + "github.com/stacklok/toolhive/pkg/vmcp/mocks" ) func TestWorkflowEngine_ExecuteElicitationStep_Accept(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) // Mock SDK to return accept response - mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"environment": "production"}, - }, + mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "accept", + Content: map[string]any{"environment": "production"}, }, nil) handler := NewDefaultElicitationHandler(mockSDK) @@ -140,13 +137,11 @@ func TestWorkflowEngine_ExecuteElicitationStep_Decline(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) // Mock SDK to return decline response - mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionDecline, - }, + mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "decline", }, nil) handler := NewDefaultElicitationHandler(mockSDK) @@ -217,13 +212,11 @@ func TestWorkflowEngine_ExecuteElicitationStep_Cancel(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) // Mock SDK to return cancel response - mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionCancel, - }, + mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "cancel", }, nil) handler := NewDefaultElicitationHandler(mockSDK) @@ -268,7 +261,7 @@ func TestWorkflowEngine_ExecuteElicitationStep_Timeout(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) // Mock SDK to return timeout error mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(nil, context.DeadlineExceeded) @@ -336,14 +329,12 @@ func TestWorkflowEngine_MultiStepWithElicitation(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) // Mock SDK to return accept with proceed=true - mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"proceed": true}, - }, + mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(&vmcp.ElicitationResult{ + Action: "accept", + Content: map[string]any{"proceed": true}, }, nil) handler := NewDefaultElicitationHandler(mockSDK) @@ -503,7 +494,7 @@ func TestDefaultElicitationHandler_SDKErrorHandling(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mockSDK := mocks.NewMockSDKElicitationRequester(ctrl) + mockSDK := mocks.NewMockElicitationRequester(ctrl) mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).Return(nil, tt.sdkError) @@ -556,17 +547,15 @@ func TestWorkflowEngine_ElicitationMessageTemplateExpansion(t *testing.T) { t.Parallel() te := newTestEngine(t) - mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + mockSDK := mocks.NewMockElicitationRequester(te.Ctrl) - var capturedReq mcp.ElicitationRequest + var capturedReq vmcp.ElicitationRequest mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, req mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { + func(_ context.Context, req vmcp.ElicitationRequest) (*vmcp.ElicitationResult, error) { capturedReq = req - return &mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"confirmed": true}, - }, + return &vmcp.ElicitationResult{ + Action: "accept", + Content: map[string]any{"confirmed": true}, }, nil }, ) @@ -598,7 +587,7 @@ func TestWorkflowEngine_ElicitationMessageTemplateExpansion(t *testing.T) { result, err := engine.ExecuteWorkflow(context.Background(), workflow, tt.params) require.NoError(t, err) assert.Equal(t, WorkflowStatusCompleted, result.Status) - assert.Equal(t, tt.expectedMessage, capturedReq.Params.Message) + assert.Equal(t, tt.expectedMessage, capturedReq.Message) assert.Equal(t, tt.message, workflow.Steps[0].Elicitation.Message) }) } diff --git a/pkg/vmcp/composer/mocks/mock_sdk_elicitation_requester.go b/pkg/vmcp/composer/mocks/mock_sdk_elicitation_requester.go deleted file mode 100644 index d88c78ded6..0000000000 --- a/pkg/vmcp/composer/mocks/mock_sdk_elicitation_requester.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/stacklok/toolhive/pkg/vmcp/composer (interfaces: SDKElicitationRequester) -// -// Generated by this command: -// -// mockgen -destination=mocks/mock_sdk_elicitation_requester.go -package=mocks github.com/stacklok/toolhive/pkg/vmcp/composer SDKElicitationRequester -// - -// Package mocks is a generated GoMock package. -package mocks - -import ( - context "context" - reflect "reflect" - - mcp "github.com/mark3labs/mcp-go/mcp" - gomock "go.uber.org/mock/gomock" -) - -// MockSDKElicitationRequester is a mock of SDKElicitationRequester interface. -type MockSDKElicitationRequester struct { - ctrl *gomock.Controller - recorder *MockSDKElicitationRequesterMockRecorder - isgomock struct{} -} - -// MockSDKElicitationRequesterMockRecorder is the mock recorder for MockSDKElicitationRequester. -type MockSDKElicitationRequesterMockRecorder struct { - mock *MockSDKElicitationRequester -} - -// NewMockSDKElicitationRequester creates a new mock instance. -func NewMockSDKElicitationRequester(ctrl *gomock.Controller) *MockSDKElicitationRequester { - mock := &MockSDKElicitationRequester{ctrl: ctrl} - mock.recorder = &MockSDKElicitationRequesterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSDKElicitationRequester) EXPECT() *MockSDKElicitationRequesterMockRecorder { - return m.recorder -} - -// RequestElicitation mocks base method. -func (m *MockSDKElicitationRequester) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestElicitation", ctx, request) - ret0, _ := ret[0].(*mcp.ElicitationResult) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// RequestElicitation indicates an expected call of RequestElicitation. -func (mr *MockSDKElicitationRequesterMockRecorder) RequestElicitation(ctx, request any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestElicitation", reflect.TypeOf((*MockSDKElicitationRequester)(nil).RequestElicitation), ctx, request) -} diff --git a/pkg/vmcp/composer/workflow_errors.go b/pkg/vmcp/composer/workflow_errors.go index a16fdbcaf4..d51d94f88e 100644 --- a/pkg/vmcp/composer/workflow_errors.go +++ b/pkg/vmcp/composer/workflow_errors.go @@ -39,44 +39,6 @@ var ( ErrToolCallFailed = errors.New("tool call failed") ) -// WorkflowError wraps workflow execution errors with context. -type WorkflowError struct { - // WorkflowID is the workflow execution ID. - WorkflowID string - - // StepID is the step that caused the error (if applicable). - StepID string - - // Message is the error message. - Message string - - // Cause is the underlying error. - Cause error -} - -// Error implements the error interface. -func (e *WorkflowError) Error() string { - if e.StepID != "" { - return fmt.Sprintf("workflow %s, step %s: %s: %v", e.WorkflowID, e.StepID, e.Message, e.Cause) - } - return fmt.Sprintf("workflow %s: %s: %v", e.WorkflowID, e.Message, e.Cause) -} - -// Unwrap returns the underlying error for errors.Is and errors.As. -func (e *WorkflowError) Unwrap() error { - return e.Cause -} - -// NewWorkflowError creates a new workflow error. -func NewWorkflowError(workflowID, stepID, message string, cause error) *WorkflowError { - return &WorkflowError{ - WorkflowID: workflowID, - StepID: stepID, - Message: message, - Cause: cause, - } -} - // ValidationError wraps workflow validation errors. type ValidationError struct { // Field is the field that failed validation. diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 4fcd75e8f8..8e1f389fb6 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/stacklok/toolhive/pkg/audit" thvjson "github.com/stacklok/toolhive/pkg/json" + ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/vmcp" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" @@ -184,6 +185,11 @@ type Config struct { // When omitted, the server default is used. // +optional SessionTTL Duration `json:"sessionTTL,omitempty" yaml:"sessionTTL,omitempty"` + + // RateLimiting defines rate limiting configuration for the Virtual MCP server. + // Requires Redis session storage to be configured for distributed rate limiting. + // +optional + RateLimiting *ratelimittypes.RateLimitConfig `json:"rateLimiting,omitempty" yaml:"rateLimiting,omitempty"` } // IncomingAuthConfig configures client authentication to the virtual MCP server. @@ -276,6 +282,14 @@ type AuthzConfig struct { // Policies contains Cedar policy definitions (when Type = "cedar"). Policies []string `json:"policies,omitempty" yaml:"policies,omitempty"` + // EntitiesJSON is a JSON string representing Cedar entities. Required for + // enterprise policies that rely on transitive relationships (e.g. + // `ClaimGroup → PlatformRole`) — without it the Cedar authorizer is + // constructed with an empty entity store and `in` checks against absent + // entities silently evaluate to false. Defaults to "[]" when empty. + // +optional + EntitiesJSON string `json:"entitiesJson,omitempty" yaml:"entitiesJson,omitempty"` + // PrimaryUpstreamProvider names the upstream IDP provider whose access // token should be used as the source of JWT claims for Cedar evaluation. // When empty, claims from the ToolHive-issued token are used. @@ -283,6 +297,28 @@ type AuthzConfig struct { // (e.g. "default", "github"). Only relevant when the embedded auth server is active. // +optional PrimaryUpstreamProvider string `json:"primaryUpstreamProvider,omitempty" yaml:"primaryUpstreamProvider,omitempty"` + + // GroupClaimName is the JWT claim key that contains group membership for + // the principal. When set, takes priority over the well-known defaults + // ("groups", "roles", "cognito:groups"). Use this for IDPs that place + // groups under a URI-style claim (e.g. "https://example.com/groups"). + // When empty, only the well-known claim names are checked. + // +optional + GroupClaimName string `json:"groupClaimName,omitempty" yaml:"groupClaimName,omitempty"` + + // RoleClaimName is the JWT claim key that contains role membership for the + // principal. When set, the claim is extracted separately from GroupClaimName + // and both are mapped to the configured group entity type. When empty, no + // role extraction is performed. + // +optional + RoleClaimName string `json:"roleClaimName,omitempty" yaml:"roleClaimName,omitempty"` + + // GroupEntityType is the Cedar entity type name used for principal parent + // UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when + // empty. Must match the entity type used in EntitiesJSON for transitive + // `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported. + // +optional + GroupEntityType string `json:"groupEntityType,omitempty" yaml:"groupEntityType,omitempty"` } // StaticBackendConfig defines a pre-configured backend server for static mode. diff --git a/pkg/vmcp/config/config_test.go b/pkg/vmcp/config/config_test.go index e3787bf83f..5f652ebce0 100644 --- a/pkg/vmcp/config/config_test.go +++ b/pkg/vmcp/config/config_test.go @@ -311,7 +311,7 @@ func checkStructTags(t reflect.Type, path string, visited map[reflect.Type]bool) t = func() reflect.Type { for { switch t.Kind() { //nolint:exhaustive // Only checking slice, map, and ptr types - case reflect.Slice, reflect.Map, reflect.Ptr: + case reflect.Slice, reflect.Map, reflect.Pointer: t = t.Elem() default: return t @@ -453,7 +453,7 @@ func collectStructTypes(t reflect.Type, visited map[reflect.Type]bool) []string var types []string // Unwrap pointers, slices, maps - for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Map { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Map { if t.Kind() == reflect.Map { // Also check map key/value types types = append(types, collectStructTypes(t.Key(), visited)...) diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index d756dce1a2..3c9370ce9f 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -232,6 +232,7 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp authtypes.StrategyTypeHeaderInjection, authtypes.StrategyTypeTokenExchange, authtypes.StrategyTypeUpstreamInject, + authtypes.StrategyTypeAwsSts, } if !slices.Contains(validTypes, strategy.Type) { return fmt.Errorf("type must be one of: %s", strings.Join(validTypes, ", ")) @@ -266,6 +267,14 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp } // Note: empty ProviderName is allowed here; ValidateAuthServerIntegration // handles provider name resolution including the empty→"default" mapping. + + case authtypes.StrategyTypeAwsSts: + if strategy.AwsSts == nil { + return fmt.Errorf("aws_sts requires AwsSts configuration") + } + if strategy.AwsSts.Region == "" { + return fmt.Errorf("aws_sts requires region field") + } } return nil diff --git a/pkg/vmcp/config/yaml_loader.go b/pkg/vmcp/config/yaml_loader.go index 5fb9a724bc..0bf30da3da 100644 --- a/pkg/vmcp/config/yaml_loader.go +++ b/pkg/vmcp/config/yaml_loader.go @@ -144,6 +144,14 @@ func (l *YAMLLoader) processBackendAuthStrategy(name string, strategy *authtypes case authtypes.StrategyTypeUnauthenticated: // No validation needed + case authtypes.StrategyTypeAwsSts: + if strategy.AwsSts == nil { + return fmt.Errorf("backend %s: aws_sts configuration is required", name) + } + if strategy.AwsSts.Region == "" { + return fmt.Errorf("backend %s: aws_sts requires region field", name) + } + default: // Unknown strategy type - let validation handle it } diff --git a/pkg/vmcp/config/zz_generated.deepcopy.go b/pkg/vmcp/config/zz_generated.deepcopy.go index 74cb2b86f7..9fb43b4214 100644 --- a/pkg/vmcp/config/zz_generated.deepcopy.go +++ b/pkg/vmcp/config/zz_generated.deepcopy.go @@ -22,8 +22,9 @@ package config import ( "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/ratelimit/types" "github.com/stacklok/toolhive/pkg/telemetry" - "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -204,6 +205,11 @@ func (in *Config) DeepCopyInto(out *Config) { *out = new(SessionStorageConfig) **out = **in } + if in.RateLimiting != nil { + in, out := &in.RateLimiting, &out.RateLimiting + *out = new(types.RateLimitConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. @@ -366,20 +372,20 @@ func (in *OutgoingAuthConfig) DeepCopyInto(out *OutgoingAuthConfig) { *out = *in if in.Default != nil { in, out := &in.Default, &out.Default - *out = new(types.BackendAuthStrategy) + *out = new(authtypes.BackendAuthStrategy) (*in).DeepCopyInto(*out) } if in.Backends != nil { in, out := &in.Backends, &out.Backends - *out = make(map[string]*types.BackendAuthStrategy, len(*in)) + *out = make(map[string]*authtypes.BackendAuthStrategy, len(*in)) for key, val := range *in { - var outVal *types.BackendAuthStrategy + var outVal *authtypes.BackendAuthStrategy if val == nil { (*out)[key] = nil } else { inVal := (*in)[key] in, out := &inVal, &outVal - *out = new(types.BackendAuthStrategy) + *out = new(authtypes.BackendAuthStrategy) (*in).DeepCopyInto(*out) } (*out)[key] = outVal diff --git a/pkg/vmcp/core/admission.go b/pkg/vmcp/core/admission.go new file mode 100644 index 0000000000..42af64be2c --- /dev/null +++ b/pkg/vmcp/core/admission.go @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "fmt" + "log/slog" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// Admission decides whether an identity may see or use a capability. It wraps the +// existing [authorizers.Authorizer] (Cedar in the vMCP path); it does NOT define a +// new policy model. +// +// The seam re-platforms the authorization decision that runs as HTTP middleware +// today (AuthzMiddleware + AnnotationEnrichmentMiddleware) into the core, so that +// List* (filter) and Call/Read/Get (deny) enforce the SAME decision from one +// source — closing the "list says yes / call says no" gap. Identity is an explicit +// parameter (never read from ctx, vmcp anti-pattern #1); the adapter re-injects it +// into the ctx the wrapped authorizer reads. A seam can only SUBTRACT reachability +// (filter list output / refuse a call), never widen it ([VMCP] contract). +// +// The seam is optimizer-agnostic: it authorizes every capability uniformly by name +// and has no special handling for the optimizer's meta-tools (find_tool/call_tool). +// The optimizer-admission integration the HTTP path performs today — call_tool +// inner-target authorization, find_tool response filtering, and inner-target +// annotation sourcing — is deliberately NOT in this seam; it is deferred to its own +// focused PR. Until then the optimizer keeps the HTTP middleware, and #5441's +// composition root fails fast (vmcp.ErrInvalidConfig) when Authz is set together +// with the optimizer, so the combination can never silently route through this seam. +type Admission interface { + // FilterTools returns the subset of tools the identity may call. Mirrors + // pkg/authz filterToolsByPolicy: a per-tool AuthorizeWithJWTClaims(call) using + // the tool's annotations; a per-tool authorizer error skips that tool + // (log-and-continue), it is not a hard failure. + FilterTools(ctx context.Context, identity *auth.Identity, tools []vmcp.Tool) ([]vmcp.Tool, error) + // AllowToolCall mirrors pkg/authz authorizeToolCall (MCPFeatureTool/Call), + // sourcing the tool's annotations for when-clause evaluation. + AllowToolCall(ctx context.Context, identity *auth.Identity, tool *vmcp.Tool, args map[string]any) (bool, error) + // FilterResources / AllowResourceRead use MCPFeatureResource/Read. + FilterResources(ctx context.Context, identity *auth.Identity, resources []vmcp.Resource) ([]vmcp.Resource, error) + AllowResourceRead(ctx context.Context, identity *auth.Identity, resource *vmcp.Resource) (bool, error) + // FilterPrompts / AllowPromptGet use MCPFeaturePrompt/Get. + FilterPrompts(ctx context.Context, identity *auth.Identity, prompts []vmcp.Prompt) ([]vmcp.Prompt, error) + AllowPromptGet(ctx context.Context, identity *auth.Identity, prompt *vmcp.Prompt) (bool, error) +} + +// newAdmission builds the admission seam New wires into the core. +// +// A nil authzCfg means authorization is unconfigured and the seam is allow-all, +// preserving today's `AuthzMiddleware != nil` guard: the composition root only +// populates Authz when Cedar policies exist (mirroring newCedarAuthzMiddleware's +// `len(Policies) > 0` check), so "no policies" reaches the core as a nil Authz. +// +// A non-nil authzCfg with zero policies is NOT treated as allow-all here: it falls +// through to CreateAuthorizer, which returns ErrNoPolicies, so the core fails +// CLOSED. This is a deliberate divergence from the live HTTP path, which allows-all +// for the same input. Fail-closed is the safer default; #5441 reconciles the two +// when it collapses the construction paths into one shared constructor. +// +// When authzCfg is set, the authorizer is built via the same registry path the +// HTTP middleware uses (authorizers.GetFactory + CreateAuthorizer, the construction +// inside newCedarAuthzMiddleware). The Cedar factory is registered by pkg/authz's +// blank import, which the core package already pulls in. +// +// authzCfg is an already-built, authorizer-agnostic config: the cedar-specific +// normalization newCedarAuthzMiddleware performs at construction time (e.g. +// defaulting an empty EntitiesJSON to "[]") belongs to whoever builds authzCfg, not +// to this generic factory path, which consumes RawConfig as-is. newCedarAuthzMiddleware +// is the sibling construction path; the two must stay in lockstep (notably the +// empty-serverName check below) until #5441 collapses them. +func newAdmission(authzCfg *authorizers.Config, serverName string) (Admission, error) { + if authzCfg == nil { + return allowAllAdmission{}, nil + } + + // Fail loudly on an empty server name, mirroring newCedarAuthzMiddleware's check. + // The Cedar entity factory attaches the MCP::"" parent UID to resource + // entities only when serverName is non-empty, so an empty name silently changes + // resource-scoped policy semantics rather than erroring (go-style: fail loudly on + // invalid input). + if serverName == "" { + return nil, fmt.Errorf( + "%w: ServerName must not be empty when Authz is set (Cedar resource-scoped policies require it)", + vmcp.ErrInvalidConfig) + } + + factory := authorizers.GetFactory(string(authzCfg.Type)) + if factory == nil { + return nil, fmt.Errorf("%w: unsupported authz type %q", vmcp.ErrInvalidConfig, authzCfg.Type) + } + authorizer, err := factory.CreateAuthorizer(authzCfg.RawConfig(), serverName) + if err != nil { + // Classify with ErrInvalidConfig so callers (errors.Is) treat a bad policy / + // malformed RawConfig the same as the unsupported-type and empty-serverName + // failures above — all are invalid-config conditions. + return nil, fmt.Errorf("%w: failed to build admission authorizer: %w", vmcp.ErrInvalidConfig, err) + } + return newCedarAdmission(authorizer), nil +} + +// cedarAdmission enforces the wrapped [authorizers.Authorizer]'s decision. It is +// named for the Cedar authorizer it backs in the vMCP path, but wraps the +// single-method Authorizer interface generically. It authorizes every capability +// uniformly — no optimizer meta-tool carve-out (see the [Admission] doc). +type cedarAdmission struct { + authorizer authorizers.Authorizer + // logger receives the per-item "skipping" warnings. It defaults to + // slog.Default(); tests inject a buffer-backed logger (via withLogger) so they + // can assert on output WITHOUT swapping the process-global slog default — that + // global swap races (-race) against parallel sibling tests that also log. + logger *slog.Logger +} + +// cedarOption configures a cedarAdmission at construction. +type cedarOption func(*cedarAdmission) + +// withLogger overrides the logger the seam emits skip warnings to. Used by tests +// to capture log output without mutating the global slog default. +func withLogger(logger *slog.Logger) cedarOption { + return func(c *cedarAdmission) { c.logger = logger } +} + +// newCedarAdmission wraps an already-built authorizer. Separated from newAdmission +// so tests can inject a stub authorizer (or a real cedar.NewCedarAuthorizer) +// without round-tripping through the config/factory. +func newCedarAdmission(a authorizers.Authorizer, opts ...cedarOption) *cedarAdmission { + adm := &cedarAdmission{authorizer: a, logger: slog.Default()} + for _, opt := range opts { + opt(adm) + } + return adm +} + +// FilterTools mirrors pkg/authz filterToolsByPolicy: each tool is authorized for +// call with its annotations injected, and a per-tool authorizer error skips that +// tool (log-and-continue). +func (a *cedarAdmission) FilterTools( + ctx context.Context, identity *auth.Identity, tools []vmcp.Tool, +) ([]vmcp.Tool, error) { + ctx = auth.WithIdentity(ctx, identity) + // A filter returns a subset, so build a fresh non-nil slice — mirroring + // pkg/authz filterToolsByPolicy (its configured-authorizer path is also + // non-nil; its nil-authorizer no-op, like the allow-all seam here, returns the + // input as-is). nil-vs-[] wire normalization is the Serve layer's concern. + filtered := make([]vmcp.Tool, 0, len(tools)) + for i := range tools { + tool := &tools[i] + toolCtx := ctx + if ann := convertAnnotations(tool.Annotations); ann != nil { + toolCtx = authorizers.WithToolAnnotations(toolCtx, ann) + } + allowed, err := a.authorizer.AuthorizeWithJWTClaims( + toolCtx, authorizers.MCPFeatureTool, authorizers.MCPOperationCall, tool.Name, nil) + if err != nil { + a.logger.Warn("admission: tool authorization check failed, skipping", "tool", tool.Name, "error", err) + continue + } + if allowed { + filtered = append(filtered, *tool) + } + } + return filtered, nil +} + +// AllowToolCall mirrors pkg/authz authorizeToolCall (MCPFeatureTool/Call), sourcing +// the tool's annotations for when-clause evaluation. It authorizes the tool named in +// `tool.Name` — there is no optimizer meta-tool special-casing here (see the +// [Admission] doc): the optimizer's call_tool inner-target authorization is deferred +// to a dedicated optimizer-admission PR, and #5441 fails fast on (Authz + optimizer) +// so that combination never reaches this seam in the meantime. +func (a *cedarAdmission) AllowToolCall( + ctx context.Context, identity *auth.Identity, tool *vmcp.Tool, args map[string]any, +) (bool, error) { + ctx = auth.WithIdentity(ctx, identity) + if ann := convertAnnotations(tool.Annotations); ann != nil { + ctx = authorizers.WithToolAnnotations(ctx, ann) + } + return a.authorizer.AuthorizeWithJWTClaims( + ctx, authorizers.MCPFeatureTool, authorizers.MCPOperationCall, tool.Name, args) +} + +// FilterResources mirrors the response filter's filterResourcesResponse +// (response_filter.go:407): per-resource read authorization, skipping on error. +func (a *cedarAdmission) FilterResources( + ctx context.Context, identity *auth.Identity, resources []vmcp.Resource, +) ([]vmcp.Resource, error) { + ctx = auth.WithIdentity(ctx, identity) + filtered := make([]vmcp.Resource, 0, len(resources)) + for i := range resources { + allowed, err := a.authorizer.AuthorizeWithJWTClaims( + ctx, authorizers.MCPFeatureResource, authorizers.MCPOperationRead, resources[i].URI, nil) + if err != nil { + a.logger.Warn("admission: resource authorization check failed, skipping", + "resource", resources[i].URI, "error", err) + continue + } + if allowed { + filtered = append(filtered, resources[i]) + } + } + return filtered, nil +} + +// AllowResourceRead authorizes a single resource read (MCPFeatureResource/Read). +func (a *cedarAdmission) AllowResourceRead( + ctx context.Context, identity *auth.Identity, resource *vmcp.Resource, +) (bool, error) { + ctx = auth.WithIdentity(ctx, identity) + return a.authorizer.AuthorizeWithJWTClaims( + ctx, authorizers.MCPFeatureResource, authorizers.MCPOperationRead, resource.URI, nil) +} + +// FilterPrompts mirrors the response filter's filterPromptsResponse +// (response_filter.go:346): per-prompt get authorization, skipping on error. +func (a *cedarAdmission) FilterPrompts( + ctx context.Context, identity *auth.Identity, prompts []vmcp.Prompt, +) ([]vmcp.Prompt, error) { + ctx = auth.WithIdentity(ctx, identity) + filtered := make([]vmcp.Prompt, 0, len(prompts)) + for i := range prompts { + allowed, err := a.authorizer.AuthorizeWithJWTClaims( + ctx, authorizers.MCPFeaturePrompt, authorizers.MCPOperationGet, prompts[i].Name, nil) + if err != nil { + a.logger.Warn("admission: prompt authorization check failed, skipping", + "prompt", prompts[i].Name, "error", err) + continue + } + if allowed { + filtered = append(filtered, prompts[i]) + } + } + return filtered, nil +} + +// AllowPromptGet authorizes a single prompt get (MCPFeaturePrompt/Get). +func (a *cedarAdmission) AllowPromptGet( + ctx context.Context, identity *auth.Identity, prompt *vmcp.Prompt, +) (bool, error) { + ctx = auth.WithIdentity(ctx, identity) + return a.authorizer.AuthorizeWithJWTClaims( + ctx, authorizers.MCPFeaturePrompt, authorizers.MCPOperationGet, prompt.Name, nil) +} + +// allowAllAdmission is the no-op seam used when authorization is unconfigured. It +// preserves today's behavior when AuthzMiddleware is nil: Filter* return their +// input unchanged and Allow* return true (the nil-authorizer no-ops at +// tool_filter.go:20,67). +type allowAllAdmission struct{} + +func (allowAllAdmission) FilterTools( + _ context.Context, _ *auth.Identity, tools []vmcp.Tool, +) ([]vmcp.Tool, error) { + return tools, nil +} + +func (allowAllAdmission) AllowToolCall( + _ context.Context, _ *auth.Identity, _ *vmcp.Tool, _ map[string]any, +) (bool, error) { + return true, nil +} + +func (allowAllAdmission) FilterResources( + _ context.Context, _ *auth.Identity, resources []vmcp.Resource, +) ([]vmcp.Resource, error) { + return resources, nil +} + +func (allowAllAdmission) AllowResourceRead(_ context.Context, _ *auth.Identity, _ *vmcp.Resource) (bool, error) { + return true, nil +} + +func (allowAllAdmission) FilterPrompts( + _ context.Context, _ *auth.Identity, prompts []vmcp.Prompt, +) ([]vmcp.Prompt, error) { + return prompts, nil +} + +func (allowAllAdmission) AllowPromptGet(_ context.Context, _ *auth.Identity, _ *vmcp.Prompt) (bool, error) { + return true, nil +} + +// convertAnnotations maps vmcp.ToolAnnotations to authorizers.ToolAnnotations, +// returning nil when no hint field is set so the adapter only writes annotation +// ctx when a hint exists (matching the existing hasAnyHint gate). Only +// authorization-relevant hints are mapped; informational fields like Title are not +// used in policy evaluation. +// +// This duplicates server.convertAnnotations (annotation_enrichment.go:92) rather +// than reusing it: that copy lives in package server, which imports this core +// package, so importing it here would create a cycle (server -> core). Like the +// filterHealthyBackends C2 duplication in this package, it is intentional and +// temporary — #5441 retires the server-side middleware (and its copy) on the domain +// path. Keep the two in sync until then. +func convertAnnotations(ann *vmcp.ToolAnnotations) *authorizers.ToolAnnotations { + if ann == nil { + return nil + } + if ann.ReadOnlyHint == nil && ann.DestructiveHint == nil && + ann.IdempotentHint == nil && ann.OpenWorldHint == nil { + return nil + } + return &authorizers.ToolAnnotations{ + ReadOnlyHint: ann.ReadOnlyHint, + DestructiveHint: ann.DestructiveHint, + IdempotentHint: ann.IdempotentHint, + OpenWorldHint: ann.OpenWorldHint, + } +} + +// findAdvertisedTool returns a pointer to the advertised tool with the given name, +// or nil. CallTool uses it to source the tool's annotations for the admission +// decision — mirroring the annotation cache the HTTP middleware populates from +// tools/list. A name absent from the advertised set carries no annotations +// (nil), and routing remains the authority on whether the call resolves. +func findAdvertisedTool(tools []vmcp.Tool, name string) *vmcp.Tool { + for i := range tools { + if tools[i].Name == name { + return &tools[i] + } + } + return nil +} diff --git a/pkg/vmcp/core/admission_test.go b/pkg/vmcp/core/admission_test.go new file mode 100644 index 0000000000..8a0064d1d1 --- /dev/null +++ b/pkg/vmcp/core/admission_test.go @@ -0,0 +1,762 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" +) + +// mockAuthorizer is a hand-rolled stub for the single-method authorizers.Authorizer +// (no generated mock exists; the issue specifies a hand-rolled stub, as +// pkg/authz/tool_filter_test.go does). It records each call — including what it +// observed in the context — so tests can assert the adapter re-injects identity +// and annotations before delegating. +type mockAuthorizer struct { + results map[string]mockResult // keyed by resourceID + calls []mockCall +} + +type mockResult struct { + authorized bool + err error +} + +type mockCall struct { + feature authorizers.MCPFeature + operation authorizers.MCPOperation + resourceID string + args map[string]interface{} + identitySubject string // "" when no identity was present in ctx + identityPresent bool + annotations *authorizers.ToolAnnotations +} + +func (m *mockAuthorizer) AuthorizeWithJWTClaims( + ctx context.Context, + feature authorizers.MCPFeature, + operation authorizers.MCPOperation, + resourceID string, + args map[string]interface{}, +) (bool, error) { + id, present := auth.IdentityFromContext(ctx) + call := mockCall{ + feature: feature, + operation: operation, + resourceID: resourceID, + args: args, + identityPresent: present, + annotations: authorizers.ToolAnnotationsFromContext(ctx), + } + if present && id != nil { + call.identitySubject = id.Subject + } + m.calls = append(m.calls, call) + + r, ok := m.results[resourceID] + if !ok { + return false, nil // default-deny for unlisted resources + } + return r.authorized, r.err +} + +func boolPtr(b bool) *bool { return &b } + +// cedarIdentity returns an identity the Cedar authorizer can resolve to a client +// ID, mirroring pkg/authz/tool_filter_test.go's cedarCtx. +func cedarIdentity() *auth.Identity { + return &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user123", + Name: "Test User", + Claims: map[string]interface{}{"sub": "user123", "name": "Test User"}, + }} +} + +// cedarAdmissionWith builds a cedarAdmission backed by a real Cedar authorizer +// compiled from the given policies. +func cedarAdmissionWith(t *testing.T, policies ...string) *cedarAdmission { + t.Helper() + a, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{Policies: policies, EntitiesJSON: "[]"}, "") + require.NoError(t, err) + return newCedarAdmission(a) +} + +// cedarAuthzConfig builds the generic authz.Config New consumes, wrapping the +// given Cedar policies (mirrors factory/incoming.go's newCedarAuthzMiddleware). +func cedarAuthzConfig(t *testing.T, policies ...string) *authorizers.Config { + t.Helper() + cfg, err := authorizers.NewConfig(cedar.Config{ + Version: "1.0", + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{Policies: policies, EntitiesJSON: "[]"}, + }) + require.NoError(t, err) + return cfg +} + +func toolNames(tools []vmcp.Tool) []string { + names := make([]string, len(tools)) + for i, t := range tools { + names[i] = t.Name + } + return names +} + +func TestCedarAdmission_FilterTools(t *testing.T) { + t.Parallel() + + errAuth := errors.New("authorization failure") + + tests := []struct { + name string + results map[string]mockResult + tools []vmcp.Tool + wantNames []string + }{ + { + name: "empty tool list returns empty", + results: map[string]mockResult{}, + tools: []vmcp.Tool{}, + wantNames: []string{}, + }, + { + name: "filters out denied tools", + results: map[string]mockResult{ + "allowed": {authorized: true}, + "denied": {authorized: false}, + "allowed2": {authorized: true}, + }, + tools: []vmcp.Tool{{Name: "allowed"}, {Name: "denied"}, {Name: "allowed2"}}, + wantNames: []string{"allowed", "allowed2"}, + }, + { + name: "skips tools whose authorizer errors (log-and-continue)", + results: map[string]mockResult{ + "good": {authorized: true}, + "error": {err: errAuth}, + }, + tools: []vmcp.Tool{{Name: "good"}, {Name: "error"}}, + wantNames: []string{"good"}, + }, + { + name: "annotated tools are still filtered", + results: map[string]mockResult{ + "readonly": {authorized: true}, + "writable": {authorized: false}, + }, + tools: []vmcp.Tool{ + {Name: "readonly", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}, + {Name: "writable", Annotations: &vmcp.ToolAnnotations{DestructiveHint: boolPtr(true)}}, + }, + wantNames: []string{"readonly"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + adm := newCedarAdmission(&mockAuthorizer{results: tc.results}) + got, err := adm.FilterTools(context.Background(), cedarIdentity(), tc.tools) + require.NoError(t, err) + assert.Equal(t, tc.wantNames, toolNames(got)) + }) + } +} + +func TestCedarAdmission_InvokesToolAuthorizerCorrectly(t *testing.T) { + t.Parallel() + + mock := &mockAuthorizer{results: map[string]mockResult{"hinted": {authorized: true}, "plain": {authorized: true}}} + adm := newCedarAdmission(mock) + + tools := []vmcp.Tool{ + {Name: "hinted", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}, + {Name: "plain"}, + } + _, err := adm.FilterTools(context.Background(), cedarIdentity(), tools) + require.NoError(t, err) + + require.Len(t, mock.calls, 2) + for _, c := range mock.calls { + assert.Equal(t, authorizers.MCPFeatureTool, c.feature) + assert.Equal(t, authorizers.MCPOperationCall, c.operation) + assert.True(t, c.identityPresent, "adapter must re-inject identity into ctx") + assert.Equal(t, "user123", c.identitySubject) + } + // Annotations are injected only for the tool that carries a hint. + byName := map[string]mockCall{mock.calls[0].resourceID: mock.calls[0], mock.calls[1].resourceID: mock.calls[1]} + require.NotNil(t, byName["hinted"].annotations) + assert.Equal(t, boolPtr(true), byName["hinted"].annotations.ReadOnlyHint) + assert.Nil(t, byName["plain"].annotations, "no annotation ctx written when the tool has no hints") +} + +func TestCedarAdmission_AllowToolCall(t *testing.T) { + t.Parallel() + + errAuth := errors.New("auth error") + + tests := []struct { + name string + results map[string]mockResult + tool string + wantOK bool + wantErr error + }{ + {"allowed", map[string]mockResult{"t": {authorized: true}}, "t", true, nil}, + {"denied", map[string]mockResult{"t": {authorized: false}}, "t", false, nil}, + {"error propagated", map[string]mockResult{"t": {err: errAuth}}, "t", false, errAuth}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + mock := &mockAuthorizer{results: tc.results} + adm := newCedarAdmission(mock) + ok, err := adm.AllowToolCall(context.Background(), cedarIdentity(), &vmcp.Tool{Name: tc.tool}, nil) + assert.Equal(t, tc.wantOK, ok) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + require.Len(t, mock.calls, 1) + assert.Equal(t, authorizers.MCPFeatureTool, mock.calls[0].feature) + assert.Equal(t, authorizers.MCPOperationCall, mock.calls[0].operation) + assert.True(t, mock.calls[0].identityPresent) + }) + } +} + +// TestCedarAdmission_AllowToolCall_ForwardsArgs asserts the call's args reach the +// authorizer (the arg-gated-policy input path), both via the mock and against a +// real Cedar arg-gated policy (mirroring pkg/authz TestAuthorizeToolCall_WithArguments). +func TestCedarAdmission_AllowToolCall_ForwardsArgs(t *testing.T) { + t.Parallel() + + t.Run("args reach the authorizer", func(t *testing.T) { + t.Parallel() + mock := &mockAuthorizer{results: map[string]mockResult{"deploy": {authorized: true}}} + adm := newCedarAdmission(mock) + args := map[string]any{"mode": "safe"} + + _, err := adm.AllowToolCall(context.Background(), cedarIdentity(), &vmcp.Tool{Name: "deploy"}, args) + require.NoError(t, err) + require.Len(t, mock.calls, 1) + assert.Equal(t, args, mock.calls[0].args, "call args must flow through to the authorizer") + }) + + t.Run("cedar arg-gated policy evaluates the args", func(t *testing.T) { + t.Parallel() + // Cedar sees args under an "arg_" prefix (preprocessed by the authorizer); + // permit only when arg_mode == "safe". + adm := cedarAdmissionWith(t, + `permit(principal, action == Action::"call_tool", resource == Tool::"deploy") when { context.arg_mode == "safe" };`) + id := cedarIdentity() + + ok, err := adm.AllowToolCall(context.Background(), id, &vmcp.Tool{Name: "deploy"}, map[string]any{"mode": "safe"}) + require.NoError(t, err) + assert.True(t, ok, "permitted when args satisfy the policy") + + ok, _ = adm.AllowToolCall(context.Background(), id, &vmcp.Tool{Name: "deploy"}, map[string]any{"mode": "dangerous"}) + assert.False(t, ok, "denied when args do not satisfy the policy") + }) +} + +func TestFindAdvertisedTool(t *testing.T) { + t.Parallel() + tools := []vmcp.Tool{{Name: "a"}, {Name: "b"}} + + tests := []struct { + name string + tools []vmcp.Tool + find string + wantFound bool + }{ + {"present first", tools, "a", true}, + {"present last", tools, "b", true}, + {"absent", tools, "missing", false}, + {"empty list", nil, "a", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := findAdvertisedTool(tc.tools, tc.find) + if tc.wantFound { + require.NotNil(t, got) + assert.Equal(t, tc.find, got.Name) + } else { + assert.Nil(t, got) + } + }) + } +} + +func TestCedarAdmission_FilterResourcesAndPrompts(t *testing.T) { + t.Parallel() + + t.Run("resources", func(t *testing.T) { + t.Parallel() + mock := &mockAuthorizer{results: map[string]mockResult{ + "res-a": {authorized: true}, + "res-b": {authorized: false}, + "res-c": {err: errors.New("boom")}, // skipped + }} + adm := newCedarAdmission(mock) + got, err := adm.FilterResources(context.Background(), cedarIdentity(), + []vmcp.Resource{{URI: "res-a"}, {URI: "res-b"}, {URI: "res-c"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "res-a", got[0].URI) + assert.Equal(t, authorizers.MCPFeatureResource, mock.calls[0].feature) + assert.Equal(t, authorizers.MCPOperationRead, mock.calls[0].operation) + }) + + t.Run("prompts", func(t *testing.T) { + t.Parallel() + mock := &mockAuthorizer{results: map[string]mockResult{ + "p-a": {authorized: true}, + "p-b": {authorized: false}, + }} + adm := newCedarAdmission(mock) + got, err := adm.FilterPrompts(context.Background(), cedarIdentity(), + []vmcp.Prompt{{Name: "p-a"}, {Name: "p-b"}}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "p-a", got[0].Name) + assert.Equal(t, authorizers.MCPFeaturePrompt, mock.calls[0].feature) + assert.Equal(t, authorizers.MCPOperationGet, mock.calls[0].operation) + }) +} + +func TestCedarAdmission_AllowResourceReadAndPromptGet(t *testing.T) { + t.Parallel() + + mock := &mockAuthorizer{results: map[string]mockResult{ + "res-a": {authorized: true}, + "p-a": {authorized: false}, + }} + adm := newCedarAdmission(mock) + + ok, err := adm.AllowResourceRead(context.Background(), cedarIdentity(), &vmcp.Resource{URI: "res-a"}) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, authorizers.MCPFeatureResource, mock.calls[0].feature) + assert.Equal(t, authorizers.MCPOperationRead, mock.calls[0].operation) + + ok, err = adm.AllowPromptGet(context.Background(), cedarIdentity(), &vmcp.Prompt{Name: "p-a"}) + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, authorizers.MCPFeaturePrompt, mock.calls[1].feature) + assert.Equal(t, authorizers.MCPOperationGet, mock.calls[1].operation) +} + +// TestCedarAdmission_WithCedarAuthorizer exercises the seam against a real Cedar +// authorizer, mirroring pkg/authz/tool_filter_test.go's Cedar cases, and covers +// the nil-identity edge: the adapter re-injects identity, so ErrMissingPrincipal +// only surfaces for a genuinely nil identity — and filter/call agree (both deny). +func TestCedarAdmission_WithCedarAuthorizer(t *testing.T) { + t.Parallel() + + adm := cedarAdmissionWith(t, + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + `permit(principal, action == Action::"read_resource", resource == Resource::"res-a");`, + `permit(principal, action == Action::"get_prompt", resource == Prompt::"p-a");`, + ) + id := cedarIdentity() + ctx := context.Background() + + t.Run("tools filter and call agree", func(t *testing.T) { + t.Parallel() + got, err := adm.FilterTools(ctx, id, []vmcp.Tool{{Name: "weather"}, {Name: "calculator"}}) + require.NoError(t, err) + assert.Equal(t, []string{"weather"}, toolNames(got)) + + ok, err := adm.AllowToolCall(ctx, id, &vmcp.Tool{Name: "weather"}, nil) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowToolCall(ctx, id, &vmcp.Tool{Name: "calculator"}, nil) + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("resource and prompt parity", func(t *testing.T) { + t.Parallel() + ok, err := adm.AllowResourceRead(ctx, id, &vmcp.Resource{URI: "res-a"}) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowResourceRead(ctx, id, &vmcp.Resource{URI: "res-b"}) + require.NoError(t, err) + assert.False(t, ok) + + ok, err = adm.AllowPromptGet(ctx, id, &vmcp.Prompt{Name: "p-a"}) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowPromptGet(ctx, id, &vmcp.Prompt{Name: "p-b"}) + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("nil identity denies consistently", func(t *testing.T) { + t.Parallel() + // FilterTools skips the tool (per-tool ErrMissingPrincipal -> log-and-continue). + got, err := adm.FilterTools(ctx, nil, []vmcp.Tool{{Name: "weather"}}) + require.NoError(t, err) + assert.Empty(t, got) + // AllowToolCall surfaces the missing-principal error (deny). + ok, err := adm.AllowToolCall(ctx, nil, &vmcp.Tool{Name: "weather"}, nil) + require.Error(t, err) + assert.False(t, ok) + }) +} + +// TestCedarAdmission_AnnotationWhenClause covers R1(2): policies keyed on +// annotations (resource.readOnlyHint) evaluate using core-sourced Tool.Annotations +// identically for both the list filter and the call. +func TestCedarAdmission_AnnotationWhenClause(t *testing.T) { + t.Parallel() + + adm := cedarAdmissionWith(t, + `permit(principal, action == Action::"call_tool", resource) when { resource.readOnlyHint == true };`) + id := cedarIdentity() + ctx := context.Background() + + readOnly := vmcp.Tool{Name: "ro", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)}} + writable := vmcp.Tool{Name: "rw", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(false)}} + + got, err := adm.FilterTools(ctx, id, []vmcp.Tool{readOnly, writable}) + require.NoError(t, err) + assert.Equal(t, []string{"ro"}, toolNames(got), "readOnlyHint-gated allow keeps only the read-only tool") + + ok, err := adm.AllowToolCall(ctx, id, &readOnly, nil) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowToolCall(ctx, id, &writable, nil) + require.NoError(t, err) + assert.False(t, ok, "the call enforces the same readOnlyHint decision as the filter") +} + +// TestNewAdmission covers construction and R1(3): a nil Authz yields an allow-all +// seam (every Filter* returns its input unchanged, every Allow* returns true). +func TestNewAdmission(t *testing.T) { + t.Parallel() + + t.Run("nil config is allow-all", func(t *testing.T) { + t.Parallel() + adm, err := newAdmission(nil, "") + require.NoError(t, err) + ctx := context.Background() + + tools := []vmcp.Tool{{Name: "a"}, {Name: "b"}} + gotTools, err := adm.FilterTools(ctx, nil, tools) + require.NoError(t, err) + assert.Equal(t, tools, gotTools) + + resources := []vmcp.Resource{{URI: "r"}} + gotRes, err := adm.FilterResources(ctx, nil, resources) + require.NoError(t, err) + assert.Equal(t, resources, gotRes) + + prompts := []vmcp.Prompt{{Name: "p"}} + gotPrompts, err := adm.FilterPrompts(ctx, nil, prompts) + require.NoError(t, err) + assert.Equal(t, prompts, gotPrompts) + + ok, err := adm.AllowToolCall(ctx, nil, &vmcp.Tool{Name: "a"}, nil) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowResourceRead(ctx, nil, &vmcp.Resource{URI: "r"}) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowPromptGet(ctx, nil, &vmcp.Prompt{Name: "p"}) + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("cedar config builds an enforcing seam", func(t *testing.T) { + t.Parallel() + adm, err := newAdmission( + cedarAuthzConfig(t, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`), + "test-vmcp") + require.NoError(t, err) + + ok, err := adm.AllowToolCall(context.Background(), cedarIdentity(), &vmcp.Tool{Name: "weather"}, nil) + require.NoError(t, err) + assert.True(t, ok) + ok, err = adm.AllowToolCall(context.Background(), cedarIdentity(), &vmcp.Tool{Name: "calculator"}, nil) + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("empty server name with authz errors", func(t *testing.T) { + t.Parallel() + _, err := newAdmission( + cedarAuthzConfig(t, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`), + "") + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + }) + + t.Run("unsupported authz type errors", func(t *testing.T) { + t.Parallel() + bad, err := authorizers.NewConfig(struct { + Version string `json:"version"` + Type string `json:"type"` + }{Version: "1.0", Type: "definitely-not-registered"}) + require.NoError(t, err) + + _, err = newAdmission(bad, "") + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + }) +} + +// TestAdmission_ListCallLookupEnforceSameDecision covers R1(1) end-to-end through +// the core: a tool denied by Cedar is omitted from ListTools, refused by CallTool, +// and unresolvable via LookupTool — closing the "list says yes / call says no" gap. +func TestAdmission_ListCallLookupEnforceSameDecision(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.ServerName = "test-vmcp" + cfg.Authz = cedarAuthzConfig(t, + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) + + rt := &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"weather": backendTarget()}} + m.reg.EXPECT().List(gomock.Any()). + Return([]vmcp.Backend{{ID: testBackendID, HealthStatus: vmcp.BackendHealthy}}).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("weather"), backendTool("calculator")}, + RoutingTable: rt, + }, nil).AnyTimes() + + // Only the permitted tool ever reaches the backend. + want := &vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}} + m.client.EXPECT().CallTool(gomock.Any(), gomock.Any(), "weather", gomock.Any(), gomock.Any()).Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + ctx := context.Background() + id := cedarIdentity() + + // List: the denied tool is omitted. + tools, err := c.ListTools(ctx, id) + require.NoError(t, err) + assert.Equal(t, []string{"weather"}, toolNames(tools)) + + // Call: the permitted tool routes; the denied tool is refused (same decision). + got, err := c.CallTool(ctx, id, "weather", nil, nil) + require.NoError(t, err) + assert.Equal(t, want, got) + + _, err = c.CallTool(ctx, id, "calculator", nil, nil) + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed) + + // Lookup: the denied tool is unresolvable; the permitted one resolves. + _, err = c.LookupTool(ctx, id, "calculator") + assert.ErrorIs(t, err, vmcp.ErrNotFound) + tool, err := c.LookupTool(ctx, id, "weather") + require.NoError(t, err) + assert.Equal(t, "weather", tool.Name) +} + +// TestAdmission_AnnotationGatedDecisionMatchesListAndCall confirms (MEDIUM follow-up) +// that an annotation-gated policy (resource.readOnlyHint) reaches the same verdict +// for ListTools and CallTool through the core: CallTool sources the tool's +// annotations from the advertised set (findAdvertisedTool), so the read-only tool is +// both advertised and callable while the writable tool is both filtered and denied. +func TestAdmission_AnnotationGatedDecisionMatchesListAndCall(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.ServerName = "test-vmcp" + cfg.Authz = cedarAuthzConfig(t, + `permit(principal, action == Action::"call_tool", resource) when { resource.readOnlyHint == true };`) + + ro := backendTool("ro") + ro.Annotations = &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)} + rw := backendTool("rw") + rw.Annotations = &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(false)} + + rt := &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"ro": backendTarget(), "rw": backendTarget()}} + m.reg.EXPECT().List(gomock.Any()). + Return([]vmcp.Backend{{ID: testBackendID, HealthStatus: vmcp.BackendHealthy}}).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{ro, rw}, + RoutingTable: rt, + }, nil).AnyTimes() + + // Only the read-only tool is callable, so only it reaches the backend. + want := &vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}} + m.client.EXPECT().CallTool(gomock.Any(), gomock.Any(), "ro", gomock.Any(), gomock.Any()).Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + ctx := context.Background() + id := cedarIdentity() + + tools, err := c.ListTools(ctx, id) + require.NoError(t, err) + assert.Equal(t, []string{"ro"}, toolNames(tools), "only the read-only tool is advertised") + + got, err := c.CallTool(ctx, id, "ro", nil, nil) + require.NoError(t, err) + assert.Equal(t, want, got, "the read-only tool is callable — annotations flow through CallTool") + + _, err = c.CallTool(ctx, id, "rw", nil, nil) + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed, "the writable tool is denied identically to the list filter") +} + +// TestAdmission_ResourceAndPromptDenyThroughCore confirms the deny wiring is +// present on the resource and prompt call paths (the seam refuses before routing, +// so no backend client call is made). +func TestAdmission_ResourceAndPromptDenyThroughCore(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.ServerName = "test-vmcp" + cfg.Authz = cedarAuthzConfig(t, + `permit(principal, action == Action::"read_resource", resource == Resource::"res-a");`, + `permit(principal, action == Action::"get_prompt", resource == Prompt::"p-a");`, + ) + + m.reg.EXPECT().List(gomock.Any()).Return(nil).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Resources: []vmcp.Resource{{URI: "res-a", BackendID: testBackendID}, {URI: "res-b", BackendID: testBackendID}}, + Prompts: []vmcp.Prompt{{Name: "p-a", BackendID: testBackendID}, {Name: "p-b", BackendID: testBackendID}}, + RoutingTable: &vmcp.RoutingTable{}, + }, nil).AnyTimes() + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + ctx := context.Background() + id := cedarIdentity() + + resources, err := c.ListResources(ctx, id) + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, "res-a", resources[0].URI) + + _, err = c.ReadResource(ctx, id, "res-b") + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed) + + prompts, err := c.ListPrompts(ctx, id) + require.NoError(t, err) + require.Len(t, prompts, 1) + assert.Equal(t, "p-a", prompts[0].Name) + + _, err = c.GetPrompt(ctx, id, "p-b", nil) + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed) +} + +// TestAdmission_CallToolForwardsArgsToAuthorizer proves call arguments flow the full +// chain CallTool -> AllowToolCall -> authorizer: an arg-gated Cedar policy permits the +// call only when the args satisfy it, so the permitted args route to the backend and +// the failing args are denied with ErrAuthorizationFailed. +func TestAdmission_CallToolForwardsArgsToAuthorizer(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.ServerName = "test-vmcp" + cfg.Authz = cedarAuthzConfig(t, + `permit(principal, action == Action::"call_tool", resource == Tool::"deploy") when { context.arg_mode == "safe" };`) + + rt := &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"deploy": backendTarget()}} + m.reg.EXPECT().List(gomock.Any()). + Return([]vmcp.Backend{{ID: testBackendID, HealthStatus: vmcp.BackendHealthy}}).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("deploy")}, + RoutingTable: rt, + }, nil).AnyTimes() + + // Only the args-satisfying call reaches the backend. + want := &vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}} + m.client.EXPECT().CallTool(gomock.Any(), gomock.Any(), "deploy", gomock.Any(), gomock.Any()).Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + ctx := context.Background() + id := cedarIdentity() + + got, err := c.CallTool(ctx, id, "deploy", map[string]any{"mode": "safe"}, nil) + require.NoError(t, err) + assert.Equal(t, want, got, "args satisfying the policy flow through and the call routes") + + _, err = c.CallTool(ctx, id, "deploy", map[string]any{"mode": "dangerous"}, nil) + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed, "args failing the policy deny the call — args reached the authorizer") +} + +// TestAdmission_CallToolNilAdvertisedToolDeniesUnderAnnotationPolicy exercises +// findAdvertisedTool's nil branch end-to-end: a name that is routable but absent from +// the advertised Tools is called with a synthesized bare Tool{Name} carrying no +// annotations, so an annotation-gated policy evaluates with no hints and denies — +// proving routing is not reached (no backend client call) and correcting the prior +// "unroutable" assumption. +func TestAdmission_CallToolNilAdvertisedToolDeniesUnderAnnotationPolicy(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.ServerName = "test-vmcp" + cfg.Authz = cedarAuthzConfig(t, + `permit(principal, action == Action::"call_tool", resource) when { resource.readOnlyHint == true };`) + + // "ghost" is in the routing table but NOT in the advertised Tools. + rt := &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"ghost": backendTarget()}} + m.reg.EXPECT().List(gomock.Any()). + Return([]vmcp.Backend{{ID: testBackendID, HealthStatus: vmcp.BackendHealthy}}).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: nil, + RoutingTable: rt, + }, nil).AnyTimes() + // No client.CallTool expectation: the call is denied before routing. + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.CallTool(context.Background(), cedarIdentity(), "ghost", nil, nil) + assert.ErrorIs(t, err, vmcp.ErrAuthorizationFailed, + "a routable name absent from the advertised set is evaluated with no hints and denied") +} + +// TestAdmission_IdentityNeverLogged covers R1(5): the seam passes *auth.Identity +// through unchanged and never logs identity/token material, even on the per-tool +// skip path. The seam logs through an injected logger (withLogger), so the test +// captures output WITHOUT swapping the process-global slog default — that global +// swap raced (-race) against parallel sibling tests that also log. +func TestAdmission_IdentityNeverLogged(t *testing.T) { + t.Parallel() + const secret = "super-secret-token-value" + + // An erroring authorizer drives the FilterTools skip-warning so the assertion + // is not vacuous. + mock := &mockAuthorizer{results: map[string]mockResult{"boom": {err: errors.New("policy eval failed")}}} + var buf bytes.Buffer + adm := newCedarAdmission(mock, + withLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))) + + id := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "user", Claims: map[string]interface{}{"secret": secret}}, + Token: secret, + UpstreamTokens: map[string]string{"github": secret}, + } + + _, _ = adm.FilterTools(context.Background(), id, []vmcp.Tool{{Name: "boom"}}) + + logs := buf.String() + require.NotEmpty(t, logs, "expected a skip warning so the assertion is not vacuous") + assert.NotContains(t, logs, secret, "identity token must never be logged") +} diff --git a/pkg/vmcp/core/core.go b/pkg/vmcp/core/core.go new file mode 100644 index 0000000000..4e10fa9a09 --- /dev/null +++ b/pkg/vmcp/core/core.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package core defines the Virtual MCP domain object: the identity-parameterized +// [VMCP] interface and the [Config] of collaborators that New assembles into a +// working core. +// +// The core is the domain boundary extracted from today's transport god-object +// (server.New): it aggregates backend capabilities, routes calls, and drives +// composite workflows behind a single, identity-explicit contract. It lives in +// its own package — rather than the root pkg/vmcp package — because Config +// references the aggregator, router, composer, health, and authz collaborators, +// all of which import pkg/vmcp; placing the core here lets it depend on both the +// root domain types and those collaborators without an import cycle. +// +// No mcp-go types cross this boundary (vmcp anti-pattern #5): the interface speaks +// only in root pkg/vmcp domain types ([vmcp.Tool], [vmcp.Resource], [vmcp.Prompt], +// and their result wrappers) plus an explicit [*auth.Identity]. +package core + +import ( + "context" + + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/telemetry" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + "github.com/stacklok/toolhive/pkg/vmcp/composer" + "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/router" +) + +// VMCP is the core Virtual MCP domain object. +// +// Contract: +// - Identity is an explicit parameter on every method and is NEVER read from +// context (anti-pattern #1). A nil identity is anonymous; bound-identity +// mismatch is the caller's concern only at the session layer (in Serve), not +// here — the core takes an already-authenticated *auth.Identity. The +// nil-identity/anonymous semantics mirror session/types.ShouldAllowAnonymous; +// the concrete behavior is reproduced by New. +// - Implementations MUST be safe for concurrent use. +// - Decorators may only SUBTRACT reachability: filter list output or refuse a +// call before delegating to inner. They hold only an inner VMCP and have no +// path to backends except through it, so they cannot widen access. This +// mirrors the list-filter/call-deny pairing in pkg/authz/tool_filter.go. +// - args/meta maps are treated as read-only; the core copies before mutating +// (go-style: copy before mutating caller input). +// - ReadResource results: Meta may be nil — the mcp-go resources/read handler +// cannot forward _meta (see the NOTE on [vmcp.ResourceReadResult].Meta). +// Do not rely on it. +// +// ctx is used only for cancellation, deadlines, and trace propagation — never to +// carry identity or other domain data. +type VMCP interface { + // ListTools returns the tools advertised to identity. The returned + // [vmcp.Tool] values carry their logical BackendID. + ListTools(ctx context.Context, identity *auth.Identity) ([]vmcp.Tool, error) + + // CallTool invokes the named tool on behalf of identity. + // + // args contains the tool input parameters. meta carries protocol-level + // metadata (_meta) forwarded from the client; it is retained on this method + // (rather than dropped) so the core can forward it through + // [vmcp.BackendClient.CallTool] to the backend MCP server. Both maps are + // treated as read-only. + CallTool(ctx context.Context, identity *auth.Identity, name string, + args map[string]any, meta map[string]any) (*vmcp.ToolCallResult, error) + + // ListResources returns the resources advertised to identity. + ListResources(ctx context.Context, identity *auth.Identity) ([]vmcp.Resource, error) + + // ReadResource reads the resource at uri on behalf of identity. The + // returned result's Meta may be nil (see the interface contract). + ReadResource(ctx context.Context, identity *auth.Identity, uri string) (*vmcp.ResourceReadResult, error) + + // ListPrompts returns the prompts advertised to identity. + ListPrompts(ctx context.Context, identity *auth.Identity) ([]vmcp.Prompt, error) + + // GetPrompt retrieves the named prompt on behalf of identity. + // + // args contains the prompt input parameters and is treated as read-only. + // Unlike CallTool there is no meta parameter: prompts/get carries no client + // _meta to forward. + GetPrompt(ctx context.Context, identity *auth.Identity, name string, + args map[string]any) (*vmcp.PromptGetResult, error) + + // LookupTool resolves an advertised tool name to its capability (incl. + // BackendID) WITHOUT invoking it. Returns an error for an unknown or + // unadvertised name — the validation seam for the call path. Lookups apply + // the same admission filter as ListTools, so they never resolve a denied + // capability. + LookupTool(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Tool, error) + + // LookupResource resolves an advertised resource URI to its capability + // (incl. BackendID) WITHOUT reading it. Returns an error for an unknown or + // unadvertised URI. Applies the same admission filter as ListResources. + LookupResource(ctx context.Context, identity *auth.Identity, uri string) (*vmcp.Resource, error) + + // LookupPrompt resolves an advertised prompt name to its capability (incl. + // BackendID) WITHOUT retrieving it. Returns an error for an unknown or + // unadvertised name. Applies the same admission filter as ListPrompts. + LookupPrompt(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Prompt, error) + + // Close releases core-held resources (backend connections, etc.). + // Implementations must be idempotent: calling Close multiple times returns nil. + Close() error +} + +// Config holds the collaborators New assembles into the core. The fields are +// declared here as the contract; New's body (which wires them into a concrete +// *coreVMCP) lands in a later change. +// +// Cross-cutting TelemetryProvider/AuditConfig are consumed by both New and Serve +// (not a clean partition). HealthStatusProvider is the read-only health view +// built at the composition root and injected here; a nil provider means no health +// filtering (all backends included), matching today's no-monitor behavior. +type Config struct { + // Aggregator discovers and merges backend capabilities into the advertised set. + Aggregator aggregator.Aggregator + + // Router resolves an advertised capability to its backend target. + Router router.Router + + // BackendRegistry enumerates the configured backends. + BackendRegistry vmcp.BackendRegistry + + // BackendClient performs MCP protocol calls against backend servers. + BackendClient vmcp.BackendClient + + // WorkflowDefs holds the composite-tool workflow definitions, keyed by name. + WorkflowDefs map[string]*composer.WorkflowDefinition + + // Authz feeds the admission seam New builds. A nil Authz means authorization + // is unconfigured (allow-all), matching today's `AuthzMiddleware != nil` guard: + // the composition root only populates this when Cedar policies exist (mirroring + // newCedarAuthzMiddleware's `len(Policies) > 0` check). When Authz is non-nil, + // ServerName is REQUIRED (New fails fast with vmcp.ErrInvalidConfig otherwise). + Authz *authz.Config + + // ServerName is the VirtualMCPServer name used as the Cedar resource entity name + // in authorization policy evaluation — parity with the serverName threaded into + // the HTTP authz middleware. It is REQUIRED when Authz is non-nil (New returns + // vmcp.ErrInvalidConfig on an empty value, since Cedar resource-scoped policies + // depend on it) and ignored when Authz is nil. + ServerName string + + // TelemetryProvider is the cross-cutting telemetry provider (also consumed by Serve). + TelemetryProvider *telemetry.Provider + + // AuditConfig is the cross-cutting audit configuration (also consumed by Serve). + AuditConfig *audit.Config + + // HealthStatusProvider is the read-only backend health view built at the + // composition root. Nil means no health filtering (all backends included). + HealthStatusProvider health.StatusProvider + + // Elicitation sends MCP elicitation requests to the client and blocks for the + // response. It is the domain-typed seam (vmcp anti-pattern #5: no mcp-go types) + // consumed by the composer's elicitation handler during composite-tool + // workflows. The composition root supplies the SDK-backed adapter; the core + // only forwards it to the workflow engine. May be nil when no configured + // workflow performs elicitation; New rejects a nil Elicitation when any + // configured workflow contains an elicitation step. + Elicitation vmcp.ElicitationRequester +} diff --git a/pkg/vmcp/core/core_calls.go b/pkg/vmcp/core/core_calls.go new file mode 100644 index 0000000000..d928c4e464 --- /dev/null +++ b/pkg/vmcp/core/core_calls.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/composer" + "github.com/stacklok/toolhive/pkg/vmcp/router" +) + +// CallTool invokes the named tool. Composite tools (those defined as workflows) +// execute through a per-call composer bound to the freshly aggregated routing +// table; all other names route to a single backend via a session router built +// from the same table. Returns vmcp.ErrNotFound for an unadvertised name and +// vmcp.ErrAuthorizationFailed when admission denies identity the call. +// +// args and meta are treated as read-only and copied before being forwarded +// (go-style: copy before mutating caller input). The admission decision enforces +// the same policy ListTools filters on. identity is never logged. See ListTools +// for the nil/anonymous semantics. +func (c *coreVMCP) CallTool( + ctx context.Context, + identity *auth.Identity, + name string, + args map[string]any, + meta map[string]any, +) (*vmcp.ToolCallResult, error) { + argsCopy := maps.Clone(args) + metaCopy := maps.Clone(meta) + + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + + // Admission deny: enforce the same decision ListTools filters on, sourcing the + // tool's annotations from the advertised set (mirroring the annotation cache the + // HTTP middleware populates from tools/list) so annotation-gated policies + // evaluate. advertisedTools includes composites, so their annotations are sourced + // too. A name absent from the advertised set carries no annotations, so an + // annotation-gated decision evaluates with no hints (and may deny). In normal + // operation the advertised set and the routing table are derived from the same + // aggregation, so this only arises if they diverge. + tool := findAdvertisedTool(c.advertisedTools(agg), name) + if tool == nil { + tool = &vmcp.Tool{Name: name} + } + // An authorizer ERROR is treated as a denial (fail closed), classified as + // ErrAuthorizationFailed so the Serve adapter can distinguish it from a transport + // failure via errors.Is — mirroring the live authorizeAndServe, which routes both + // err and !authorized to the unauthorized response. The underlying error is + // preserved in the chain for server-side diagnostics; Serve decides what reaches + // the client. + if allowed, err := c.admission.AllowToolCall(ctx, identity, tool, argsCopy); err != nil { + return nil, fmt.Errorf("%w: tool %q: %w", vmcp.ErrAuthorizationFailed, name, err) + } else if !allowed { + return nil, fmt.Errorf("%w: tool %q", vmcp.ErrAuthorizationFailed, name) + } + + // Composite tool: execute only when the workflow is actually advertised in the + // current view — accessible AND not shadowed by a conflicting backend tool. This + // uses the same gate as ListTools (accessibleComposites), so advertised equals + // executed. A name that collides with a backend tool is NOT in the set and falls + // through to backend routing, matching the legacy decorator. + if def, ok := c.accessibleComposites(agg)[name]; ok { + engine := c.composerFactory(agg.RoutingTable, agg.Tools) + return executeComposite(ctx, engine, def, argsCopy) + } + + // Backend tool: route through a session router bound to the fresh table. The + // backend client translates the advertised name to the backend's capability + // name internally (client.go:772), mirroring the legacy tool handler. + target, err := router.NewSessionRouter(agg.RoutingTable).RouteTool(ctx, name) + if err != nil { + if errors.Is(err, router.ErrToolNotFound) { + return nil, fmt.Errorf("%w: tool %q", vmcp.ErrNotFound, name) + } + return nil, fmt.Errorf("routing tool %q: %w", name, err) + } + return c.backendClient.CallTool(ctx, target, name, argsCopy, metaCopy) +} + +// ReadResource reads the resource at uri from its backend. Returns +// vmcp.ErrNotFound for an unadvertised URI and vmcp.ErrAuthorizationFailed when +// admission denies identity the read. See ListTools for identity semantics. +func (c *coreVMCP) ReadResource( + ctx context.Context, + identity *auth.Identity, + uri string, +) (*vmcp.ResourceReadResult, error) { + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + + // Admission deny: mirror ListResources' filter for the single read. Resources + // carry no annotations, so the URI alone identifies the decision. An authorizer + // error fails closed, classified as ErrAuthorizationFailed (see CallTool). + if allowed, err := c.admission.AllowResourceRead(ctx, identity, &vmcp.Resource{URI: uri}); err != nil { + return nil, fmt.Errorf("%w: resource %q: %w", vmcp.ErrAuthorizationFailed, uri, err) + } else if !allowed { + return nil, fmt.Errorf("%w: resource %q", vmcp.ErrAuthorizationFailed, uri) + } + + target, err := router.NewSessionRouter(agg.RoutingTable).RouteResource(ctx, uri) + if err != nil { + if errors.Is(err, router.ErrResourceNotFound) { + return nil, fmt.Errorf("%w: resource %q", vmcp.ErrNotFound, uri) + } + return nil, fmt.Errorf("routing resource %q: %w", uri, err) + } + // Pass the advertised URI; the backend client owns the single translation to + // the backend's capability name (client.go:874), matching CallTool. + return c.backendClient.ReadResource(ctx, target, uri) +} + +// GetPrompt retrieves the named prompt from its backend. args is treated as +// read-only and copied before being forwarded. Returns vmcp.ErrNotFound for an +// unadvertised name and vmcp.ErrAuthorizationFailed when admission denies identity +// the get. See ListTools for identity semantics. +func (c *coreVMCP) GetPrompt( + ctx context.Context, + identity *auth.Identity, + name string, + args map[string]any, +) (*vmcp.PromptGetResult, error) { + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + + // Admission deny: mirror ListPrompts' filter for the single get. Prompts carry + // no annotations, so the name alone identifies the decision. An authorizer error + // fails closed, classified as ErrAuthorizationFailed (see CallTool). + if allowed, err := c.admission.AllowPromptGet(ctx, identity, &vmcp.Prompt{Name: name}); err != nil { + return nil, fmt.Errorf("%w: prompt %q: %w", vmcp.ErrAuthorizationFailed, name, err) + } else if !allowed { + return nil, fmt.Errorf("%w: prompt %q", vmcp.ErrAuthorizationFailed, name) + } + + target, err := router.NewSessionRouter(agg.RoutingTable).RoutePrompt(ctx, name) + if err != nil { + if errors.Is(err, router.ErrPromptNotFound) { + return nil, fmt.Errorf("%w: prompt %q", vmcp.ErrNotFound, name) + } + return nil, fmt.Errorf("routing prompt %q: %w", name, err) + } + // Pass the advertised name; the backend client owns the single translation to + // the backend's capability name (client.go:927), matching CallTool. + return c.backendClient.GetPrompt(ctx, target, name, maps.Clone(args)) +} + +// executeComposite runs a composite-tool workflow and converts the result to a +// ToolCallResult. Workflow failures are returned as an IsError result (not a +// transport error), mirroring the legacy compositeToolsDecorator +// (internal/compositetools/decorator.go:76-114). +func executeComposite( + ctx context.Context, + engine composer.Composer, + def *composer.WorkflowDefinition, + params map[string]any, +) (*vmcp.ToolCallResult, error) { + result, err := engine.ExecuteWorkflow(ctx, def, params) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + slog.Warn("workflow execution timeout", "tool", def.Name, "error", err) + return compositeErrorResult("Workflow execution timeout exceeded"), nil + } + slog.Error("workflow execution failed", "tool", def.Name, "error", err) + return compositeErrorResult(fmt.Sprintf("Workflow execution failed: %v", err)), nil + } + if result == nil { + slog.Error("workflow executor returned nil result", "tool", def.Name) + return compositeErrorResult("Workflow executor returned nil result"), nil + } + if result.Error != nil { + slog.Error("workflow completed with error", "tool", def.Name, "error", result.Error) + return compositeErrorResult(fmt.Sprintf("Workflow error: %v", result.Error)), nil + } + + jsonBytes, err := json.Marshal(result.Output) + if err != nil { + return compositeErrorResult(fmt.Sprintf("failed to marshal output: %v", err)), nil + } + return &vmcp.ToolCallResult{ + Content: []vmcp.Content{{Type: vmcp.ContentTypeText, Text: string(jsonBytes)}}, + StructuredContent: result.Output, + }, nil +} + +// compositeErrorResult builds a tool-level error result for a failed workflow. +func compositeErrorResult(msg string) *vmcp.ToolCallResult { + return &vmcp.ToolCallResult{ + Content: []vmcp.Content{{Type: vmcp.ContentTypeText, Text: msg}}, + IsError: true, + } +} diff --git a/pkg/vmcp/core/core_calls_test.go b/pkg/vmcp/core/core_calls_test.go new file mode 100644 index 0000000000..c456d1e28a --- /dev/null +++ b/pkg/vmcp/core/core_calls_test.go @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + "github.com/stacklok/toolhive/pkg/vmcp/composer" +) + +// expectAggregation wires reg.List + agg.AggregateCapabilities to return agg once. +func expectAggregation(m *coreMocks, agg *aggregator.AggregatedCapabilities) { + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "be1", HealthStatus: vmcp.BackendHealthy}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(agg, nil) +} + +func TestCallTool_RoutesToBackend(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("tool_a")}, + RoutingTable: &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"tool_a": target}}, + }) + + want := &vmcp.ToolCallResult{StructuredContent: map[string]any{"result": "ok"}} + m.client.EXPECT(). + CallTool(gomock.Any(), gomock.Any(), "tool_a", gomock.Any(), gomock.Any()). + Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.CallTool(context.Background(), nil, "tool_a", map[string]any{"a": 1}, nil) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestCallTool_NotFound(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + expectAggregation(m, &aggregator.AggregatedCapabilities{RoutingTable: &vmcp.RoutingTable{}}) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.CallTool(context.Background(), nil, "missing", nil, nil) + assert.ErrorIs(t, err, vmcp.ErrNotFound) +} + +func TestCallTool_CopyBeforeMutate(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + RoutingTable: &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"tool_a": target}}, + }) + + // The backend client mutates the maps it receives; the caller's originals + // must be untouched because CallTool forwards clones. + m.client.EXPECT(). + CallTool(gomock.Any(), gomock.Any(), "tool_a", gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ *vmcp.BackendTarget, _ string, args, meta map[string]any) (*vmcp.ToolCallResult, error) { + args["injected"] = true + meta["injected"] = true + return &vmcp.ToolCallResult{}, nil + }) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + args := map[string]any{"a": 1} + meta := map[string]any{"m": "n"} + _, err = c.CallTool(context.Background(), nil, "tool_a", args, meta) + require.NoError(t, err) + + assert.Equal(t, map[string]any{"a": 1}, args, "caller args must not be mutated") + assert.Equal(t, map[string]any{"m": "n"}, meta, "caller meta must not be mutated") +} + +func TestCallTool_CompositeWorkflow(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.WorkflowDefs = map[string]*composer.WorkflowDefinition{ + "wf": {Name: "wf", Steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.echo"}}}, + } + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("be1.echo")}, + RoutingTable: &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"be1.echo": target}}, + }) + + // The composite workflow's single tool step routes to the backend through the + // per-call composer built from the aggregated routing table. + m.client.EXPECT(). + CallTool(gomock.Any(), gomock.Any(), "be1.echo", gomock.Any(), gomock.Any()). + Return(&vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.CallTool(context.Background(), nil, "wf", nil, nil) + require.NoError(t, err) + require.NotNil(t, got) + assert.False(t, got.IsError) + assert.Equal(t, true, got.StructuredContent["ok"]) +} + +func TestCallTool_CompositeNotAccessible(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.WorkflowDefs = map[string]*composer.WorkflowDefinition{ + "wf": {Name: "wf", Steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.echo"}}}, + } + + // Routing table does not contain be1.echo, so the composite is not reachable. + expectAggregation(m, &aggregator.AggregatedCapabilities{RoutingTable: &vmcp.RoutingTable{}}) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.CallTool(context.Background(), nil, "wf", nil, nil) + assert.ErrorIs(t, err, vmcp.ErrNotFound) +} + +func TestReadResource(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + RoutingTable: &vmcp.RoutingTable{Resources: map[string]*vmcp.BackendTarget{"file://a": target}}, + }) + + want := &vmcp.ResourceReadResult{Contents: []vmcp.ResourceContent{{URI: "file://a", Text: "hi"}}} + m.client.EXPECT().ReadResource(gomock.Any(), gomock.Any(), "file://a").Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ReadResource(context.Background(), nil, "file://a") + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestReadResource_NotFound(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + expectAggregation(m, &aggregator.AggregatedCapabilities{RoutingTable: &vmcp.RoutingTable{}}) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.ReadResource(context.Background(), nil, "file://missing") + assert.ErrorIs(t, err, vmcp.ErrNotFound) +} + +func TestGetPrompt(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + RoutingTable: &vmcp.RoutingTable{Prompts: map[string]*vmcp.BackendTarget{"p1": target}}, + }) + + want := &vmcp.PromptGetResult{Description: "a prompt"} + m.client.EXPECT().GetPrompt(gomock.Any(), gomock.Any(), "p1", gomock.Any()).Return(want, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.GetPrompt(context.Background(), nil, "p1", map[string]any{"x": 1}) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestGetPrompt_CopyBeforeMutate(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := backendTarget() + expectAggregation(m, &aggregator.AggregatedCapabilities{ + RoutingTable: &vmcp.RoutingTable{Prompts: map[string]*vmcp.BackendTarget{"p1": target}}, + }) + + // The backend client mutates the args it receives; the caller's original must be + // untouched because GetPrompt forwards a clone (parity with CallTool). + m.client.EXPECT(). + GetPrompt(gomock.Any(), gomock.Any(), "p1", gomock.Any()). + DoAndReturn(func(_ context.Context, _ *vmcp.BackendTarget, _ string, args map[string]any) (*vmcp.PromptGetResult, error) { + args["injected"] = true + return &vmcp.PromptGetResult{}, nil + }) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + args := map[string]any{"x": 1} + _, err = c.GetPrompt(context.Background(), nil, "p1", args) + require.NoError(t, err) + assert.Equal(t, map[string]any{"x": 1}, args, "caller args must not be mutated") +} + +func TestGetPrompt_NotFound(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + expectAggregation(m, &aggregator.AggregatedCapabilities{RoutingTable: &vmcp.RoutingTable{}}) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.GetPrompt(context.Background(), nil, "missing", nil) + assert.ErrorIs(t, err, vmcp.ErrNotFound) +} + +// TestCallTool_ResolvesRenamedTool exercises the conflict-resolution name path: +// the advertised name uses the dot convention ("be1.echo") while the routing +// table is keyed by the prefixed resolved name ("be1_echo") whose target carries +// a non-empty OriginalCapabilityName. The session router resolves it via +// GetBackendCapabilityName, and the core forwards the advertised name to the +// client (the client owns the translation to the backend's capability name). +func TestCallTool_ResolvesRenamedTool(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + target := &vmcp.BackendTarget{WorkloadID: "be1", OriginalCapabilityName: "echo", BaseURL: "http://be1:8080"} + expectAggregation(m, &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{{Name: "be1_echo", BackendID: "be1"}}, + RoutingTable: &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"be1_echo": target}}, + }) + + m.client.EXPECT(). + CallTool(gomock.Any(), target, "be1.echo", gomock.Any(), gomock.Any()). + Return(&vmcp.ToolCallResult{}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.CallTool(context.Background(), nil, "be1.echo", nil, nil) + require.NoError(t, err) +} + +// TestCompositeNameConflict_AdvertisedEqualsExecuted is the F1 parity guard: when a +// composite tool name collides with a backend tool name, ListTools advertises the +// backend tool (composites dropped) and CallTool must ALSO route that name to the +// backend — never execute the withheld composite. The single accessibleComposites +// gate keeps advertised == executed (matching the legacy decorator). +func TestCompositeNameConflict_AdvertisedEqualsExecuted(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + // Composite "shared" is accessible (its step routes), but its name also collides + // with an advertised backend tool "shared". + cfg.WorkflowDefs = map[string]*composer.WorkflowDefinition{ + "shared": {Name: "shared", Steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.echo"}}}, + } + + beTarget := &vmcp.BackendTarget{WorkloadID: "be1", BaseURL: "http://be1:8080"} + agg := &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{{Name: "shared", BackendID: "be1"}}, + RoutingTable: &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{ + "shared": beTarget, + "be1.echo": beTarget, + }}, + } + // Two aggregations: one for ListTools, one for CallTool. + m.reg.EXPECT().List(gomock.Any()).Return(nil).Times(2) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(agg, nil).Times(2) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + // ListTools advertises only the backend tool; the conflicting composite is dropped. + tools, err := c.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, tools, 1) + assert.Equal(t, "shared", tools[0].Name) + assert.Equal(t, "be1", tools[0].BackendID) + + // CallTool("shared") must route to the backend, not execute the composite. + want := &vmcp.ToolCallResult{StructuredContent: map[string]any{"from": "backend"}} + m.client.EXPECT().CallTool(gomock.Any(), beTarget, "shared", gomock.Any(), gomock.Any()).Return(want, nil) + + got, err := c.CallTool(context.Background(), nil, "shared", nil, nil) + require.NoError(t, err) + assert.Equal(t, want, got, "conflicting name must resolve to the backend tool, not the composite") +} + +// stubComposer is a configurable composer.Composer for unit-testing +// executeComposite's result/error conversion without the real workflow engine. +type stubComposer struct { + result *composer.WorkflowResult + err error +} + +func (s stubComposer) ExecuteWorkflow( + _ context.Context, _ *composer.WorkflowDefinition, _ map[string]any, +) (*composer.WorkflowResult, error) { + return s.result, s.err +} +func (stubComposer) ValidateWorkflow(_ context.Context, _ *composer.WorkflowDefinition) error { + return nil +} +func (stubComposer) GetWorkflowStatus(_ context.Context, _ string) (*composer.WorkflowStatus, error) { + return nil, nil +} +func (stubComposer) CancelWorkflow(_ context.Context, _ string) error { return nil } + +func TestExecuteComposite(t *testing.T) { + t.Parallel() + + def := &composer.WorkflowDefinition{Name: "wf"} + + tests := []struct { + name string + composer stubComposer + wantIsError bool + wantMsg string // substring expected in the error content + wantOutput map[string]any + }{ + { + name: "success", + composer: stubComposer{result: &composer.WorkflowResult{Output: map[string]any{"k": "v"}}}, + wantIsError: false, + wantOutput: map[string]any{"k": "v"}, + }, + { + name: "execution error", + composer: stubComposer{err: errors.New("boom")}, + wantIsError: true, + wantMsg: "Workflow execution failed", + }, + { + name: "timeout", + composer: stubComposer{err: context.DeadlineExceeded}, + wantIsError: true, + wantMsg: "timeout", + }, + { + name: "nil result", + composer: stubComposer{}, + wantIsError: true, + wantMsg: "nil result", + }, + { + name: "result carries error", + composer: stubComposer{result: &composer.WorkflowResult{Error: errors.New("step failed")}}, + wantIsError: true, + wantMsg: "Workflow error", + }, + { + name: "marshal failure", + // A channel is not JSON-serializable, forcing json.Marshal(Output) to fail. + composer: stubComposer{result: &composer.WorkflowResult{Output: map[string]any{"ch": make(chan int)}}}, + wantIsError: true, + wantMsg: "failed to marshal output", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := executeComposite(context.Background(), tt.composer, def, nil) + require.NoError(t, err) // workflow failures are returned as IsError results, not errors + require.NotNil(t, got) + assert.Equal(t, tt.wantIsError, got.IsError) + if tt.wantMsg != "" { + require.NotEmpty(t, got.Content) + assert.Contains(t, got.Content[0].Text, tt.wantMsg) + } + if tt.wantOutput != nil { + assert.Equal(t, tt.wantOutput, got.StructuredContent) + } + }) + } +} diff --git a/pkg/vmcp/core/core_vmcp.go b/pkg/vmcp/core/core_vmcp.go new file mode 100644 index 0000000000..11a13c10e1 --- /dev/null +++ b/pkg/vmcp/core/core_vmcp.go @@ -0,0 +1,500 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + "github.com/stacklok/toolhive/pkg/vmcp/composer" + "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/internal/backendtelemetry" + "github.com/stacklok/toolhive/pkg/vmcp/internal/compositetools" + "github.com/stacklok/toolhive/pkg/vmcp/router" +) + +const ( + // stateStoreCleanupInterval is how often the in-memory workflow state store + // purges stale entries. Matches the legacy server.New value (server.go:386). + stateStoreCleanupInterval = 5 * time.Minute + + // stateStoreMaxAge is how long completed/failed workflows are retained. + // Matches the legacy server.New value (server.go:386). + stateStoreMaxAge = 1 * time.Hour +) + +// coreVMCP is the concrete, identity-parameterized [VMCP] assembled by [New]. +// +// It is stateless with respect to MCP sessions: every List/Lookup/Call +// re-derives the advertised capability view by health-filtering the backend +// registry and aggregating on demand ("the core filters, Serve caches" — caching +// is added by a Serve decorator, not here). It holds no context-coupled router: +// routing is performed through a [router.NewSessionRouter] bound to the freshly +// aggregated routing table, removing composite tools' dependency on +// context-injected capabilities (vmcp anti-pattern #1). +// +// Safe for concurrent use: all fields are read-only after construction except +// the cleanup guarded by closeOnce. +type coreVMCP struct { + aggregator aggregator.Aggregator + backendRegistry vmcp.BackendRegistry + backendClient vmcp.BackendClient + + // health is the injected read-only backend health view. Nil means no health + // filtering (all backends included), matching today's no-monitor behavior. + health health.StatusProvider + + // admission enforces the authorization decision for List* (filter) and + // Call/Read/Get (deny) from one source. Never nil: New installs an allow-all + // seam when authz is unconfigured. + admission Admission + + // workflowDefs holds the validated composite-tool workflow definitions keyed + // by advertised tool name. + workflowDefs map[string]*composer.WorkflowDefinition + + // composerFactory builds a per-call composite-tool engine bound to a routing + // table, generalizing server.New's sessionComposerFactory (server.go:393). + composerFactory func(sessionRT *vmcp.RoutingTable, sessionTools []vmcp.Tool) composer.Composer + + // stopStore stops the workflow state store's background cleanup goroutine. + // Captured at construction (the store is created internally, not injected) so + // Close is not a silent capability assertion. Guarded by closeOnce. + stopStore func() + + closeOnce sync.Once +} + +var _ VMCP = (*coreVMCP)(nil) + +// New constructs the core [VMCP] by relocating the domain wiring that lives in +// server.New today (server.go:330-405): telemetry backend-client decoration, the +// optional workflow auditor, the in-memory state store and workflow engine, the +// per-session composer factory, and fail-fast workflow validation. +// +// cfg.Router is consumed only to build the workflow-validation engine (parity +// with server.New); the core does not route through it at request time. cfg's +// Aggregator, BackendRegistry, and BackendClient are required and New fails +// loudly when any is nil. The admission seam is built here from cfg.Authz (with +// cfg.ServerName); a nil cfg.Authz yields an allow-all seam. +func New(cfg *Config) (VMCP, error) { + if err := validateConfig(cfg); err != nil { + return nil, err + } + + // Build the admission seam before acquiring resources so a bad policy fails + // fast without leaking the state store's cleanup goroutine. + admission, err := newAdmission(cfg.Authz, cfg.ServerName) + if err != nil { + return nil, fmt.Errorf("failed to build admission seam: %w", err) + } + + backendClient := cfg.BackendClient + + // Telemetry backend-client decoration must happen BEFORE building the workflow + // engine so that workflow backend calls are instrumented (server.go:350-367). + if cfg.TelemetryProvider != nil { + decorated, err := backendtelemetry.MonitorBackends( + context.Background(), + cfg.TelemetryProvider.MeterProvider(), + cfg.TelemetryProvider.TracerProvider(), + cfg.BackendRegistry.List(context.Background()), + backendClient, + ) + if err != nil { + return nil, fmt.Errorf("failed to monitor backends: %w", err) + } + backendClient = decorated + } + + // Workflow auditor (server.go:370-381). + var workflowAuditor *audit.WorkflowAuditor + if cfg.AuditConfig != nil { + if err := cfg.AuditConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid audit configuration: %w", err) + } + var err error + workflowAuditor, err = audit.NewWorkflowAuditor(cfg.AuditConfig) + if err != nil { + return nil, fmt.Errorf("failed to create workflow auditor: %w", err) + } + slog.Info("workflow audit logging enabled") + } + + // The elicitation handler depends only on the domain-typed ElicitationRequester + // (#5436); no mcp-go types cross this boundary (vmcp anti-pattern #5). + elicitationHandler := composer.NewDefaultElicitationHandler(cfg.Elicitation) + + // State store + workflow engine (server.go:386-387). The state store starts a + // background cleanup goroutine immediately, so any error path after this point + // must stop it to avoid a leak. + stateStore := composer.NewInMemoryStateStore(stateStoreCleanupInterval, stateStoreMaxAge) + + // NewInMemoryStateStore returns *inMemoryStateStore, which owns that cleanup + // goroutine. Capture its Stop here so Close releases it; warn loudly (rather + // than a silent no-op, per go-style) if a future store swap drops the + // capability, so a leaked goroutine is diagnosable instead of invisible. + stopStore := func() {} + if s, ok := stateStore.(interface{ Stop() }); ok { + stopStore = s.Stop + } else { + slog.Warn("workflow state store exposes no Stop(); its cleanup goroutine will leak on Close", + "type", fmt.Sprintf("%T", stateStore)) + } + + // composerFactory builds a composite-tool engine bound to a specific routing + // table, generalizing server.New's sessionComposerFactory (server.go:393-398). + // Workflow-level telemetry (toolhive_vmcp_workflow_*) is intentionally NOT wired + // here: that instrumentation lives in the session/Serve layer + // (sessionmanager/factory.go:174-176) and is added when the core is wired into + // Serve (#5439). This mirrors server.go:393-398, which is also uninstrumented. + composerFactory := func(sessionRT *vmcp.RoutingTable, sessionTools []vmcp.Tool) composer.Composer { + return composer.NewWorkflowEngine( + router.NewSessionRouter(sessionRT), backendClient, elicitationHandler, + stateStore, workflowAuditor, sessionTools, + ) + } + + // Validate workflows fail-fast (server.go:400-405). The validation engine uses + // cfg.Router; ValidateWorkflow checks structure (cycles, references) and does + // not route, so the context-coupled default router is acceptable here. + validationEngine := composer.NewWorkflowEngine( + cfg.Router, backendClient, elicitationHandler, stateStore, workflowAuditor, nil, + ) + workflowDefs, err := validateWorkflowDefs(validationEngine, cfg.WorkflowDefs) + if err != nil { + stopStore() + return nil, fmt.Errorf("workflow validation failed: %w", err) + } + + return &coreVMCP{ + aggregator: cfg.Aggregator, + backendRegistry: cfg.BackendRegistry, + backendClient: backendClient, + health: cfg.HealthStatusProvider, + admission: admission, + workflowDefs: workflowDefs, + composerFactory: composerFactory, + stopStore: stopStore, + }, nil +} + +// ListTools health-filters the registry, aggregates backend capabilities on +// demand, appends the composite tools reachable in the current view, and returns +// only the subset identity is admitted to call (the admission seam — the same +// decision CallTool enforces). +// +// A nil identity is treated as anonymous, consistent with +// session/types.ShouldAllowAnonymous — bound-session caller enforcement +// (ErrNilCaller/ErrUnauthorizedCaller) lives in the Serve session layer, not +// here. identity is never logged. +func (c *coreVMCP) ListTools(ctx context.Context, identity *auth.Identity) ([]vmcp.Tool, error) { + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + return c.admission.FilterTools(ctx, identity, c.advertisedTools(agg)) +} + +// ListResources health-filters the registry, aggregates resources on demand, and +// returns only those identity is admitted to read. +func (c *coreVMCP) ListResources(ctx context.Context, identity *auth.Identity) ([]vmcp.Resource, error) { + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + return c.admission.FilterResources(ctx, identity, agg.Resources) +} + +// ListPrompts health-filters the registry, aggregates prompts on demand, and +// returns only those identity is admitted to get. +func (c *coreVMCP) ListPrompts(ctx context.Context, identity *auth.Identity) ([]vmcp.Prompt, error) { + agg, err := c.aggregatedView(ctx) + if err != nil { + return nil, err + } + return c.admission.FilterPrompts(ctx, identity, agg.Prompts) +} + +// LookupTool resolves an advertised tool name (incl. composite tools) to its +// capability without invoking it. It delegates to ListTools, so it applies the +// same health/advertising AND admission view: a name that is unknown, unadvertised, +// or denied to identity returns vmcp.ErrNotFound (a denied tool is never resolved). +func (c *coreVMCP) LookupTool(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Tool, error) { + tools, err := c.ListTools(ctx, identity) + if err != nil { + return nil, err + } + for i := range tools { + if tools[i].Name == name { + tool := tools[i] + return &tool, nil + } + } + return nil, fmt.Errorf("%w: tool %q", vmcp.ErrNotFound, name) +} + +// LookupResource resolves an advertised resource URI to its capability without +// reading it. Delegates to ListResources, so a URI that is unknown, unadvertised, +// or denied to identity returns vmcp.ErrNotFound. +func (c *coreVMCP) LookupResource(ctx context.Context, identity *auth.Identity, uri string) (*vmcp.Resource, error) { + resources, err := c.ListResources(ctx, identity) + if err != nil { + return nil, err + } + for i := range resources { + if resources[i].URI == uri { + resource := resources[i] + return &resource, nil + } + } + return nil, fmt.Errorf("%w: resource %q", vmcp.ErrNotFound, uri) +} + +// LookupPrompt resolves an advertised prompt name to its capability without +// retrieving it. Delegates to ListPrompts, so a name that is unknown, unadvertised, +// or denied to identity returns vmcp.ErrNotFound. +func (c *coreVMCP) LookupPrompt(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Prompt, error) { + prompts, err := c.ListPrompts(ctx, identity) + if err != nil { + return nil, err + } + for i := range prompts { + if prompts[i].Name == name { + prompt := prompts[i] + return &prompt, nil + } + } + return nil, fmt.Errorf("%w: prompt %q", vmcp.ErrNotFound, name) +} + +// Close stops the workflow state store's cleanup goroutine. It is idempotent: +// the underlying Stop closes a channel that cannot be closed twice, so the work +// is guarded by sync.Once and subsequent calls return nil. +func (c *coreVMCP) Close() error { + c.closeOnce.Do(c.stopStore) + return nil +} + +// aggregatedView health-filters the backend registry and aggregates capabilities +// on demand. The core holds no per-session capability cache (the core filters, +// Serve caches): every List/Lookup/Call re-derives the advertised view, so a +// lookup-then-call flow aggregates twice. This is intentional — caching is the +// Serve layer's responsibility, not the core's (vmcp anti-patterns #8/#9). +func (c *coreVMCP) aggregatedView(ctx context.Context) (*aggregator.AggregatedCapabilities, error) { + backends := filterHealthyBackends(c.backendRegistry.List(ctx), c.health) + agg, err := c.aggregator.AggregateCapabilities(ctx, backends) + if err != nil { + return nil, fmt.Errorf("capability aggregation failed: %w", err) + } + return agg, nil +} + +// advertisedTools returns the aggregated backend tools followed by the composite +// tools reachable in agg's routing table. +// +// Composite tools must be added here rather than by a Serve decorator: decorators +// may only SUBTRACT reachability ([VMCP] contract), so the core is the only layer +// that can advertise them. +func (c *coreVMCP) advertisedTools(agg *aggregator.AggregatedCapabilities) []vmcp.Tool { + defs := c.accessibleComposites(agg) + if len(defs) == 0 { + return agg.Tools + } + + composite := compositetools.ConvertWorkflowDefsToTools(defs) + out := make([]vmcp.Tool, 0, len(agg.Tools)+len(composite)) + out = append(out, agg.Tools...) + out = append(out, composite...) + return out +} + +// accessibleComposites returns the composite-tool definitions the core advertises +// (and therefore executes) for agg's view: those whose every tool step is reachable +// in the routing table AND whose names do not collide with a backend tool. On a name +// collision ALL composites are dropped so the backend tool wins, matching the legacy +// compositeToolsDecorator (sessionmanager/factory.go:158-168, decorator.go:83-86). +// +// This is the single source of truth shared by advertisedTools (what ListTools shows) +// and CallTool (what executes), so a withheld composite is never executed — advertised +// equals executed. Returns nil when there are no composites or a conflict drops them. +func (c *coreVMCP) accessibleComposites( + agg *aggregator.AggregatedCapabilities, +) map[string]*composer.WorkflowDefinition { + if len(c.workflowDefs) == 0 { + return nil + } + + defs := compositetools.FilterWorkflowDefsForSession(c.workflowDefs, agg.RoutingTable) + if len(defs) == 0 { + return nil + } + if err := compositetools.ValidateNoToolConflicts(agg.Tools, compositetools.ConvertWorkflowDefsToTools(defs)); err != nil { + slog.Warn("composite tool name conflict detected; omitting composite tools", "error", err) + return nil + } + return defs +} + +// validateConfig checks New's required inputs and the elicitation contract, +// keeping New itself within the cyclomatic-complexity budget. Returns a +// vmcp.ErrInvalidConfig-wrapped error on the first violation. +func validateConfig(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("%w: nil core config", vmcp.ErrInvalidConfig) + } + if cfg.Aggregator == nil { + return fmt.Errorf("%w: Aggregator is required", vmcp.ErrInvalidConfig) + } + if cfg.Router == nil { + return fmt.Errorf("%w: Router is required", vmcp.ErrInvalidConfig) + } + if cfg.BackendRegistry == nil { + return fmt.Errorf("%w: BackendRegistry is required", vmcp.ErrInvalidConfig) + } + if cfg.BackendClient == nil { + return fmt.Errorf("%w: BackendClient is required", vmcp.ErrInvalidConfig) + } + // Fail fast on the elicitation contract: a workflow with an elicitation step run + // against a nil Elicitation would nil-deref deep inside the engine at call time + // (the legacy server.New path always wired a non-nil adapter). Reject it at + // construction so the misconfiguration surfaces at startup, not mid-workflow. + if cfg.Elicitation == nil && workflowsRequireElicitation(cfg.WorkflowDefs) { + return fmt.Errorf( + "%w: Elicitation is required when a workflow contains an elicitation step", vmcp.ErrInvalidConfig) + } + return nil +} + +// validateWorkflowDefs validates each workflow definition, returning only the +// valid ones. It fails fast on the first invalid definition. +// +// Relocated from server.New (server.go:1211); the legacy path keeps its own copy +// until server.New is reduced to a wrapper (Phase 3, #5432/#5445). +func validateWorkflowDefs( + validator composer.Composer, + workflowDefs map[string]*composer.WorkflowDefinition, +) (map[string]*composer.WorkflowDefinition, error) { + if len(workflowDefs) == 0 { + // Nil-when-empty, mirroring legacy server.New; all readers guard for it + // (advertisedTools' len check and the c.workflowDefs[name] lookup). + return nil, nil + } + + validDefs := make(map[string]*composer.WorkflowDefinition, len(workflowDefs)) + for name, def := range workflowDefs { + if err := validator.ValidateWorkflow(context.Background(), def); err != nil { + return nil, fmt.Errorf("invalid workflow definition %q: %w", name, err) + } + validDefs[name] = def + } + slog.Info("loaded valid composite tool workflows", "count", len(validDefs)) + return validDefs, nil +} + +// workflowsRequireElicitation reports whether any workflow definition contains an +// elicitation step, directly or as a forEach inner step. New uses it to enforce +// that a non-nil Elicitation is supplied whenever a workflow could request it. +func workflowsRequireElicitation(defs map[string]*composer.WorkflowDefinition) bool { + for _, def := range defs { + for i := range def.Steps { + step := &def.Steps[i] + if step.Type == composer.StepTypeElicitation { + return true + } + if step.Type == composer.StepTypeForEach && step.InnerStep != nil && + step.InnerStep.Type == composer.StepTypeElicitation { + return true + } + } + } + return false +} + +// filterHealthyBackends filters backends to only include those that are healthy +// or degraded. Backends that are unhealthy, unknown, or unauthenticated are +// excluded from capability aggregation to prevent exposing tools from +// unavailable backends. +// +// Health status filtering: +// - healthy: included (fully operational) +// - degraded: included (slow but working) +// - empty/zero-value: included (assume healthy when health monitoring is disabled) +// - unhealthy: excluded (not responding, circuit breaker may be open) +// - unknown: excluded (status not yet determined) +// - unauthenticated: excluded (misconfiguration: backend requires auth but none configured) +// +// When healthStatusProvider is provided, the current health status from the +// health monitor is used (respects circuit breaker state). When nil, falls back +// to the initial health status from the backend registry. +// +// This is an intentional, temporary duplication of discovery.filterHealthyBackends +// (discovery/middleware.go:157, rules at 185-188): the discovery middleware keeps +// its own copy on the legacy server.New path until that path is removed in Phase 3 +// (#5442/#5445). Keep the include/exclude rules identical across both copies. +func filterHealthyBackends(backends []vmcp.Backend, healthStatusProvider health.StatusProvider) []vmcp.Backend { + if len(backends) == 0 { + return backends + } + + healthy := make([]vmcp.Backend, 0, len(backends)) + excluded := 0 + + for i := range backends { + backend := &backends[i] + + // Get current health status from health monitor if available so the + // circuit breaker state is respected during capability aggregation. + var healthStatus vmcp.BackendHealthStatus + if healthStatusProvider != nil { + if status, exists := healthStatusProvider.QueryBackendStatus(backend.ID); exists { + healthStatus = status + } else { + // Backend not tracked by health monitor - use registry status. + healthStatus = backend.HealthStatus + } + } else { + // Health monitoring disabled - use registry status. + healthStatus = backend.HealthStatus + } + + // Include healthy, degraded, and empty/zero-value (assume healthy) backends. + // Explicitly exclude unhealthy, unknown, and unauthenticated backends. + if healthStatus == "" || + healthStatus == vmcp.BackendHealthy || + healthStatus == vmcp.BackendDegraded { + healthy = append(healthy, *backend) + } else { + excluded++ + //nolint:gosec // G706: backend fields are internal, not user-controlled + slog.Debug("excluding backend from capability aggregation due to health status", + "backend_name", backend.Name, + "backend_id", backend.ID, + "health_status", healthStatus, + "source", func() string { + if healthStatusProvider != nil { + return "health_monitor" + } + return "registry" + }()) + } + } + + if excluded > 0 { + //nolint:gosec // G706: values are internal counts, not user-controlled + slog.Debug("filtered backends for capability aggregation", + "total_backends", len(backends), + "healthy_backends", len(healthy), + "excluded_backends", excluded) + } + + return healthy +} diff --git a/pkg/vmcp/core/core_vmcp_test.go b/pkg/vmcp/core/core_vmcp_test.go new file mode 100644 index 0000000000..924143a064 --- /dev/null +++ b/pkg/vmcp/core/core_vmcp_test.go @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + aggmocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" + "github.com/stacklok/toolhive/pkg/vmcp/composer" + vmcpmocks "github.com/stacklok/toolhive/pkg/vmcp/mocks" + routermocks "github.com/stacklok/toolhive/pkg/vmcp/router/mocks" +) + +// fakeStatusProvider is a map-backed health.StatusProvider for tests. A missing +// key reports (zero, false), mirroring a backend untracked by the health monitor. +type fakeStatusProvider map[string]vmcp.BackendHealthStatus + +func (f fakeStatusProvider) QueryBackendStatus(id string) (vmcp.BackendHealthStatus, bool) { + s, ok := f[id] + return s, ok +} + +// coreMocks bundles the gomock collaborators wired into a test Config. +type coreMocks struct { + agg *aggmocks.MockAggregator + reg *vmcpmocks.MockBackendRegistry + client *vmcpmocks.MockBackendClient + router *routermocks.MockRouter +} + +// baseConfig returns a Config with the four required collaborators mocked. +func baseConfig(t *testing.T) (*Config, *coreMocks) { + t.Helper() + ctrl := gomock.NewController(t) + m := &coreMocks{ + agg: aggmocks.NewMockAggregator(ctrl), + reg: vmcpmocks.NewMockBackendRegistry(ctrl), + client: vmcpmocks.NewMockBackendClient(ctrl), + router: routermocks.NewMockRouter(ctrl), + } + cfg := &Config{ + Aggregator: m.agg, + Router: m.router, + BackendRegistry: m.reg, + BackendClient: m.client, + } + return cfg, m +} + +// testBackendID is the single backend ID used across these tests. +const testBackendID = "be1" + +func backendTool(name string) vmcp.Tool { + return vmcp.Tool{Name: name, BackendID: testBackendID} +} + +func backendTarget() *vmcp.BackendTarget { + return &vmcp.BackendTarget{WorkloadID: testBackendID, BaseURL: "http://" + testBackendID + ":8080"} +} + +func TestNew_RequiredCollaborators(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + {"valid", func(*Config) {}, false}, + {"nil aggregator", func(c *Config) { c.Aggregator = nil }, true}, + {"nil router", func(c *Config) { c.Router = nil }, true}, + {"nil backend registry", func(c *Config) { c.BackendRegistry = nil }, true}, + {"nil backend client", func(c *Config) { c.BackendClient = nil }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg, _ := baseConfig(t) + tt.mutate(cfg) + + c, err := New(cfg) + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + assert.Nil(t, c) + return + } + require.NoError(t, err) + require.NotNil(t, c) + require.NoError(t, c.Close()) + }) + } +} + +func TestNew_NilConfig(t *testing.T) { + t.Parallel() + c, err := New(nil) + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + assert.Nil(t, c) +} + +func TestNew_ValidatesWorkflows(t *testing.T) { + t.Parallel() + + valid := &composer.WorkflowDefinition{ + Name: "wf", + Steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.tool"}}, + } + circular := &composer.WorkflowDefinition{ + Name: "wf", + Steps: []composer.WorkflowStep{ + {ID: "s1", Type: composer.StepTypeTool, Tool: "be1.tool", DependsOn: []string{"s2"}}, + {ID: "s2", Type: composer.StepTypeTool, Tool: "be1.tool", DependsOn: []string{"s1"}}, + }, + } + + tests := []struct { + name string + defs map[string]*composer.WorkflowDefinition + wantErr bool + }{ + {"valid workflow", map[string]*composer.WorkflowDefinition{"wf": valid}, false}, + {"invalid workflow fails fast", map[string]*composer.WorkflowDefinition{"wf": circular}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg, _ := baseConfig(t) + cfg.WorkflowDefs = tt.defs + + c, err := New(cfg) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, c) + return + } + require.NoError(t, err) + require.NoError(t, c.Close()) + }) + } +} + +func TestNew_ElicitationRequiredWhenWorkflowElicits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + steps []composer.WorkflowStep + wantErr bool + }{ + { + name: "elicitation step requires a requester", + steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeElicitation}}, + wantErr: true, + }, + { + name: "forEach inner elicitation step requires a requester", + steps: []composer.WorkflowStep{{ + ID: "s1", + Type: composer.StepTypeForEach, + InnerStep: &composer.WorkflowStep{ID: "inner", Type: composer.StepTypeElicitation}, + }}, + wantErr: true, + }, + { + name: "tool-only workflow needs no requester", + steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.tool"}}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg, _ := baseConfig(t) + cfg.Elicitation = nil // explicit: no requester wired + cfg.WorkflowDefs = map[string]*composer.WorkflowDefinition{ + "wf": {Name: "wf", Steps: tt.steps}, + } + + c, err := New(cfg) + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + assert.Nil(t, c) + return + } + require.NoError(t, err) + require.NoError(t, c.Close()) + }) + } +} + +func TestListTools_AggregatesOnDemand(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + backends := []vmcp.Backend{{ID: "be1", HealthStatus: vmcp.BackendHealthy}} + m.reg.EXPECT().List(gomock.Any()).Return(backends) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), backends).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("tool_a"), backendTool("tool_b")}, + RoutingTable: &vmcp.RoutingTable{}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + tools, err := c.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, tools, 2) + assert.Equal(t, "be1", tools[0].BackendID) +} + +func TestListTools_AdvertisesAccessibleCompositeTools(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + cfg.WorkflowDefs = map[string]*composer.WorkflowDefinition{ + "wf": {Name: "wf", Steps: []composer.WorkflowStep{{ID: "s1", Type: composer.StepTypeTool, Tool: "be1.tool_a"}}}, + } + + rt := &vmcp.RoutingTable{Tools: map[string]*vmcp.BackendTarget{"be1.tool_a": backendTarget()}} + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "be1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("be1.tool_a")}, + RoutingTable: rt, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + tools, err := c.ListTools(context.Background(), nil) + require.NoError(t, err) + + names := make([]string, 0, len(tools)) + for _, tl := range tools { + names = append(names, tl.Name) + } + assert.Contains(t, names, "be1.tool_a") + assert.Contains(t, names, "wf", "accessible composite tool should be advertised") +} + +func TestListResourcesAndPrompts(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return(nil).Times(2) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Resources: []vmcp.Resource{{URI: "file://a", BackendID: "be1"}}, + Prompts: []vmcp.Prompt{{Name: "p1", BackendID: "be1"}}, + }, nil).Times(2) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + resources, err := c.ListResources(context.Background(), nil) + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, "file://a", resources[0].URI) + + prompts, err := c.ListPrompts(context.Background(), nil) + require.NoError(t, err) + require.Len(t, prompts, 1) + assert.Equal(t, "p1", prompts[0].Name) +} + +func TestListTools_AggregationError(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return(nil) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.ListTools(context.Background(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "aggregation failed") +} + +func TestListTools_HealthFiltersBeforeAggregating(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + backends := []vmcp.Backend{ + {ID: "healthy", HealthStatus: vmcp.BackendHealthy}, + {ID: "degraded", HealthStatus: vmcp.BackendDegraded}, + {ID: "unhealthy", HealthStatus: vmcp.BackendHealthy}, // overridden by provider below + {ID: "unknown", HealthStatus: vmcp.BackendHealthy}, // overridden by provider below + } + cfg.HealthStatusProvider = fakeStatusProvider{ + "unhealthy": vmcp.BackendUnhealthy, + "unknown": vmcp.BackendUnknown, + } + + m.reg.EXPECT().List(gomock.Any()).Return(backends) + var got []vmcp.Backend + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, b []vmcp.Backend) (*aggregator.AggregatedCapabilities, error) { + got = b + return &aggregator.AggregatedCapabilities{}, nil + }) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + _, err = c.ListTools(context.Background(), nil) + require.NoError(t, err) + + gotIDs := make([]string, 0, len(got)) + for _, b := range got { + gotIDs = append(gotIDs, b.ID) + } + assert.ElementsMatch(t, []string{"healthy", "degraded"}, gotIDs, + "only healthy/degraded backends should reach the aggregator") +} + +func TestFilterHealthyBackends(t *testing.T) { + t.Parallel() + + backends := []vmcp.Backend{ + {ID: "healthy", HealthStatus: vmcp.BackendHealthy}, + {ID: "degraded", HealthStatus: vmcp.BackendDegraded}, + {ID: "empty", HealthStatus: ""}, + {ID: "unhealthy", HealthStatus: vmcp.BackendUnhealthy}, + {ID: "unknown", HealthStatus: vmcp.BackendUnknown}, + {ID: "unauthenticated", HealthStatus: vmcp.BackendUnauthenticated}, + } + + tests := []struct { + name string + provider fakeStatusProvider + wantIDs []string + }{ + { + name: "nil provider uses registry status", + wantIDs: []string{"healthy", "degraded", "empty"}, + }, + { + name: "provider overrides registry status", + provider: fakeStatusProvider{"healthy": vmcp.BackendUnhealthy, "unhealthy": vmcp.BackendHealthy}, + // "healthy" demoted, "unhealthy" promoted; untracked backends fall back to registry. + wantIDs: []string{"degraded", "empty", "unhealthy"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var provider interface { + QueryBackendStatus(string) (vmcp.BackendHealthStatus, bool) + } + if tt.provider != nil { + provider = tt.provider + } + got := filterHealthyBackends(backends, provider) + ids := make([]string, 0, len(got)) + for _, b := range got { + ids = append(ids, b.ID) + } + assert.ElementsMatch(t, tt.wantIDs, ids) + }) + } +} + +func TestFilterHealthyBackends_Empty(t *testing.T) { + t.Parallel() + assert.Empty(t, filterHealthyBackends(nil, nil)) +} + +func TestLookup(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return(nil).Times(6) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{backendTool("tool_a")}, + Resources: []vmcp.Resource{{URI: "file://a", BackendID: "be1"}}, + Prompts: []vmcp.Prompt{{Name: "p1", BackendID: "be1"}}, + }, nil).Times(6) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + ctx := context.Background() + + tool, err := c.LookupTool(ctx, nil, "tool_a") + require.NoError(t, err) + assert.Equal(t, "be1", tool.BackendID) + _, err = c.LookupTool(ctx, nil, "missing") + assert.ErrorIs(t, err, vmcp.ErrNotFound) + + res, err := c.LookupResource(ctx, nil, "file://a") + require.NoError(t, err) + assert.Equal(t, "be1", res.BackendID) + _, err = c.LookupResource(ctx, nil, "file://missing") + assert.ErrorIs(t, err, vmcp.ErrNotFound) + + prompt, err := c.LookupPrompt(ctx, nil, "p1") + require.NoError(t, err) + assert.Equal(t, "be1", prompt.BackendID) + _, err = c.LookupPrompt(ctx, nil, "missing") + assert.ErrorIs(t, err, vmcp.ErrNotFound) +} + +func TestClose_Idempotent(t *testing.T) { + t.Parallel() + cfg, _ := baseConfig(t) + + c, err := New(cfg) + require.NoError(t, err) + + require.NoError(t, c.Close()) + assert.NotPanics(t, func() { + require.NoError(t, c.Close()) + require.NoError(t, c.Close()) + }) +} + +// Must run serially: it swaps the global slog default to capture output into a +// non-thread-safe buffer, which is unsafe if other tests log concurrently. +// +//nolint:paralleltest // installs a global slog default + non-thread-safe buffer; must not run in parallel +func TestIdentityNotLogged(t *testing.T) { + cfg, m := baseConfig(t) + + const secret = "super-secret-token-value" + + // An excluded backend makes filterHealthyBackends emit a DEBUG log on every + // aggregatedView, guaranteeing the buffer is non-empty so the assertion below + // is not vacuous (it would otherwise pass if every path short-circuited). + cfg.HealthStatusProvider = fakeStatusProvider{"bad": vmcp.BackendUnhealthy} + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{ + {ID: "ok", HealthStatus: vmcp.BackendHealthy}, + {ID: "bad", HealthStatus: vmcp.BackendHealthy}, + }).AnyTimes() + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{ + RoutingTable: &vmcp.RoutingTable{}, + }, nil).AnyTimes() + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + id := &auth.Identity{Token: secret, UpstreamTokens: map[string]string{"github": secret}} + ctx := context.Background() + + // Drive every identity-taking method; return values are irrelevant — we only + // inspect the captured logs. + _, _ = c.ListTools(ctx, id) + _, _ = c.ListResources(ctx, id) + _, _ = c.ListPrompts(ctx, id) + _, _ = c.LookupTool(ctx, id, "missing") + _, _ = c.LookupResource(ctx, id, "missing") + _, _ = c.LookupPrompt(ctx, id, "missing") + _, _ = c.CallTool(ctx, id, "missing", nil, nil) + _, _ = c.ReadResource(ctx, id, "missing") + _, _ = c.GetPrompt(ctx, id, "missing", nil) + + logs := buf.String() + require.NotEmpty(t, logs, "expected the driven methods to emit logs, else the assertion is vacuous") + assert.NotContains(t, logs, secret, "identity token must never be logged") +} diff --git a/pkg/vmcp/discovery/middleware.go b/pkg/vmcp/discovery/middleware.go index 62a0325804..c1bd2ccad0 100644 --- a/pkg/vmcp/discovery/middleware.go +++ b/pkg/vmcp/discovery/middleware.go @@ -146,9 +146,13 @@ func Middleware( // or degraded. Backends that are unhealthy, unknown, or unauthenticated are excluded // from capability aggregation to prevent exposing tools from unavailable backends. // -// TODO(#4920): Unauthenticated backends are treated as routable for phase determination -// but are excluded here because discovery probes cannot carry user tokens. If health -// probes could authenticate, these backends would be fully healthy and included here. +// A note on BackendUnauthenticated: a 401/403 from a backend that has an outgoing +// auth strategy configured is treated as BackendHealthy by the health checker +// (health probes deliberately do not carry user credentials — the challenge proves +// reachability). BackendUnauthenticated therefore indicates a misconfiguration: +// the backend requires authentication but no outgoing auth strategy is configured +// on the backend target. Excluding such backends from capability aggregation is +// the correct behavior — their capabilities cannot be safely exposed. // // Health status filtering: // - healthy: included (fully operational) @@ -156,7 +160,7 @@ func Middleware( // - empty/zero-value: included (assume healthy when health monitoring is disabled) // - unhealthy: excluded (not responding, circuit breaker may be open) // - unknown: excluded (status not yet determined) -// - unauthenticated: excluded (authentication failed) +// - unauthenticated: excluded (misconfiguration: backend requires auth but none configured) // // When healthStatusProvider is provided, the current health status from the health // monitor is used (respects circuit breaker state). When nil, falls back to the diff --git a/pkg/vmcp/elicitation.go b/pkg/vmcp/elicitation.go new file mode 100644 index 0000000000..c79b2ed80b --- /dev/null +++ b/pkg/vmcp/elicitation.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmcp + +import "context" + +//go:generate mockgen -destination=mocks/mock_elicitation_requester.go -package=mocks -source=elicitation.go ElicitationRequester + +// ElicitationRequester sends an elicitation request to the client and blocks +// for the response. +// +// This is the domain seam for MCP elicitation: callers (e.g. the composer's +// elicitation handler) depend only on this interface and the domain +// ElicitationRequest/ElicitationResult value types, never on the underlying +// SDK. The transport adapter (sdkElicitationAdapter in pkg/vmcp/server) is the +// sole point that translates to/from mcp-go types, keeping SDK coupling +// confined to the adapter boundary. +// +// The underlying SDK handles JSON-RPC ID correlation internally, so +// implementations do not need to track request IDs. +type ElicitationRequester interface { + // RequestElicitation sends an elicitation request and blocks until the + // client responds or the context is cancelled. + RequestElicitation(ctx context.Context, req ElicitationRequest) (*ElicitationResult, error) +} + +// ElicitationRequest is the domain-typed elicitation request. +// +// It mirrors, one-to-one, the mcp-go fields the composer constructs today +// (form-mode only). URL-mode fields (ElicitationID/URL/Mode) are deliberately +// omitted because the composer never constructs them. The adapter in +// pkg/vmcp/server is responsible for translating this to the SDK request type. +type ElicitationRequest struct { + // Message is the human-readable prompt shown to the user. + Message string + + // RequestedSchema is the JSON Schema describing the expected response + // content. It is passed through to the SDK unchanged. + RequestedSchema any + + // Meta carries optional protocol metadata. The composer sets none today. + Meta map[string]any +} + +// ElicitationResult is the domain-typed elicitation response. +// +// Content stays typed as any so consumers can perform their own assertion to +// map[string]any (matching the SDK's wire shape) without the domain seam +// pinning a concrete type. +type ElicitationResult struct { + // Action is what the user did: "accept", "decline", or "cancel". + Action string + + // Content contains the user-provided data (for the accept action). + Content any +} diff --git a/pkg/vmcp/errors.go b/pkg/vmcp/errors.go index f2b8e58868..1458f3a5ce 100644 --- a/pkg/vmcp/errors.go +++ b/pkg/vmcp/errors.go @@ -119,6 +119,14 @@ func IsAuthenticationError(err error) bool { return true } + // mcp-go ErrAuthorizationRequired ("authorization required") and + // ErrOAuthAuthorizationRequired ("no valid token available, authorization + // required") string forms. Used as a fallback when the typed sentinel is + // not preserved through the error chain (issue #5223). + if strings.Contains(errLower, "authorization required") { + return true + } + return false } diff --git a/pkg/vmcp/headerforward/transport.go b/pkg/vmcp/headerforward/transport.go new file mode 100644 index 0000000000..e235fcb17a --- /dev/null +++ b/pkg/vmcp/headerforward/transport.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package headerforward provides the HTTP round-tripper that injects +// per-backend forwarded headers (plaintext + secret-resolved) onto outbound +// requests. It is consumed by both the capability-discovery HTTP client +// (pkg/vmcp/client) and the per-session HTTP client +// (pkg/vmcp/session/internal/backend). +package headerforward + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/transport/middleware" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// headerForwardRoundTripper injects a pre-resolved set of HTTP headers onto every +// outbound request to a specific backend. Headers are resolved once at client +// creation time (plaintext verbatim + secret identifiers looked up via +// secrets.EnvironmentProvider) and stored in an immutable map. The request is +// cloned before mutation so callers retain an untouched request. +// +// This tripper applies to health checks, listTools/listResources/listPrompts, and +// tool/resource/prompt invocations — every HTTP call made by the vMCP backend +// client passes through the same transport chain. +type headerForwardRoundTripper struct { + base http.RoundTripper + headers http.Header // canonicalized header name → single value +} + +// RoundTrip injects the pre-resolved headers onto a clone of the request and +// delegates to the wrapped transport. Names already present on the inbound +// request are skipped — this preserves any header the *caller* set on the +// request before invoking the round-tripper chain. Inner (closer to the wire) +// transports run AFTER this one and use Set() unconditionally, so on any +// overlapping name those stages still win on the wire. Restricted names are +// blocked at resolve time, so user-supplied config cannot reach this point +// for Host, hop-by-hop, or X-Forwarded-* anyway. +func (h *headerForwardRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if len(h.headers) == 0 { + return h.base.RoundTrip(req) + } + reqCopy := req.Clone(req.Context()) + if reqCopy.Header == nil { + reqCopy.Header = make(http.Header) + } + for name, values := range h.headers { + if len(values) == 0 { + continue + } + // Do not clobber headers already set by earlier (outer) tripper stages. + // The vMCP runtime is authoritative for identity/trace/auth headers. + if reqCopy.Header.Get(name) != "" { + continue + } + reqCopy.Header.Set(name, values[0]) + } + return h.base.RoundTrip(reqCopy) +} + +// BuildHeaderForwardTripper constructs a headerForwardRoundTripper for the +// backend's pre-resolved HeaderForwardConfig. Returns base unchanged when no +// header injection is configured or the effective header set is empty. +// +// Used by both the vMCP backend client (startup capability discovery) and the +// per-session backend connector (long-lived MCP traffic). Exported so the +// session backend in pkg/vmcp/session/internal/backend can share the same +// transport-chain wiring. +// +// Fails loudly (constructor validation, per go-style.md) when a secret identifier +// cannot be resolved through the provider, so a misconfigured backend surfaces +// at pod startup — not as a silent missing-header on every request. +// +// Restricted header names (matching pkg/transport/middleware.RestrictedHeaders) +// are rejected to prevent Host, Content-Length, Authorization, hop-by-hop, and +// X-Forwarded-* spoofing via user-supplied config. +func BuildHeaderForwardTripper( + ctx context.Context, + base http.RoundTripper, + cfg *vmcp.HeaderForwardConfig, + provider secrets.Provider, + backendName string, +) (http.RoundTripper, error) { + if cfg == nil { + return base, nil + } + + headers, err := resolveHeaderForward(ctx, cfg, provider, backendName) + if err != nil { + return nil, err + } + if len(headers) == 0 { + return base, nil + } + return &headerForwardRoundTripper{base: base, headers: headers}, nil +} + +// resolveHeaderForward merges plaintext headers with secret-resolved headers into +// a single canonicalized http.Header. Plaintext entries are copied verbatim; +// secret identifiers are looked up via the provider (TOOLHIVE_SECRET_). +// +// Returns an error if any header name is in middleware.RestrictedHeaders or if +// any referenced secret identifier is not present in the environment. +func resolveHeaderForward( + ctx context.Context, + cfg *vmcp.HeaderForwardConfig, + provider secrets.Provider, + backendName string, +) (http.Header, error) { + if cfg == nil { + return nil, nil + } + + size := len(cfg.AddPlaintextHeaders) + len(cfg.AddHeadersFromSecret) + if size == 0 { + return nil, nil + } + out := make(http.Header, size) + + for name, value := range cfg.AddPlaintextHeaders { + canonical := http.CanonicalHeaderKey(name) + if middleware.RestrictedHeaders[canonical] { + return nil, fmt.Errorf( + "backend %q: header %q is restricted and cannot be forwarded", + backendName, canonical, + ) + } + out.Set(canonical, value) + } + + if provider == nil && len(cfg.AddHeadersFromSecret) > 0 { + return nil, fmt.Errorf( + "backend %q: secret provider required to resolve %d header secret(s)", + backendName, len(cfg.AddHeadersFromSecret), + ) + } + + for name, identifier := range cfg.AddHeadersFromSecret { + canonical := http.CanonicalHeaderKey(name) + if middleware.RestrictedHeaders[canonical] { + return nil, fmt.Errorf( + "backend %q: header %q is restricted and cannot be forwarded", + backendName, canonical, + ) + } + value, err := provider.GetSecret(ctx, identifier) + if err != nil { + // Do not include the identifier or value in any user-facing error; + // the log is the only place we record the identifier, not the value. + slog.Error("failed to resolve header-forward secret", + "backend", backendName, "header", canonical, "identifier", identifier, + "error", err) + if errors.Is(err, secrets.ErrSecretNotFound) { + return nil, fmt.Errorf( + "backend %q: header %q secret not found in pod environment", + backendName, canonical, + ) + } + return nil, fmt.Errorf( + "backend %q: failed to resolve header %q from secret provider: %w", + backendName, canonical, err, + ) + } + out.Set(canonical, value) + } + + return out, nil +} diff --git a/pkg/vmcp/headerforward/transport_test.go b/pkg/vmcp/headerforward/transport_test.go new file mode 100644 index 0000000000..d1d19042d8 --- /dev/null +++ b/pkg/vmcp/headerforward/transport_test.go @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package headerforward + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/transport/middleware" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// mockProvider is an in-memory stand-in for secrets.Provider so tests don't rely +// on process env. It only implements GetSecret; other methods return errors. +// +// errors maps an identifier to a per-key error to inject — used to verify that +// resolveHeaderForward propagates non-NotFound errors (transient backend +// failures, permission errors, etc.) without translating them to NotFound. +type mockProvider struct { + values map[string]string + errors map[string]error +} + +func (m *mockProvider) GetSecret(_ context.Context, name string) (string, error) { + if m.errors != nil { + if err, ok := m.errors[name]; ok { + return "", err + } + } + if v, ok := m.values[name]; ok { + return v, nil + } + return "", secrets.ErrSecretNotFound +} + +// mockProvider needs to satisfy secrets.Provider; the other methods are unused. +func (*mockProvider) SetSecret(_ context.Context, _, _ string) error { return nil } +func (*mockProvider) DeleteSecret(_ context.Context, _ string) error { return nil } +func (*mockProvider) ListSecrets(_ context.Context) ([]secrets.SecretDescription, error) { + return nil, nil +} +func (*mockProvider) DeleteSecrets(_ context.Context, _ []string) error { return nil } +func (*mockProvider) Cleanup() error { return nil } +func (*mockProvider) Capabilities() secrets.ProviderCapabilities { + return secrets.ProviderCapabilities{CanRead: true} +} + +// captureTripper records the headers seen on the last request that reached it. +type captureTripper struct { + lastHeaders http.Header +} + +func (c *captureTripper) RoundTrip(req *http.Request) (*http.Response, error) { + c.lastHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil +} + +func TestHeaderForwardRoundTripper_InjectsPlaintext(t *testing.T) { + t.Parallel() + + base := &captureTripper{} + rt := &headerForwardRoundTripper{ + base: base, + headers: http.Header{ + "X-Mcp-Toolsets": []string{"projects,issues"}, + "X-Tenant": []string{"acme"}, + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "projects,issues", base.lastHeaders.Get("X-Mcp-Toolsets")) + assert.Equal(t, "acme", base.lastHeaders.Get("X-Tenant")) + // Original request must remain unmutated. + assert.Empty(t, req.Header.Get("X-Mcp-Toolsets")) +} + +func TestHeaderForwardRoundTripper_NoHeadersIsNoOp(t *testing.T) { + t.Parallel() + + base := &captureTripper{} + rt := &headerForwardRoundTripper{base: base, headers: nil} + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + assert.Empty(t, base.lastHeaders.Get("X-Mcp-Toolsets")) +} + +func TestHeaderForwardRoundTripper_DoesNotClobberExistingHeader(t *testing.T) { + t.Parallel() + + base := &captureTripper{} + rt := &headerForwardRoundTripper{ + base: base, + headers: http.Header{ + "Authorization": []string{"Bearer user-provided"}, + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + require.NoError(t, err) + // Pre-set the header as an outer (identity/trace/auth) tripper would. + req.Header.Set("Authorization", "Bearer real-auth") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "Bearer real-auth", base.lastHeaders.Get("Authorization"), + "user-supplied header-forward value must not clobber an already-set header") +} + +func TestResolveHeaderForward_PlaintextAndSecretMerged(t *testing.T) { + t.Parallel() + + cfg := &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + "X-Tenant": "acme", + }, + AddHeadersFromSecret: map[string]string{ + "X-API-Key": "HEADER_FORWARD_X_API_KEY_BACKEND_A", + }, + } + provider := &mockProvider{values: map[string]string{ + "HEADER_FORWARD_X_API_KEY_BACKEND_A": "resolved-from-env", + }} + + hdr, err := resolveHeaderForward(t.Context(), cfg, provider, "backend-a") + require.NoError(t, err) + assert.Equal(t, "acme", hdr.Get("X-Tenant")) + assert.Equal(t, "resolved-from-env", hdr.Get("X-Api-Key")) +} + +func TestResolveHeaderForward_SecretNotFoundReturnsError(t *testing.T) { + t.Parallel() + + cfg := &vmcp.HeaderForwardConfig{ + AddHeadersFromSecret: map[string]string{ + "X-API-Key": "HEADER_FORWARD_X_API_KEY_BACKEND_A", + }, + } + provider := &mockProvider{values: map[string]string{}} + + hdr, err := resolveHeaderForward(t.Context(), cfg, provider, "backend-a") + require.Error(t, err) + assert.Nil(t, hdr) + // Error must not leak the identifier or the backend's private values. + assert.NotContains(t, err.Error(), "HEADER_FORWARD_X_API_KEY_BACKEND_A") +} + +// TestResolveHeaderForward_RestrictedHeaderRejected iterates the full +// middleware.RestrictedHeaders set and verifies every entry is rejected via +// both the plaintext and secret-backed paths. Hardcoding a subset would let a +// future addition to RestrictedHeaders silently fall out of coverage. +func TestResolveHeaderForward_RestrictedHeaderRejected(t *testing.T) { + t.Parallel() + + require.NotEmpty(t, middleware.RestrictedHeaders, "guard against empty map") + + for header := range middleware.RestrictedHeaders { + t.Run("plaintext_"+header, func(t *testing.T) { + t.Parallel() + cfg := &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{header: "x"}, + } + _, err := resolveHeaderForward(t.Context(), cfg, &mockProvider{}, "backend-x") + require.Error(t, err) + assert.Contains(t, err.Error(), "restricted") + }) + + t.Run("secret_"+header, func(t *testing.T) { + t.Parallel() + cfg := &vmcp.HeaderForwardConfig{ + AddHeadersFromSecret: map[string]string{header: "HEADER_FORWARD_RESTRICTED"}, + } + _, err := resolveHeaderForward(t.Context(), cfg, &mockProvider{}, "backend-x") + require.Error(t, err) + assert.Contains(t, err.Error(), "restricted") + }) + } +} + +// TestResolveHeaderForward_NonNotFoundProviderError verifies that a transient +// secret-provider failure (e.g. permission denied, timeout) is propagated with +// its original chain intact — distinct from the user-facing "not found" +// message produced for ErrSecretNotFound. +func TestResolveHeaderForward_NonNotFoundProviderError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("permission denied calling secrets backend") + cfg := &vmcp.HeaderForwardConfig{ + AddHeadersFromSecret: map[string]string{ + "X-API-Key": "HEADER_FORWARD_X_API_KEY_BACKEND_A", + }, + } + provider := &mockProvider{ + errors: map[string]error{ + "HEADER_FORWARD_X_API_KEY_BACKEND_A": wantErr, + }, + } + + hdr, err := resolveHeaderForward(t.Context(), cfg, provider, "backend-a") + require.Error(t, err) + assert.Nil(t, hdr) + require.ErrorIs(t, err, wantErr, + "non-NotFound provider errors must be wrapped with %%w so callers can errors.Is them") + assert.NotContains(t, err.Error(), "not found", + "only ErrSecretNotFound should produce the user-facing 'not found' message") +} + +func TestResolveHeaderForward_NilCfgReturnsNil(t *testing.T) { + t.Parallel() + hdr, err := resolveHeaderForward(t.Context(), nil, nil, "x") + require.NoError(t, err) + assert.Nil(t, hdr) +} + +func TestBuildHeaderForwardTripper_NilCfgReturnsBase(t *testing.T) { + t.Parallel() + base := &captureTripper{} + got, err := BuildHeaderForwardTripper(t.Context(), base, nil, nil, "x") + require.NoError(t, err) + assert.Same(t, base, got, "nil cfg must pass base through untouched") +} diff --git a/pkg/vmcp/headerforward/wirefmt/wirefmt.go b/pkg/vmcp/headerforward/wirefmt/wirefmt.go new file mode 100644 index 0000000000..3d5a3bbf2e --- /dev/null +++ b/pkg/vmcp/headerforward/wirefmt/wirefmt.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package wirefmt is the shared wire-format contract for headerForward +// data shipped from the operator to the vMCP runtime via pod env vars. +// +// Both the operator (producer) and the vMCP runtime (consumer) import this +// package: the operator emits TOOLHIVE_HEADER_FORWARD_ +// env vars and the runtime parses them at startup. Living in pkg/ rather +// than under cmd/thv-operator preserves one-way Go layering — the +// operator binary depends on pkg/ helpers, never the reverse. +package wirefmt + +import ( + "fmt" + "regexp" + "strings" +) + +// ManifestEnvVarPrefix is the prefix the operator uses when emitting one +// JSON-encoded manifest env var per backend on a workload pod for +// MCPServerEntry / MCPRemoteProxy headerForward configuration. +// +// The full env var name is "TOOLHIVE_HEADER_FORWARD_"; +// the value is JSON-encoded vmcp.HeaderForwardConfig with original header +// names preserved (uppercased/sanitized normalization can't recover hyphens +// or original casing on the receive side, so we carry the literal names in +// the JSON value instead). Plaintext header values appear inline; secret- +// backed entries carry only the secret identifier — the actual Secret +// value rides a sibling TOOLHIVE_SECRET_HEADER_FORWARD__ env var +// emitted via valueFrom.secretKeyRef and resolved at request time by +// secrets.EnvironmentProvider. +const ManifestEnvVarPrefix = "TOOLHIVE_HEADER_FORWARD_" + +// envVarSanitizer matches any character outside the env-var-safe set +// [A-Z0-9_]. Used by NormalizeForEnvVar to collapse non-conforming chars. +var envVarSanitizer = regexp.MustCompile(`[^A-Z0-9_]`) + +// NormalizeForEnvVar applies the canonical sanitization used in every +// header-forward env-var name: uppercase, hyphens to underscores, anything +// outside [A-Z0-9_] also to underscore. The secret-ref and the manifest +// emitters MUST share this function so a header that round-trips through +// one branch never collides with the other. +// +// Collision domain: this normalization is one-way and lossy. Two distinct +// header names that differ only in non-[A-Z0-9_-] characters will collapse +// to the same normalized form (e.g. "X-A.B" and "X-A_B" both become +// "X_A_B"). For backend (owner) names this is mitigated by Kubernetes +// DNS-1123 subdomain validation, which forbids underscores in resource +// names — two K8s resources cannot present a colliding pair. Header names +// don't have that guard, so callers iterating env vars should log when +// they see the same normalized owner key twice; readers that decode a +// single-backend manifest are not affected because the JSON value carries +// the literal user-authored header names verbatim. +func NormalizeForEnvVar(s string) string { + upper := strings.ToUpper(strings.ReplaceAll(s, "-", "_")) + return envVarSanitizer.ReplaceAllString(upper, "_") +} + +// SecretEnvVarName generates the environment variable name for a header +// forward secret. The generated name follows the TOOLHIVE_SECRET_ +// pattern expected by secrets.EnvironmentProvider. +// +// Parameters: +// - ownerName: the resource owning the header forwarding configuration +// (an MCPRemoteProxy or an MCPServerEntry). +// - headerName: the HTTP header name (e.g. "X-API-Key"). +// +// Returns the full env var name (e.g. "TOOLHIVE_SECRET_HEADER_FORWARD_X_API_KEY_MY_OWNER") +// and the secret identifier (the env var name with the leading +// "TOOLHIVE_SECRET_" stripped, used by secrets.EnvironmentProvider lookups). +func SecretEnvVarName(ownerName, headerName string) (envVarName, secretIdentifier string) { + sanitizedHeader := NormalizeForEnvVar(headerName) + sanitizedOwner := NormalizeForEnvVar(ownerName) + secretIdentifier = fmt.Sprintf("HEADER_FORWARD_%s_%s", sanitizedHeader, sanitizedOwner) + envVarName = fmt.Sprintf("TOOLHIVE_SECRET_%s", secretIdentifier) + return envVarName, secretIdentifier +} + +// ManifestEnvVarName generates the environment variable name for the +// JSON-encoded headerForward manifest emitted per backend on a workload +// pod. One env var per backend; the JSON value carries every configured +// header (plaintext values inline, secret entries by identifier). +// +// Returns the full env var name (e.g. "TOOLHIVE_HEADER_FORWARD_MY_OWNER") +// and the normalized owner segment ("MY_OWNER") used when iterating env +// vars matching ManifestEnvVarPrefix. +func ManifestEnvVarName(ownerName string) (envVarName, normalizedOwner string) { + normalizedOwner = NormalizeForEnvVar(ownerName) + envVarName = ManifestEnvVarPrefix + normalizedOwner + return envVarName, normalizedOwner +} diff --git a/pkg/vmcp/headerforward/wirefmt/wirefmt_test.go b/pkg/vmcp/headerforward/wirefmt/wirefmt_test.go new file mode 100644 index 0000000000..54e095a354 --- /dev/null +++ b/pkg/vmcp/headerforward/wirefmt/wirefmt_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wirefmt + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +// envVarPattern is the character set every env var name produced by this +// package must conform to. The runtime's secrets.EnvironmentProvider lookups +// expect [A-Z0-9_], so every name we emit MUST round-trip cleanly. +var envVarPattern = regexp.MustCompile(`^[A-Z0-9_]+$`) + +func TestNormalizeForEnvVar(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"hyphens become underscores", "my-proxy", "MY_PROXY"}, + {"already uppercase preserved", "FOO_BAR", "FOO_BAR"}, + {"non-alnum collapses to underscore", "proxy.name@123", "PROXY_NAME_123"}, + {"mixed case lowercased to upper", "GitHub-Copilot", "GITHUB_COPILOT"}, + {"empty stays empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := NormalizeForEnvVar(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSecretEnvVarName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ownerName string + headerName string + expectedEnvVarName string + expectedSecretIdentifier string + }{ + { + name: "simple names", + ownerName: "my-proxy", + headerName: "X-API-Key", + expectedEnvVarName: "TOOLHIVE_SECRET_HEADER_FORWARD_X_API_KEY_MY_PROXY", + expectedSecretIdentifier: "HEADER_FORWARD_X_API_KEY_MY_PROXY", + }, + { + name: "lowercase header upcases", + ownerName: "test-proxy", + headerName: "authorization", + expectedEnvVarName: "TOOLHIVE_SECRET_HEADER_FORWARD_AUTHORIZATION_TEST_PROXY", + expectedSecretIdentifier: "HEADER_FORWARD_AUTHORIZATION_TEST_PROXY", + }, + { + name: "multiple hyphens", + ownerName: "my-remote-proxy", + headerName: "X-Custom-Header", + expectedEnvVarName: "TOOLHIVE_SECRET_HEADER_FORWARD_X_CUSTOM_HEADER_MY_REMOTE_PROXY", + expectedSecretIdentifier: "HEADER_FORWARD_X_CUSTOM_HEADER_MY_REMOTE_PROXY", + }, + { + name: "special characters in owner name", + ownerName: "proxy.name@123", + headerName: "X-Token", + expectedEnvVarName: "TOOLHIVE_SECRET_HEADER_FORWARD_X_TOKEN_PROXY_NAME_123", + expectedSecretIdentifier: "HEADER_FORWARD_X_TOKEN_PROXY_NAME_123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + envVarName, secretIdentifier := SecretEnvVarName(tt.ownerName, tt.headerName) + + assert.Equal(t, tt.expectedEnvVarName, envVarName) + assert.Equal(t, tt.expectedSecretIdentifier, secretIdentifier) + + assert.Regexp(t, envVarPattern, envVarName) + assert.Regexp(t, envVarPattern, secretIdentifier) + assert.Equal(t, "TOOLHIVE_SECRET_"+secretIdentifier, envVarName, + "envVarName must equal TOOLHIVE_SECRET_ + secretIdentifier") + }) + } +} + +func TestManifestEnvVarName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ownerName string + expectedEnvVarName string + expectedNormalizedOwn string + }{ + { + name: "hyphen owner", + ownerName: "github-copilot", + expectedEnvVarName: "TOOLHIVE_HEADER_FORWARD_GITHUB_COPILOT", + expectedNormalizedOwn: "GITHUB_COPILOT", + }, + { + name: "already uppercase", + ownerName: "STRIPE", + expectedEnvVarName: "TOOLHIVE_HEADER_FORWARD_STRIPE", + expectedNormalizedOwn: "STRIPE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + envVarName, normalizedOwner := ManifestEnvVarName(tt.ownerName) + assert.Equal(t, tt.expectedEnvVarName, envVarName) + assert.Equal(t, tt.expectedNormalizedOwn, normalizedOwner) + assert.Regexp(t, envVarPattern, envVarName) + assert.Equal(t, ManifestEnvVarPrefix+normalizedOwner, envVarName, + "envVarName must equal ManifestEnvVarPrefix + normalizedOwner") + }) + } +} diff --git a/pkg/vmcp/health/checker.go b/pkg/vmcp/health/checker.go index 2bd37b4fdd..3505babca7 100644 --- a/pkg/vmcp/health/checker.go +++ b/pkg/vmcp/health/checker.go @@ -15,6 +15,7 @@ import ( "time" "github.com/stacklok/toolhive/pkg/vmcp" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) const ( @@ -77,7 +78,14 @@ func NewHealthChecker( // Health determination logic: // - Success with fast response: Backend is healthy (BackendHealthy) // - Success with slow response (> degradedThreshold): Backend is degraded (BackendDegraded) -// - Authentication error: Backend is unauthenticated (BackendUnauthenticated) +// - Authentication error (HTTP 401/403) AND backend has an outgoing auth strategy +// configured: Backend is healthy (BackendHealthy). Health probes deliberately do +// not carry user credentials, so the backend's auth challenge proves reachability +// and a working auth layer — that is success for probe purposes. +// - Authentication error AND backend has no outgoing auth strategy configured +// (AuthConfig nil or StrategyTypeUnauthenticated): Backend is unauthenticated +// (BackendUnauthenticated). This signals operator misconfiguration — the backend +// requires authentication but none was configured on the backend target. // - Timeout or connection error: Backend is unhealthy (BackendUnhealthy) // - Other errors: Backend is unhealthy (BackendUnhealthy) // @@ -127,8 +135,19 @@ func (h *healthChecker) CheckHealth(ctx context.Context, target *vmcp.BackendTar responseDuration := time.Since(startTime) if err != nil { - // Categorize the error to determine health status - status := categorizeError(err) + // Categorize the error to determine health status. The target's outgoing + // auth config is consulted: a 401/403 from a backend with an outgoing auth + // strategy is the expected response to a no-credential probe and maps to + // BackendHealthy. In that case we return a nil error so the monitor records + // this as a successful check and does not open the circuit breaker. + status := categorizeError(target, err) + if status == vmcp.BackendHealthy { + slog.Debug("health check received expected auth challenge — treating as healthy", + "backend", target.WorkloadName, + "error", err, + "duration", responseDuration) + return vmcp.BackendHealthy, nil + } slog.Debug("health check failed for backend", "backend", target.WorkloadName, "error", err, @@ -170,10 +189,16 @@ func statusFromManagedWorkload(target *vmcp.BackendTarget) (vmcp.BackendHealthSt } } -// categorizeError determines the appropriate health status based on the error type. +// categorizeError determines the appropriate health status based on the error type +// and the backend's outgoing auth configuration. +// // This uses sentinel error checking with errors.Is() for type-safe error categorization. // Falls back to string-based detection for backwards compatibility with non-wrapped errors. -func categorizeError(err error) vmcp.BackendHealthStatus { +// +// For auth errors (HTTP 401/403), the target's AuthConfig is consulted to distinguish +// an expected auth challenge (backend has outgoing auth configured) from a misconfiguration +// (backend has no outgoing auth strategy). See authErrorStatus for details. +func categorizeError(target *vmcp.BackendTarget, err error) vmcp.BackendHealthStatus { if err == nil { return vmcp.BackendHealthy } @@ -181,7 +206,7 @@ func categorizeError(err error) vmcp.BackendHealthStatus { // 1. Type-safe detection: Check for sentinel errors using errors.Is() // BackendClient now wraps all errors with appropriate sentinel errors if errors.Is(err, vmcp.ErrAuthenticationFailed) || errors.Is(err, vmcp.ErrAuthorizationFailed) { - return vmcp.BackendUnauthenticated + return authErrorStatus(target) } if errors.Is(err, vmcp.ErrTimeout) || errors.Is(err, vmcp.ErrCancelled) { @@ -195,7 +220,7 @@ func categorizeError(err error) vmcp.BackendHealthStatus { // 2. String-based detection: Fallback for backwards compatibility // This handles errors from sources that don't wrap with sentinel errors if vmcp.IsAuthenticationError(err) { - return vmcp.BackendUnauthenticated + return authErrorStatus(target) } if vmcp.IsTimeoutError(err) || vmcp.IsConnectionError(err) { @@ -205,3 +230,24 @@ func categorizeError(err error) vmcp.BackendHealthStatus { // Default to unhealthy for unknown errors return vmcp.BackendUnhealthy } + +// authErrorStatus maps an authentication error (HTTP 401/403) to a health status +// using the backend's outgoing auth configuration. +// +// Health probes deliberately do not carry user credentials. If the backend is +// configured with an outgoing auth strategy, a 401/403 from the backend proves +// that the backend is alive, the auth layer works, and the network+TLS path is +// healthy — this is the expected response to an unauthenticated probe and is +// therefore treated as BackendHealthy. +// +// If the backend has no outgoing auth strategy configured (AuthConfig nil or +// StrategyTypeUnauthenticated), a 401/403 indicates operator misconfiguration: +// the backend requires authentication but none was configured on the backend +// target. This is reported as BackendUnauthenticated so it surfaces in status. +func authErrorStatus(target *vmcp.BackendTarget) vmcp.BackendHealthStatus { + if target != nil && target.AuthConfig != nil && + target.AuthConfig.Type != authtypes.StrategyTypeUnauthenticated { + return vmcp.BackendHealthy + } + return vmcp.BackendUnauthenticated +} diff --git a/pkg/vmcp/health/checker_test.go b/pkg/vmcp/health/checker_test.go index f6c83c6e15..c8d2a20cf1 100644 --- a/pkg/vmcp/health/checker_test.go +++ b/pkg/vmcp/health/checker_test.go @@ -10,11 +10,13 @@ import ( "testing" "time" + "github.com/mark3labs/mcp-go/client/transport" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/vmcp" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" "github.com/stacklok/toolhive/pkg/vmcp/mocks" ) @@ -332,73 +334,178 @@ func TestHealthChecker_CheckHealth_ErrorCategorization(t *testing.T) { func TestCategorizeError(t *testing.T) { t.Parallel() + // Backends with an outgoing auth strategy configured: a 401/403 is the + // expected response to a no-credential probe and must be treated as healthy. + targetWithUpstreamInject := &vmcp.BackendTarget{ + AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}, + } + targetWithTokenExchange := &vmcp.BackendTarget{ + AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeTokenExchange}, + } + targetWithHeaderInjection := &vmcp.BackendTarget{ + AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeHeaderInjection}, + } + + // Backends without an outgoing auth strategy: a 401/403 indicates operator + // misconfiguration and must surface as BackendUnauthenticated. + targetNoAuthConfig := &vmcp.BackendTarget{AuthConfig: nil} + targetUnauthenticatedStrategy := &vmcp.BackendTarget{ + AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUnauthenticated}, + } + tests := []struct { name string + target *vmcp.BackendTarget err error expectedStatus vmcp.BackendHealthStatus }{ { name: "nil error", + target: targetNoAuthConfig, err: nil, expectedStatus: vmcp.BackendHealthy, }, + + // Auth errors + outgoing auth configured -> healthy (probe challenge is expected). { - name: "authentication failed", + name: "auth error with upstream_inject strategy is healthy", + target: targetWithUpstreamInject, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendHealthy, + }, + { + name: "auth error with token_exchange strategy is healthy", + target: targetWithTokenExchange, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendHealthy, + }, + { + name: "auth error with header_injection strategy is healthy", + target: targetWithHeaderInjection, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendHealthy, + }, + { + name: "authz error with upstream_inject strategy is healthy", + target: targetWithUpstreamInject, + err: vmcp.ErrAuthorizationFailed, + expectedStatus: vmcp.BackendHealthy, + }, + { + name: "string-based auth error with header_injection strategy is healthy", + target: targetWithHeaderInjection, + err: errors.New("HTTP 401"), + expectedStatus: vmcp.BackendHealthy, + }, + + // Auth errors + no outgoing auth configured -> unauthenticated (misconfig signal). + { + name: "auth error with nil AuthConfig is unauthenticated (misconfig)", + target: targetNoAuthConfig, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendUnauthenticated, + }, + { + name: "auth error with StrategyTypeUnauthenticated is unauthenticated (misconfig)", + target: targetUnauthenticatedStrategy, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendUnauthenticated, + }, + { + name: "authentication failed (string) with nil AuthConfig", + target: targetNoAuthConfig, err: errors.New("authentication failed"), expectedStatus: vmcp.BackendUnauthenticated, }, { - name: "authentication error", + name: "authentication error (string) with nil AuthConfig", + target: targetNoAuthConfig, err: errors.New("authentication error: invalid credentials"), expectedStatus: vmcp.BackendUnauthenticated, }, { - name: "request unauthorized", + name: "request unauthorized with nil AuthConfig", + target: targetNoAuthConfig, err: errors.New("request unauthorized"), expectedStatus: vmcp.BackendUnauthenticated, }, { - name: "HTTP 401", + name: "HTTP 401 with nil AuthConfig", + target: targetNoAuthConfig, err: errors.New("HTTP 401"), expectedStatus: vmcp.BackendUnauthenticated, }, { - name: "HTTP 403", + name: "HTTP 403 with nil AuthConfig", + target: targetNoAuthConfig, err: errors.New("HTTP 403"), expectedStatus: vmcp.BackendUnauthenticated, }, { - name: "timeout", + name: "nil target with auth error is unauthenticated", + target: nil, + err: vmcp.ErrAuthenticationFailed, + expectedStatus: vmcp.BackendUnauthenticated, + }, + + // Non-auth errors: AuthConfig is irrelevant; classification is unchanged. + { + name: "timeout with upstream_inject strategy is still unhealthy", + target: targetWithUpstreamInject, err: errors.New("request timeout"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "deadline exceeded", + name: "timeout with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, + err: errors.New("request timeout"), + expectedStatus: vmcp.BackendUnhealthy, + }, + { + name: "deadline exceeded with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, err: errors.New("context deadline exceeded"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "connection refused", + name: "connection refused with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, err: errors.New("connection refused"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "connection reset", + name: "connection refused with header_injection strategy is still unhealthy", + target: targetWithHeaderInjection, + err: errors.New("connection refused"), + expectedStatus: vmcp.BackendUnhealthy, + }, + { + name: "connection reset with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, err: errors.New("connection reset by peer"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "no route to host", + name: "no route to host with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, err: errors.New("no route to host"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "network unreachable", + name: "network unreachable with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, err: errors.New("network is unreachable"), expectedStatus: vmcp.BackendUnhealthy, }, { - name: "generic error", + name: "generic error with nil AuthConfig is unhealthy", + target: targetNoAuthConfig, + err: errors.New("something went wrong"), + expectedStatus: vmcp.BackendUnhealthy, + }, + { + name: "generic error with token_exchange strategy is still unhealthy", + target: targetWithTokenExchange, err: errors.New("something went wrong"), expectedStatus: vmcp.BackendUnhealthy, }, @@ -408,7 +515,7 @@ func TestCategorizeError(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - status := categorizeError(tt.err) + status := categorizeError(tt.target, tt.err) assert.Equal(t, tt.expectedStatus, status) }) } @@ -446,6 +553,14 @@ func TestIsAuthenticationError(t *testing.T) { {name: "404 not found", err: errors.New("404 not found"), expectErr: false}, {name: "500 internal server error", err: errors.New("500 internal server error"), expectErr: false}, {name: "hostname with 401", err: errors.New("http://backend401.example.com"), expectErr: false}, + // Pin against accidental loosening of the "authorization required" + // substring matcher: a validation message of the form "field + // 'authorization' required" must not be misclassified as an auth + // failure. The current matcher uses the contiguous substring + // "authorization required" (one space, no punctuation), so this + // string does not match — but a future loosening (e.g. allowing + // any whitespace) would silently regress. + {name: "validation message containing 'authorization' and 'required'", err: errors.New("field 'authorization' required"), expectErr: false}, {name: "nil error", err: nil, expectErr: false}, } @@ -588,11 +703,13 @@ func TestHealthChecker_CheckHealth_ContextCarriesHealthCheckMarker(t *testing.T) "apply outgoing auth to health check probes") } -// TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated verifies that auth -// errors from health checks are correctly categorised as BackendUnauthenticated, not -// BackendUnhealthy. This distinguishes a backend that is reachable-but-needs-auth -// from one that is down, which is important for health check probes that apply -// outgoing auth credentials (header_injection or token_exchange). +// TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated verifies that +// auth errors from health checks are categorised as BackendUnauthenticated when the +// backend target has no outgoing auth strategy configured (AuthConfig nil in these +// cases). This represents a misconfiguration: the backend requires authentication +// but no strategy was configured on the target. A 401/403 from a backend that *does* +// have an outgoing auth strategy is treated as BackendHealthy by the checker and +// is covered in TestCategorizeError. func TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated(t *testing.T) { t.Parallel() @@ -627,6 +744,16 @@ func TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated(t *tes name: "mcp-go ErrUnauthorized wrapped as ErrAuthenticationFailed by wrapBackendError", err: fmt.Errorf("%w: failed to initialize for backend my-backend: unauthorized (401)", vmcp.ErrAuthenticationFailed), }, + { + // Issue #5223: mcp-go v0.49.0+ returns *AuthorizationRequiredError for + // 401 with WWW-Authenticate, wrapped in *transport.Error. Both layers + // Unwrap() to transport.ErrAuthorizationRequired. The string fallback + // must recognize "authorization required" so the probe error reaches + // health monitoring as BackendUnauthenticated rather than falling + // through to BackendUnhealthy and producing WARN spam. + name: "transport.Error wrapping AuthorizationRequiredError", + err: transport.NewError(&transport.AuthorizationRequiredError{}), + }, } for _, tt := range tests { @@ -657,6 +784,105 @@ func TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated(t *tes } } +// TestHealthChecker_CheckHealth_AuthErrorWithOutgoingAuthIsHealthy verifies that a +// 401/403 from a backend that has an outgoing auth strategy configured (e.g., +// upstream_inject, token_exchange, header_injection) is treated as BackendHealthy. +// Health probes deliberately do not carry user credentials, so the backend's auth +// challenge is the expected response and proves reachability. This is the behavior +// change introduced by the fix for issue #4920. +func TestHealthChecker_CheckHealth_AuthErrorWithOutgoingAuthIsHealthy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authConfig *authtypes.BackendAuthStrategy + err error + }{ + { + name: "upstream_inject + sentinel auth error", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}, + err: vmcp.ErrAuthenticationFailed, + }, + { + name: "token_exchange + status code 401", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeTokenExchange}, + err: fmt.Errorf("backend unavailable: failed to initialize client for backend my-backend: status code 401"), + }, + { + name: "header_injection + HTTP 403", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeHeaderInjection}, + err: errors.New("HTTP 403 forbidden"), + }, + { + name: "upstream_inject + wrapped sentinel", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}, + err: fmt.Errorf("%w: unauthorized (401)", vmcp.ErrAuthenticationFailed), + }, + { + // Issue #5223 exact reproducer: North's github-copilot-entry backend + // behind an upstreamInject auth strategy. Probe carries no user creds; + // mcp-go returns the typed authorization-required chain. Once correctly + // classified as auth, #4935's logic must take over and report + // BackendHealthy with nil err so the monitor records a successful + // check, the circuit breaker stays closed, and the WARN spam stops. + name: "upstream_inject + transport.Error wrapping AuthorizationRequiredError", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}, + err: transport.NewError(&transport.AuthorizationRequiredError{}), + }, + { + // Same mcp-go chain as the upstream_inject row above; this row pins + // that token_exchange flows through the same authErrorStatus branch. + name: "token_exchange + transport.Error wrapping AuthorizationRequiredError", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeTokenExchange}, + err: transport.NewError(&transport.AuthorizationRequiredError{}), + }, + { + // Same mcp-go chain as the upstream_inject row above; this row pins + // that header_injection flows through the same authErrorStatus branch. + name: "header_injection + transport.Error wrapping AuthorizationRequiredError", + authConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeHeaderInjection}, + err: transport.NewError(&transport.AuthorizationRequiredError{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockClient := mocks.NewMockBackendClient(ctrl) + mockClient.EXPECT(). + ListCapabilities(gomock.Any(), gomock.Any()). + Return(nil, tt.err). + Times(1) + + checker := NewHealthChecker(mockClient, 5*time.Second, 0) + target := &vmcp.BackendTarget{ + WorkloadID: "backend-1", + WorkloadName: "test-backend", + BaseURL: "http://localhost:8080", + AuthConfig: tt.authConfig, + } + + status, err := checker.CheckHealth(t.Context(), target) + // When the status is BackendHealthy (expected auth challenge) the + // checker returns a nil error so the monitor records it as a + // successful check and does not increment failure counters or open + // the circuit breaker. + assert.NoError(t, err, + "auth challenge from an auth-configured backend must be reported "+ + "as a successful check (nil error) so the monitor records "+ + "success and the circuit breaker stays closed") + assert.Equal(t, vmcp.BackendHealthy, status, + "auth failure from a probe against a backend with an outgoing "+ + "auth strategy configured must be BackendHealthy — the challenge "+ + "is the expected response to a no-credential probe") + }) + } +} + func TestHealthChecker_CheckHealth_MultipleBackends(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/health/monitor.go b/pkg/vmcp/health/monitor.go index 651fac55ea..db66b8656f 100644 --- a/pkg/vmcp/health/monitor.go +++ b/pkg/vmcp/health/monitor.go @@ -449,13 +449,19 @@ func (m *Monitor) performHealthCheck(ctx context.Context, backend *vmcp.Backend) return } - // Create BackendTarget from Backend + // Create BackendTarget from Backend. Carry CA bundle and header-forward config + // so health checks hit the backend with the same TLS trust and header injection + // as list/call requests — otherwise a healthy-to-the-monitor backend could fail + // for real traffic (or vice versa). target := &vmcp.BackendTarget{ WorkloadID: backend.ID, WorkloadName: backend.Name, BaseURL: backend.BaseURL, TransportType: backend.TransportType, + CABundlePath: backend.CABundlePath, + CABundleData: backend.CABundleData, AuthConfig: backend.AuthConfig, + HeaderForward: backend.HeaderForward, HealthStatus: backend.HealthStatus, Metadata: backend.Metadata, } @@ -592,8 +598,14 @@ type Summary struct { } // Routable returns the number of backends that can serve traffic. -// This includes healthy backends and unauthenticated backends (which are -// reachable but require per-request user auth, e.g., upstream OAuth). +// +// TODO(#4920 follow-up): This counts BackendUnauthenticated toward the routable +// total for historical reasons (PR #4866 added this when BackendUnauthenticated +// meant "reachable but needs per-request user auth"). After the #4920 fix, +// BackendUnauthenticated indicates misconfiguration (backend requires auth but +// no outgoing auth strategy is configured) and should not be considered routable. +// Revert to `s.Healthy` in a follow-up PR that also updates the operator status +// collector and controller counterparts of this logic. func (s Summary) Routable() int { return s.Healthy + s.Unauthenticated } @@ -608,11 +620,11 @@ func (s Summary) String() string { // This converts backend health information into the format needed for status reporting // to the Kubernetes API or CLI output. // -// Phase determination (unauthenticated backends are routable — they need per-request user auth -// but are reachable and running): -// - Ready: All backends healthy or unauthenticated, or no backends configured (cold start) +// Phase determination (see Summary.Routable for the TODO about unauthenticated +// backends being counted as routable — legacy from PR #4866, to be reverted): +// - Ready: All backends routable, or no backends configured (cold start) // - Pending: Backends configured but no health check data yet (waiting for first check) -// - Degraded: Some backends routable (healthy/unauthenticated), some degraded/unhealthy +// - Degraded: Some backends routable, some degraded/unhealthy // - Failed: No routable backends (and at least one backend exists) // // Returns a Status instance with current health information and discovered backends. @@ -650,12 +662,12 @@ func (m *Monitor) BuildStatus() *vmcp.Status { } // determinePhase determines the overall phase based on backend health. -// Unauthenticated backends are treated as routable — they are reachable and running, -// they just require per-request user auth (e.g., upstream OAuth). +// See Summary.Routable for the TODO about unauthenticated backends being +// counted as routable (legacy from PR #4866, to be reverted in a follow-up). // Takes both the health summary and the count of configured backends to distinguish: // - No backends configured (configuredCount==0): Ready (cold start) // - Backends configured but no health data (configuredCount>0 && summary.Total==0): Pending -// - Has health data: Ready/Degraded/Failed based on routable (healthy + unauthenticated) count +// - Has health data: Ready/Degraded/Failed based on routable count func determinePhase(summary Summary, configuredBackendCount int) vmcp.Phase { if summary.Total == 0 { // No health data yet - distinguish cold start from waiting for first check @@ -829,7 +841,7 @@ func formatBackendMessage(state *State) string { case vmcp.BackendUnhealthy: baseMsg = "Unhealthy" case vmcp.BackendUnauthenticated: - baseMsg = "Authentication required" + baseMsg = "Authentication misconfigured (backend requires auth, none configured)" case vmcp.BackendUnknown: baseMsg = "Unknown" default: diff --git a/pkg/vmcp/server/telemetry.go b/pkg/vmcp/internal/backendtelemetry/backendtelemetry.go similarity index 79% rename from pkg/vmcp/server/telemetry.go rename to pkg/vmcp/internal/backendtelemetry/backendtelemetry.go index 5002f314cb..57ae2c9691 100644 --- a/pkg/vmcp/server/telemetry.go +++ b/pkg/vmcp/internal/backendtelemetry/backendtelemetry.go @@ -1,7 +1,14 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package server +// Package backendtelemetry decorates a [vmcp.BackendClient] so each backend MCP +// call records OpenTelemetry traces and metrics. +// +// It lives in pkg/vmcp/internal so that both the transport server (server.New) +// and the core constructor (core.New) can share a single decorator without an +// import cycle: server and core both depend on this leaf package, and it depends +// on neither. +package backendtelemetry import ( "context" @@ -23,9 +30,9 @@ const ( instrumentationName = "github.com/stacklok/toolhive/pkg/vmcp" ) -// monitorBackends decorates the backend client so it records telemetry on each method call. +// MonitorBackends decorates the backend client so it records telemetry on each method call. // It also emits a gauge for the number of backends discovered once, since the number of backends is static. -func monitorBackends( +func MonitorBackends( ctx context.Context, meterProvider metric.MeterProvider, tracerProvider trace.TracerProvider, @@ -84,57 +91,6 @@ func monitorBackends( }, nil } -// monitorSessionCardinality registers observable gauges for the main vMCP -// session layers so operators can distinguish transport/session churn from -// live SDK-session growth and post-initialize registration backlog. -func monitorSessionCardinality( - meterProvider metric.MeterProvider, - srv *Server, -) error { - meter := meterProvider.Meter(instrumentationName) - - activeTransportSessions, err := meter.Int64ObservableGauge( - "toolhive_vmcp_active_transport_sessions", - metric.WithDescription("Number of active transport-layer MCP sessions tracked by the vMCP"), - ) - if err != nil { - return fmt.Errorf("failed to create active transport sessions gauge: %w", err) - } - - activeSDKSessions, err := meter.Int64ObservableGauge( - "toolhive_vmcp_active_sdk_sessions", - metric.WithDescription("Number of live SDK client sessions currently registered on this vMCP replica"), - ) - if err != nil { - return fmt.Errorf("failed to create active SDK sessions gauge: %w", err) - } - - inFlightRegistrations, err := meter.Int64ObservableGauge( - "toolhive_vmcp_inflight_session_registrations", - metric.WithDescription("Number of session registrations currently executing the post-initialize capability injection hook"), - ) - if err != nil { - return fmt.Errorf("failed to create in-flight session registrations gauge: %w", err) - } - - _, err = meter.RegisterCallback( - func(_ context.Context, observer metric.Observer) error { - observer.ObserveInt64(activeTransportSessions, int64(srv.sessionManager.Count())) - observer.ObserveInt64(activeSDKSessions, srv.activeClientSessionCount()) - observer.ObserveInt64(inFlightRegistrations, srv.inFlightRegistrationCount()) - return nil - }, - activeTransportSessions, - activeSDKSessions, - inFlightRegistrations, - ) - if err != nil { - return fmt.Errorf("failed to register session cardinality callback: %w", err) - } - - return nil -} - type telemetryBackendClient struct { backendClient vmcp.BackendClient tracer trace.Tracer @@ -145,24 +101,6 @@ type telemetryBackendClient struct { clientOperationDuration metric.Float64Histogram } -func (s *Server) activeClientSessionCount() int64 { - var count int64 - s.activeClientSessions.Range(func(_, _ any) bool { - count++ - return true - }) - return count -} - -func (s *Server) inFlightRegistrationCount() int64 { - var count int64 - s.inFlightRegistrations.Range(func(_, _ any) bool { - count++ - return true - }) - return count -} - var _ vmcp.BackendClient = telemetryBackendClient{} // mapActionToMCPMethod maps internal action names to MCP method names per the OTEL MCP spec. diff --git a/pkg/vmcp/internal/backendtelemetry/backendtelemetry_test.go b/pkg/vmcp/internal/backendtelemetry/backendtelemetry_test.go new file mode 100644 index 0000000000..0c96135357 --- /dev/null +++ b/pkg/vmcp/internal/backendtelemetry/backendtelemetry_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package backendtelemetry + +import ( + "testing" +) + +func TestMapActionToMCPMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action string + expected string + }{ + {name: "call_tool maps to tools/call", action: "call_tool", expected: "tools/call"}, + {name: "read_resource maps to resources/read", action: "read_resource", expected: "resources/read"}, + {name: "get_prompt maps to prompts/get", action: "get_prompt", expected: "prompts/get"}, + {name: "unknown action passes through", action: "list_capabilities", expected: "list_capabilities"}, + {name: "empty string passes through", action: "", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mapActionToMCPMethod(tt.action) + if got != tt.expected { + t.Errorf("mapActionToMCPMethod(%q) = %q, want %q", tt.action, got, tt.expected) + } + }) + } +} + +func TestMapTransportTypeToNetworkTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + transportType string + expected string + }{ + {name: "stdio maps to pipe", transportType: "stdio", expected: "pipe"}, + {name: "sse maps to tcp", transportType: "sse", expected: "tcp"}, + {name: "streamable-http maps to tcp", transportType: "streamable-http", expected: "tcp"}, + {name: "unknown defaults to tcp", transportType: "unknown", expected: "tcp"}, + {name: "empty defaults to tcp", transportType: "", expected: "tcp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mapTransportTypeToNetworkTransport(tt.transportType) + if got != tt.expected { + t.Errorf("mapTransportTypeToNetworkTransport(%q) = %q, want %q", tt.transportType, got, tt.expected) + } + }) + } +} diff --git a/pkg/vmcp/mocks/mock_elicitation_requester.go b/pkg/vmcp/mocks/mock_elicitation_requester.go new file mode 100644 index 0000000000..eb41338e96 --- /dev/null +++ b/pkg/vmcp/mocks/mock_elicitation_requester.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: elicitation.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_elicitation_requester.go -package=mocks -source=elicitation.go ElicitationRequester +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + vmcp "github.com/stacklok/toolhive/pkg/vmcp" + gomock "go.uber.org/mock/gomock" +) + +// MockElicitationRequester is a mock of ElicitationRequester interface. +type MockElicitationRequester struct { + ctrl *gomock.Controller + recorder *MockElicitationRequesterMockRecorder + isgomock struct{} +} + +// MockElicitationRequesterMockRecorder is the mock recorder for MockElicitationRequester. +type MockElicitationRequesterMockRecorder struct { + mock *MockElicitationRequester +} + +// NewMockElicitationRequester creates a new mock instance. +func NewMockElicitationRequester(ctrl *gomock.Controller) *MockElicitationRequester { + mock := &MockElicitationRequester{ctrl: ctrl} + mock.recorder = &MockElicitationRequesterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockElicitationRequester) EXPECT() *MockElicitationRequesterMockRecorder { + return m.recorder +} + +// RequestElicitation mocks base method. +func (m *MockElicitationRequester) RequestElicitation(ctx context.Context, req vmcp.ElicitationRequest) (*vmcp.ElicitationResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestElicitation", ctx, req) + ret0, _ := ret[0].(*vmcp.ElicitationResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestElicitation indicates an expected call of RequestElicitation. +func (mr *MockElicitationRequesterMockRecorder) RequestElicitation(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestElicitation", reflect.TypeOf((*MockElicitationRequester)(nil).RequestElicitation), ctx, req) +} diff --git a/pkg/vmcp/registry.go b/pkg/vmcp/registry.go index ba9f2c6b81..ccce6e321c 100644 --- a/pkg/vmcp/registry.go +++ b/pkg/vmcp/registry.go @@ -373,6 +373,7 @@ func BackendToTarget(backend *Backend) *BackendTarget { AuthConfig: backend.AuthConfig, SessionAffinity: false, // TODO: Add session affinity support in future phases HealthStatus: backend.HealthStatus, + HeaderForward: backend.HeaderForward, Metadata: backend.Metadata, } } diff --git a/pkg/vmcp/schema/reflect.go b/pkg/vmcp/schema/reflect.go index 5fdc29ffe6..7eaf84c26a 100644 --- a/pkg/vmcp/schema/reflect.go +++ b/pkg/vmcp/schema/reflect.go @@ -42,7 +42,7 @@ func GenerateSchema[T any]() (map[string]any, error) { // generateSchemaForType generates schema for a reflect.Type. func generateSchemaForType(t reflect.Type) (map[string]any, error) { // Handle pointer types - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } diff --git a/pkg/vmcp/server/annotation_enrichment.go b/pkg/vmcp/server/annotation_enrichment.go index 11b9519101..1edfbd3775 100644 --- a/pkg/vmcp/server/annotation_enrichment.go +++ b/pkg/vmcp/server/annotation_enrichment.go @@ -88,8 +88,15 @@ func findToolAnnotations(toolName string, caps *aggregator.AggregatedCapabilitie // convertAnnotations converts vmcp.ToolAnnotations to authorizers.ToolAnnotations. // Only authorization-relevant hint fields are mapped; informational fields like // Title are intentionally omitted since they are not used in policy evaluation. -// Returns nil if the source annotations contain no hint fields. +// Returns nil if the source annotations are nil or contain no hint fields. +// +// Kept identical to core.convertAnnotations (pkg/vmcp/core/admission.go), including +// the nil guard below — the two are intentional, temporary duplicates (the core +// cannot import this package: server imports core) and #5441 deletes this copy. func convertAnnotations(ann *vmcp.ToolAnnotations) *authorizers.ToolAnnotations { + if ann == nil { + return nil + } if ann.ReadOnlyHint == nil && ann.DestructiveHint == nil && ann.IdempotentHint == nil && ann.OpenWorldHint == nil { return nil diff --git a/pkg/vmcp/server/integration_test.go b/pkg/vmcp/server/integration_test.go index b3d2256d71..d6c4325d45 100644 --- a/pkg/vmcp/server/integration_test.go +++ b/pkg/vmcp/server/integration_test.go @@ -506,8 +506,8 @@ func TestIntegration_AuditLogging(t *testing.T) { // table needed for tool calls and resource reads to be audit-logged correctly. auditSessionFactory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) auditSessionFactory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { mock := sessionmocks.NewMockMultiSession(ctrl) mock.EXPECT().ID().Return(id).AnyTimes() mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes() diff --git a/pkg/vmcp/server/refresh_scheduler_test.go b/pkg/vmcp/server/refresh_scheduler_test.go index b1be41ab63..e9258f7b77 100644 --- a/pkg/vmcp/server/refresh_scheduler_test.go +++ b/pkg/vmcp/server/refresh_scheduler_test.go @@ -269,14 +269,14 @@ const ( type boundRefreshTestSession struct { sessiontypes.MultiSession backendIDs string - tokenHash string + binding string identity *auth.Identity } func (s *boundRefreshTestSession) GetMetadata() map[string]string { return map[string]string{ - vmcpsession.MetadataKeyBackendIDs: s.backendIDs, - sessiontypes.MetadataKeyTokenHash: s.tokenHash, + vmcpsession.MetadataKeyBackendIDs: s.backendIDs, + sessiontypes.MetadataKeyIdentityBinding: s.binding, } } @@ -297,12 +297,12 @@ func TestReconcileSessionsLackingBackends_SkipsBoundSessionsWithoutCallerToken(t sessions := map[string]sessiontypes.MultiSession{ "restored": &boundRefreshTestSession{ backendIDs: reconcileTestBackend, - tokenHash: "subject-hash", + binding: "https://idp.example\x00user-123", identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}}, }, "live": &boundRefreshTestSession{ backendIDs: reconcileTestBackend, - tokenHash: "subject-hash", + binding: "https://idp.example\x00user-123", identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}, Token: "bearer"}, }, "anonymous": &boundRefreshTestSession{backendIDs: reconcileTestBackend}, diff --git a/pkg/vmcp/server/sdk_elicitation_adapter.go b/pkg/vmcp/server/sdk_elicitation_adapter.go index 2c2605fac4..3cf0eb7ebd 100644 --- a/pkg/vmcp/server/sdk_elicitation_adapter.go +++ b/pkg/vmcp/server/sdk_elicitation_adapter.go @@ -7,47 +7,65 @@ package server import ( "context" + "maps" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "github.com/stacklok/toolhive/pkg/vmcp/composer" + "github.com/stacklok/toolhive/pkg/vmcp" ) -// sdkElicitationAdapter wraps mark3labs MCPServer to implement composer.SDKElicitationRequester. +// sdkElicitationAdapter wraps mark3labs MCPServer to implement vmcp.ElicitationRequester. // -// This adapter bridges the gap between the server's SDK instance and the workflow engine's -// SDK-agnostic elicitation handler. It enables: -// - Migration to official SDK without changing workflow code -// - Decoupling of workflow engine from specific SDK implementation -// - Testability via mock implementations +// It is the sole point where mcp-go elicitation types appear: it translates the +// domain ElicitationRequest/ElicitationResult to/from the SDK types, keeping the +// composer and the rest of vmcp free of SDK coupling. // -// Per MCP 2025-06-18 spec: The SDK handles JSON-RPC ID correlation internally. -// Our adapter is a simple pass-through that doesn't need to manage IDs. +// Per MCP 2025-06-18 spec: The SDK handles JSON-RPC ID correlation internally, +// so the adapter does not manage IDs. // // Thread-safety: Safe for concurrent calls. The mark3labs MCPServer is thread-safe. type sdkElicitationAdapter struct { // mcpServer is the mark3labs SDK server instance that handles elicitation protocol. - mcpServer *server.MCPServer + // Typed as the minimal mcpElicitationRequester seam so the translation logic + // can be unit-tested against a fake SDK; *server.MCPServer satisfies it. + mcpServer mcpElicitationRequester } -// newSDKElicitationAdapter creates a new elicitation adapter that wraps the mark3labs SDK server. +// mcpElicitationRequester is the minimal slice of the mark3labs SDK that the +// adapter depends on. *server.MCPServer satisfies it in production; tests +// substitute a fake to verify domain ⇄ mcp-go translation without a live session. +type mcpElicitationRequester interface { + RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) +} + +// NewSDKElicitationAdapter creates a new elicitation adapter that wraps the mark3labs SDK server. // -// The returned adapter implements composer.SDKElicitationRequester by delegating to the -// SDK's RequestElicitation method. Session management and JSON-RPC ID correlation are -// handled entirely by the SDK. -func newSDKElicitationAdapter(mcpServer *server.MCPServer) composer.SDKElicitationRequester { +// The returned adapter implements vmcp.ElicitationRequester by translating the domain +// request to/from mcp-go types and delegating to the SDK's RequestElicitation method. +// This adapter is the sole point where mcp-go elicitation types appear. Session +// management and JSON-RPC ID correlation are handled entirely by the SDK. +// +// Intended for embedders that wrap the vMCP composer in their own pipeline and need to +// drive MCP elicitation through the same SDK server that serves /mcp traffic. Pass the +// *server.MCPServer obtained from (*Server).MCPServer() so the returned requester +// correlates with the server handling incoming client sessions; a parallel MCPServer +// constructed by the caller will not work because ClientSession correlation is keyed +// to the server that received the initialize request. +func NewSDKElicitationAdapter(mcpServer *server.MCPServer) vmcp.ElicitationRequester { return &sdkElicitationAdapter{ mcpServer: mcpServer, } } -// RequestElicitation delegates to the mark3labs SDK's RequestElicitation method. +// RequestElicitation translates the domain request to mcp-go, delegates to the +// mark3labs SDK's RequestElicitation method, and translates the response back. // // This is a synchronous blocking call that: -// 1. Forwards the request to the mark3labs SDK -// 2. Blocks until the client responds or timeout occurs -// 3. Returns the response from the SDK +// 1. Maps the domain ElicitationRequest to an mcp.ElicitationRequest +// 2. Forwards the request to the mark3labs SDK +// 3. Blocks until the client responds or timeout occurs +// 4. Maps the SDK's mcp.ElicitationResult back to the domain ElicitationResult // // The SDK handles all protocol details internally: // - JSON-RPC ID generation and correlation @@ -57,12 +75,26 @@ func newSDKElicitationAdapter(mcpServer *server.MCPServer) composer.SDKElicitati // Per MCP 2025-06-18 spec: Elicitation is a synchronous request/response protocol. // The server sends a request and blocks until the client responds. // -// Returns ElicitationResult from the SDK or error if the request fails, times out, +// Returns the domain ElicitationResult or error if the request fails, times out, // or the user declines/cancels. func (a *sdkElicitationAdapter) RequestElicitation( ctx context.Context, - request mcp.ElicitationRequest, -) (*mcp.ElicitationResult, error) { + req vmcp.ElicitationRequest, +) (*vmcp.ElicitationResult, error) { + // Translate the domain request to the SDK request type (form-mode only). + mcpReq := mcp.ElicitationRequest{ + Params: mcp.ElicitationParams{ + Message: req.Message, + RequestedSchema: req.RequestedSchema, + }, + } + // Only attach _meta when the caller actually set it. NewMetaFromMap mutates + // its argument (it deletes progressToken), so copy first to avoid mutating + // the caller's map. + if req.Meta != nil { + mcpReq.Params.Meta = mcp.NewMetaFromMap(maps.Clone(req.Meta)) + } + // Delegate to the mark3labs SDK's RequestElicitation method. // The SDK will: // 1. Extract session ID from context (set by SDK middleware) @@ -73,5 +105,15 @@ func (a *sdkElicitationAdapter) RequestElicitation( // 6. Return result to us // // We don't need to manage any of this - it's all handled by the SDK. - return a.mcpServer.RequestElicitation(ctx, request) + resp, err := a.mcpServer.RequestElicitation(ctx, mcpReq) + if err != nil { + return nil, err + } + + // Translate the SDK response back to the domain result. Content is passed + // through as any so the caller performs its own map assertion unchanged. + return &vmcp.ElicitationResult{ + Action: string(resp.Action), + Content: resp.Content, + }, nil } diff --git a/pkg/vmcp/server/sdk_elicitation_adapter_test.go b/pkg/vmcp/server/sdk_elicitation_adapter_test.go index fbc0ffd04c..8c28877593 100644 --- a/pkg/vmcp/server/sdk_elicitation_adapter_test.go +++ b/pkg/vmcp/server/sdk_elicitation_adapter_test.go @@ -11,63 +11,88 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/vmcp" ) +// fakeSDKElicitationRequester captures the translated mcp-go request and returns +// a canned response/error, letting us assert the adapter's domain ⇄ mcp-go mapping. +type fakeSDKElicitationRequester struct { + gotRequest mcp.ElicitationRequest + result *mcp.ElicitationResult + err error +} + +func (f *fakeSDKElicitationRequester) RequestElicitation( + _ context.Context, + request mcp.ElicitationRequest, +) (*mcp.ElicitationResult, error) { + f.gotRequest = request + return f.result, f.err +} + func TestSDKElicitationAdapter_RequestElicitation(t *testing.T) { t.Parallel() + content := map[string]any{"confirmed": true} + tests := []struct { - name string - mockFunc func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) - wantError bool - wantAction mcp.ElicitationResponseAction + name string + req vmcp.ElicitationRequest + result *mcp.ElicitationResult + sdkErr error + wantError bool + wantAction string + wantContent any }{ { - name: "accept action", - mockFunc: func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - return &mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionAccept, - Content: map[string]any{"confirmed": true}, - }, - }, nil + name: "accept round-trips action and content", + req: vmcp.ElicitationRequest{ + Message: "Confirm?", + RequestedSchema: map[string]any{"type": "object"}, + }, + result: &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{ + Action: mcp.ElicitationResponseActionAccept, + Content: content, + }, }, - wantAction: mcp.ElicitationResponseActionAccept, + wantAction: "accept", + wantContent: content, }, { - name: "decline action", - mockFunc: func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - return &mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionDecline, - }, - }, nil + name: "decline maps action with nil content", + req: vmcp.ElicitationRequest{Message: "Proceed?"}, + result: &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{ + Action: mcp.ElicitationResponseActionDecline, + }, }, - wantAction: mcp.ElicitationResponseActionDecline, + wantAction: "decline", + wantContent: nil, }, { - name: "cancel action", - mockFunc: func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - return &mcp.ElicitationResult{ - ElicitationResponse: mcp.ElicitationResponse{ - Action: mcp.ElicitationResponseActionCancel, - }, - }, nil + name: "cancel maps action with nil content", + req: vmcp.ElicitationRequest{Message: "Continue?"}, + result: &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{ + Action: mcp.ElicitationResponseActionCancel, + }, }, - wantAction: mcp.ElicitationResponseActionCancel, + wantAction: "cancel", + wantContent: nil, }, { - name: "SDK error", - mockFunc: func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - return nil, errors.New("SDK internal error") - }, + name: "SDK error is propagated", + req: vmcp.ElicitationRequest{Message: "Confirm?"}, + sdkErr: errors.New("SDK internal error"), wantError: true, }, { - name: "context cancelled", - mockFunc: func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { - return nil, context.Canceled - }, + name: "context cancellation is propagated", + req: vmcp.ElicitationRequest{Message: "Confirm?"}, + sdkErr: context.Canceled, wantError: true, }, } @@ -76,48 +101,93 @@ func TestSDKElicitationAdapter_RequestElicitation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - adapter := &testSDKElicitationRequester{mockFunc: tt.mockFunc} + fake := &fakeSDKElicitationRequester{result: tt.result, err: tt.sdkErr} + adapter := &sdkElicitationAdapter{mcpServer: fake} - request := mcp.ElicitationRequest{ - Params: mcp.ElicitationParams{ - Message: "Test", - RequestedSchema: map[string]any{"type": "object"}, - }, - } + result, err := adapter.RequestElicitation(context.Background(), tt.req) - result, err := adapter.RequestElicitation(context.Background(), request) + // Request translation: domain fields map onto mcp.ElicitationParams. + assert.Equal(t, tt.req.Message, fake.gotRequest.Params.Message) + assert.Equal(t, tt.req.RequestedSchema, fake.gotRequest.Params.RequestedSchema) if tt.wantError { - assert.Error(t, err) + require.Error(t, err) assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.NotNil(t, result) - assert.Equal(t, tt.wantAction, result.Action) + return } + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tt.wantAction, result.Action) + assert.Equal(t, tt.wantContent, result.Content) }) } } +// TestSDKElicitationAdapter_NilMetaProducesNoMeta verifies that a nil domain +// Meta does not produce an _meta block on the translated request (no behavior +// change versus the prior mcp-go-only path, which never set Meta). +func TestSDKElicitationAdapter_NilMetaProducesNoMeta(t *testing.T) { + t.Parallel() + + fake := &fakeSDKElicitationRequester{ + result: &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{Action: mcp.ElicitationResponseActionAccept}, + }, + } + adapter := &sdkElicitationAdapter{mcpServer: fake} + + _, err := adapter.RequestElicitation(context.Background(), vmcp.ElicitationRequest{Message: "Confirm?"}) + require.NoError(t, err) + assert.Nil(t, fake.gotRequest.Params.Meta) +} + +// TestSDKElicitationAdapter_MetaIsCopied verifies that translating a request +// with Meta does not mutate the caller's map (NewMetaFromMap deletes +// progressToken from its argument). +func TestSDKElicitationAdapter_MetaIsCopied(t *testing.T) { + t.Parallel() + + fake := &fakeSDKElicitationRequester{ + result: &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{Action: mcp.ElicitationResponseActionAccept}, + }, + } + adapter := &sdkElicitationAdapter{mcpServer: fake} + + meta := map[string]any{"progressToken": "tok-1", "trace": "abc"} + _, err := adapter.RequestElicitation(context.Background(), vmcp.ElicitationRequest{ + Message: "Confirm?", + Meta: meta, + }) + require.NoError(t, err) + + // Caller's map is untouched. + assert.Equal(t, "tok-1", meta["progressToken"]) + // Translated request carries the metadata. + require.NotNil(t, fake.gotRequest.Params.Meta) + assert.Equal(t, "tok-1", fake.gotRequest.Params.Meta.ProgressToken) +} + func TestSDKElicitationAdapter_Integration(t *testing.T) { t.Parallel() mcpServer := server.NewMCPServer("test", "1.0.0") - adapter := newSDKElicitationAdapter(mcpServer) + adapter := NewSDKElicitationAdapter(mcpServer) assert.NotNil(t, adapter) } -type testSDKElicitationRequester struct { - mockFunc func(context.Context, mcp.ElicitationRequest) (*mcp.ElicitationResult, error) -} +// TestServer_MCPServer_ReturnsSameInstance verifies that (*Server).MCPServer +// returns the exact mark3labs server pointer stored at construction time. +// Identity matters because ClientSession correlation is keyed to the server +// that received the initialize request; embedders building their own +// elicitation requester must receive the authoritative instance. +func TestServer_MCPServer_ReturnsSameInstance(t *testing.T) { + t.Parallel() -func (t *testSDKElicitationRequester) RequestElicitation( - ctx context.Context, - request mcp.ElicitationRequest, -) (*mcp.ElicitationResult, error) { - if t.mockFunc != nil { - return t.mockFunc(ctx, request) - } - return nil, errors.New("not implemented") + mcpServer := server.NewMCPServer("test", "1.0.0") + srv := &Server{mcpServer: mcpServer} + + assert.Same(t, mcpServer, srv.MCPServer()) } diff --git a/pkg/vmcp/server/serve.go b/pkg/vmcp/server/serve.go new file mode 100644 index 0000000000..f1288625ed --- /dev/null +++ b/pkg/vmcp/server/serve.go @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "cmp" + "context" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/server" + + "github.com/stacklok/toolhive/pkg/audit" + asrunner "github.com/stacklok/toolhive/pkg/authserver/runner" + "github.com/stacklok/toolhive/pkg/telemetry" + transportsession "github.com/stacklok/toolhive/pkg/transport/session" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/core" + "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" + vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status" +) + +// ServerConfig holds the transport-only runtime configuration for Serve. +// +// It carries the subset of the legacy server.Config that configures the HTTP/SDK +// runtime, plus the cross-cutting TelemetryProvider/AuditConfig that are consumed +// by both the core (core.New) and the transport (Serve) — not a clean partition. +// The fields the core exclusively owns (Aggregator, Router, BackendClient, Authz) +// live on core.Config instead and are absent here. Two collaborators are shared +// rather than core-owned, so they appear on both configs: BackendRegistry (the +// Serve session layer also enumerates backends when creating a session) and the +// composite-tool WorkflowDefs (carried indirectly via SessionManagerConfig, whose +// ComposerFactory closes over the core-owned router/backend client). The +// composition root passes the same BackendRegistry instance to both core.New and +// Serve and assembles SessionManagerConfig. +// +// The name intentionally stutters as server.ServerConfig: it is mandated by the +// New/Serve split, and the package's existing Config is the legacy transport +// config being decomposed, so it cannot be reused for this transport-only type. +// +//nolint:revive // exported-stutter is intentional; see the doc comment above. +type ServerConfig struct { + // Name and Version are exposed in the MCP protocol via the mcp-go server. + Name string + Version string + + // GroupRef is the name of the MCPGroup containing backend workloads, used for + // operational visibility in the status endpoint and logging. + GroupRef string + + // Host is the bind address (default: "127.0.0.1"). + Host string + + // Port is the bind port. A zero value means "let the OS assign a random port". + Port int + + // EndpointPath is the MCP endpoint path (default: "/mcp"). + EndpointPath string + + // SessionTTL is the session time-to-live duration (default: 30 minutes). + SessionTTL time.Duration + + // AuthMiddleware is the optional authentication middleware applied to MCP routes. + // If nil, no authentication is required. + AuthMiddleware func(http.Handler) http.Handler + + // AuthInfoHandler is the optional handler for the + // /.well-known/oauth-protected-resource endpoint. + AuthInfoHandler http.Handler + + // AuthServer is the optional embedded authorization server. When non-nil, its + // routes are registered on the mux alongside the protected resource metadata. + AuthServer *asrunner.EmbeddedAuthServer + + // HealthMonitor is the already-built backend health monitor (constructed at the + // composition root so the same instance's StatusProvider can be injected into the + // core). Serve owns only its Start/Stop lifecycle. Nil disables health monitoring. + HealthMonitor *health.Monitor + + // StatusReportingInterval is the interval for reporting status updates. + // If zero, the default reporting interval is used. + StatusReportingInterval time.Duration + + // StatusReporter enables the vMCP runtime to report operational status. + // If nil, status reporting is disabled. + StatusReporter vmcpstatus.Reporter + + // Watcher is the optional Kubernetes backend watcher for dynamic mode, used by + // the /readyz endpoint to gate readiness on cache sync. + Watcher Watcher + + // BackendRegistry enumerates the configured backends. It is a shared + // collaborator: the core (core.Config.BackendRegistry) consumes it for + // capability aggregation, and the Serve session layer consumes it here — when + // the OnRegisterSession hook fires, sessionmanager.CreateSession lists the + // registry's backends to open the session's backend connections. The + // composition root passes the same instance to both core.New and Serve. + BackendRegistry vmcp.BackendRegistry + + // SessionStorage configures the session storage backend. When nil or provider is + // "memory", local in-process storage is used. + SessionStorage *vmcpconfig.SessionStorageConfig + + // SessionManagerConfig is the pre-built construction config for the vMCP session + // manager (sessionmanager.New). FactoryConfig.Base is the required underlying + // MultiSessionFactory; the config also carries the composite-tool WorkflowDefs and + // ComposerFactory, the optimizer factory/config, and the telemetry provider. + // Required; Serve returns an error when it is nil. The composition root assembles + // it because the ComposerFactory closes over the core-owned router and backend + // client. + // + // Caller responsibility: unlike server.New, Serve does NOT run validateWorkflows on + // FactoryConfig.WorkflowDefs — the composition root must validate composite-tool + // definitions before assembling this config (sessionmanager.New only checks the + // WorkflowDefs/ComposerFactory pairing). This responsibility moves here with the + // relocation and matters when server.New is routed through Serve in Phase 3. + SessionManagerConfig *sessionmanager.FactoryConfig + + // TelemetryProvider is the cross-cutting telemetry provider (also consumed by + // core.New). If nil, no telemetry is recorded. + TelemetryProvider *telemetry.Provider + + // AuditConfig is the cross-cutting audit configuration (also consumed by + // core.New). If nil, no audit logging is performed. + AuditConfig *audit.Config +} + +// Serve is the transport-side entry point of the New/Serve split: it wraps an +// already-constructed core [core.VMCP] and returns the existing *Server. +// +// At this phase Serve constructs the self-contained transport pieces — it applies +// the transport defaults (mirroring server.New), builds the mcp-go server, and wires +// the core's Close into the server's shutdown sequence — plus the SDK session layer: +// the three session hooks and the two-phase session-creation wiring (the transport +// session manager, the session data storage backend, and the vMCP session manager). +// It does NOT build the route mux or the HTTP lifecycle here — those remain in the +// carried-forward (*Server).Handler/Start/Stop, which register the unauthenticated +// routes (/health, /ping, /readyz, /status, /api/backends/health, the metrics and +// .well-known endpoints, and any embedded auth-server routes) when Serve's *Server +// is served. +// +// The remaining transport concerns — the authenticated middleware chain (#5441), the +// direct VMCP request path (#5442), and the AS runner / status reporter / optimizer / +// health monitor lifecycle (#5443) — are relocated under Serve by the subsequent +// Phase 2 tasks. server.New is not yet routed through Serve and keeps its own copy of +// the session wiring until Phase 3, so this is purely additive and observable behavior +// is unchanged. +// +// Serve returns a vmcp.ErrInvalidConfig-wrapped error for a nil cfg, a nil core, or a +// nil required collaborator (SessionManagerConfig or BackendRegistry). The session +// hooks close over the constructed *Server, so they are registered after it is +// assembled. +// +// Contract: the returned *Server now has a live session manager and backend registry, +// but a nil discovery manager, router, and backend client at this phase. It must NOT +// be Start()ed or served on the "/" MCP route until #5441/#5442 wire those fields — +// the carried-forward Handler passes them to discovery.Middleware, which would +// nil-deref. The unauthenticated routes (registered as direct mux entries) are safe +// to serve. +func Serve(ctx context.Context, v core.VMCP, cfg *ServerConfig) (*Server, error) { + if cfg == nil { + return nil, fmt.Errorf("%w: nil server config", vmcp.ErrInvalidConfig) + } + if v == nil { + return nil, fmt.Errorf("%w: VMCP is required", vmcp.ErrInvalidConfig) + } + if cfg.SessionManagerConfig == nil { + return nil, fmt.Errorf("%w: SessionManagerConfig is required", vmcp.ErrInvalidConfig) + } + if cfg.BackendRegistry == nil { + // Required: the OnRegisterSession hook enumerates the registry to open the + // session's backend connections, so a nil registry would nil-panic inside the + // hook goroutine (where the error is swallowed) rather than here. Fail loudly. + return nil, fmt.Errorf("%w: BackendRegistry is required", vmcp.ErrInvalidConfig) + } + + serveCfg := buildServeConfig(cfg) + + // Build the SDK hooks up front so they can be passed to NewMCPServer, but + // register their callbacks after srv is assembled below — the callbacks close + // over srv (relocated from server.New, where the hooks object is co-located with + // the mcp-go server for the same reason). + hooks := &server.Hooks{} + + // Build the mcp-go server (mirrors server.New): tools and resources are + // registered dynamically, so capabilities start disabled. + mcpServer := server.NewMCPServer( + serveCfg.Name, + serveCfg.Version, + server.WithToolCapabilities(false), + server.WithResourceCapabilities(false, false), + server.WithLogging(), + server.WithHooks(hooks), + ) + + // Two-phase session-creation wiring (relocated from server.New). The pluggable + // data storage holds serialisable session metadata (memory or Redis, reading + // THV_SESSION_REDIS_PASSWORD); the vMCP session manager owns the live, node-local + // MultiSessions; the transport session manager (built last, below) holds the + // lightweight Streamable HTTP placeholders. + sessionDataStorage, err := buildSessionDataStorage(ctx, serveCfg) + if err != nil { + return nil, fmt.Errorf("failed to create session data storage: %w", err) + } + // Close sessionDataStorage if Serve returns an error after this point so the + // background cleanup goroutine does not leak (go-style: pair acquisition with + // release). + closeStorageOnErr := true + defer func() { + if closeStorageOnErr { + _ = sessionDataStorage.Close() + } + }() + + // sessionmanager.New takes an interface; avoid passing a typed-nil monitor. + var healthStatus health.StatusProvider + if cfg.HealthMonitor != nil { + healthStatus = cfg.HealthMonitor + } + vmcpSessMgr, optimizerCleanup, err := sessionmanager.New( + sessionDataStorage, cfg.SessionManagerConfig, cfg.BackendRegistry, healthStatus) + if err != nil { + return nil, err + } + + // Built last — after the fallible calls above — because NewManager starts a cleanup + // goroutine on construction (released only by Stop), so an early error return cannot + // leak it (mirroring the closeStorageOnErr guard on the sibling sessionDataStorage). + sessionManager := transportsession.NewManager(serveCfg.SessionTTL, transportsession.NewStreamableSession) + + srv := &Server{ + config: serveCfg, + mcpServer: mcpServer, + backendRegistry: cfg.BackendRegistry, + sessionManager: sessionManager, + sessionDataStorage: sessionDataStorage, + vmcpSessionMgr: vmcpSessMgr, + ready: make(chan struct{}), + healthMonitor: cfg.HealthMonitor, + statusReporter: cfg.StatusReporter, + } + + if optimizerCleanup != nil { + srv.shutdownFuncs = append(srv.shutdownFuncs, optimizerCleanup) + } + + // Serve owns the injected core's lifecycle: release its backend connections + // when the server stops. core.VMCP.Close is idempotent, so this is safe even + // if Stop runs before Start. + srv.shutdownFuncs = append(srv.shutdownFuncs, func(context.Context) error { + return v.Close() + }) + + // Register the SDK hooks against the assembled *Server, matching server.New. + // DocPlanner fork: the fork replaces upstream's lazyInjectSessionTools with + // cross-replica hydration in OnRequestInitialization and tracks live SDK + // sessions for owner forwarding. + hooks.AddOnRequestInitialization(func(hookCtx context.Context, id any, message any) error { + return srv.handleSessionRequestInitialization(hookCtx, id, message) + }) + hooks.AddOnRegisterSession(func(hookCtx context.Context, session server.ClientSession) { + srv.handleSessionRegistration(hookCtx, session) + }) + hooks.AddOnUnregisterSession(func(_ context.Context, session server.ClientSession) { + srv.activeClientSessions.Delete(session.SessionID()) + }) + + // Disarm the close-on-error guard: the Server is fully constructed. + closeStorageOnErr = false + return srv, nil +} + +// buildServeConfig maps the transport-only ServerConfig onto the existing *Config +// the carried-forward (*Server) methods read from, applying the same transport +// defaults server.New applies today. Defaults are applied here rather than by +// mutating the caller's ServerConfig (go-style: copy before mutating caller input). +// Port 0 is left untouched to mean "OS-assigned". +// +// Several Config fields are deliberately NOT mapped at this phase (see +// TestBuildServeConfigMapsSharedFields, which guards this list against drift): +// - AuthzMiddleware: the authenticated/authz middleware chain is relocated under +// Serve by #5441, after which authorization moves to the core admission seam. +// - HealthMonitorConfig: Serve receives the already-built *health.Monitor via +// ServerConfig.HealthMonitor (A2) and assigns it to the Server directly, so it +// never needs the monitor's construction config. +// - StatusReporter: Serve assigns ServerConfig.StatusReporter to the Server field +// directly (like HealthMonitor); nothing reads Config.StatusReporter on the Serve +// path, so mapping it here would be dead. +// - SessionFactory, OptimizerFactory, OptimizerConfig: the vMCP session manager is +// built directly in Serve from ServerConfig.SessionManagerConfig (a pre-built +// *sessionmanager.FactoryConfig that carries the session factory and optimizer +// wiring), not via Config→New, so these Config fields are unused on the Serve path. +func buildServeConfig(cfg *ServerConfig) *Config { + return &Config{ + Name: cmp.Or(cfg.Name, defaultServerName), + Version: cmp.Or(cfg.Version, defaultServerVersion), + GroupRef: cfg.GroupRef, + Host: cmp.Or(cfg.Host, defaultHost), + Port: cfg.Port, + EndpointPath: cmp.Or(cfg.EndpointPath, defaultEndpointPath), + SessionTTL: cmp.Or(cfg.SessionTTL, defaultSessionTTL), + AuthMiddleware: cfg.AuthMiddleware, + AuthInfoHandler: cfg.AuthInfoHandler, + AuthServer: cfg.AuthServer, + TelemetryProvider: cfg.TelemetryProvider, + AuditConfig: cfg.AuditConfig, + StatusReportingInterval: cfg.StatusReportingInterval, + Watcher: cfg.Watcher, + SessionStorage: cfg.SessionStorage, + } +} diff --git a/pkg/vmcp/server/serve_session_test.go b/pkg/vmcp/server/serve_session_test.go new file mode 100644 index 0000000000..28216e0deb --- /dev/null +++ b/pkg/vmcp/server/serve_session_test.go @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/auth" + transportsession "github.com/stacklok/toolhive/pkg/transport/session" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" + vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" + sessionfactorymocks "github.com/stacklok/toolhive/pkg/vmcp/session/mocks" + sessionmocks "github.com/stacklok/toolhive/pkg/vmcp/session/types/mocks" +) + +// These tests exercise the session-creation wiring and SDK hooks relocated into +// Serve (#5440). They drive the SDK session lifecycle through the relocated +// vmcpSessionMgr + mcpServer directly, mounting the Streamable HTTP server WITHOUT +// the authenticated discovery middleware (relocated by #5441/#5442). That keeps the +// test within this task's scope while proving the hooks fire and two-phase session +// creation runs identically when Serve is exercised directly. The full HTTP suite +// stays on server.New (its parity gate) in session_management_integration_test.go. + +// toolSessionState tracks observable behaviour of the mock session factory below. +type toolSessionState struct { + makeWithIDCalled atomic.Bool +} + +// newToolSessionFactory creates a MockMultiSessionFactory whose MakeSessionWithID +// returns a MockMultiSession advertising the given tools, mirroring the proven mock +// in session_management_integration_test.go so GetAdaptedTools produces SDK tools. +func newToolSessionFactory( + t *testing.T, ctrl *gomock.Controller, tools []vmcp.Tool, +) (*sessionfactorymocks.MockMultiSessionFactory, *toolSessionState) { + t.Helper() + state := &toolSessionState{} + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + state.makeWithIDCalled.Store(true) + mock := sessionmocks.NewMockMultiSession(ctrl) + mock.EXPECT().ID().Return(id).AnyTimes() + mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes() + mock.EXPECT().CreatedAt().Return(time.Time{}).AnyTimes() + mock.EXPECT().Type().Return(transportsession.SessionType("")).AnyTimes() + mock.EXPECT().GetData().Return(nil).AnyTimes() + mock.EXPECT().SetData(gomock.Any()).AnyTimes() + // Identity binding so Terminate takes the storage.Delete path; the sentinel + // value is sufficient for tests that do not validate the binding content. + mock.EXPECT().GetMetadata().Return(map[string]string{ + vmcpsession.MetadataKeyIdentityBinding: "unauthenticated", + }).AnyTimes() + mock.EXPECT().SetMetadata(gomock.Any(), gomock.Any()).AnyTimes() + toolsCopy := make([]vmcp.Tool, len(tools)) + copy(toolsCopy, tools) + mock.EXPECT().Tools().Return(toolsCopy).AnyTimes() + mock.EXPECT().AllTools().Return(toolsCopy).AnyTimes() + mock.EXPECT().Resources().Return(nil).AnyTimes() + mock.EXPECT().Prompts().Return(nil).AnyTimes() + mock.EXPECT().BackendSessions().Return(nil).AnyTimes() + rt := &vmcp.RoutingTable{Tools: make(map[string]*vmcp.BackendTarget, len(tools))} + for _, tool := range tools { + rt.Tools[tool.Name] = &vmcp.BackendTarget{WorkloadID: tool.Name} + } + mock.EXPECT().GetRoutingTable().Return(rt).AnyTimes() + mock.EXPECT().ReadResource(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mock.EXPECT().GetPrompt(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mock.EXPECT().CallTool(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&vmcp.ToolCallResult{Content: []vmcp.Content{{Type: "text", Text: "ok"}}}, nil).AnyTimes() + mock.EXPECT().Close().Return(nil).AnyTimes() + return mock, nil + }).AnyTimes() + return factory, state +} + +// TestServeRegistersSessionHooks verifies that Serve registers the OnRegisterSession +// hook and wires two-phase session creation: an MCP initialize triggers the hook, +// which creates the session (MakeSessionWithID) and injects its tools, so a +// subsequent tools/list advertises them. The OnBeforeListTools hook also runs (and +// no-ops, since the tools are already present on this pod). +func TestServeRegistersSessionHooks(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + testTool := vmcp.Tool{Name: "serve-tool", Description: "a relocated-wiring test tool"} + factory, state := newToolSessionFactory(t, ctrl, []vmcp.Tool{testTool}) + + srv, err := Serve(context.Background(), &stubVMCP{}, &ServerConfig{ + SessionManagerConfig: &sessionmanager.FactoryConfig{Base: factory}, + BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{}), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + + // Mount the Streamable HTTP server on the relocated mcpServer + vmcpSessionMgr, + // bypassing the not-yet-relocated discovery middleware (#5441/#5442). + streamable := server.NewStreamableHTTPServer( + srv.mcpServer, + server.WithEndpointPath("/mcp"), + server.WithSessionIdManager(srv.vmcpSessionMgr), + ) + ts := httptest.NewServer(streamable) + t.Cleanup(ts.Close) + + // initialize → fires OnRegisterSession → CreateSession → tool injection. + initResp := postServeMCP(t, ts.URL, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "1.0"}, + }, + }, "") + defer initResp.Body.Close() + require.Equal(t, http.StatusOK, initResp.StatusCode, "initialize should succeed") + + sessionID := initResp.Header.Get("Mcp-Session-Id") + require.NotEmpty(t, sessionID, "session ID should be returned in Mcp-Session-Id header") + + // The OnRegisterSession hook may complete asynchronously after the response. + require.Eventually(t, state.makeWithIDCalled.Load, 2*time.Second, 10*time.Millisecond, + "OnRegisterSession hook should have created the session via MakeSessionWithID") + + // tools/list runs the OnBeforeListTools hook and returns the per-session tools + // injected during registration. + listResp := postServeMCP(t, ts.URL, map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": map[string]any{}, + }, sessionID) + defer listResp.Body.Close() + + body, err := io.ReadAll(listResp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listResp.StatusCode, "tools/list should succeed; body: %s", string(body)) + assert.Contains(t, string(body), testTool.Name, + "tools/list should advertise the tool injected by the OnRegisterSession hook") +} + +// fakeSDKSession is a minimal server.ClientSession + server.SessionWithTools used to +// drive lazyInjectSessionTools directly (the SDK's session-context plumbing is otherwise +// only reachable over HTTP, via MCPServer.WithContext). Its tool store is the observable +// surface the injection asserts against. +type fakeSDKSession struct { + id string + tools map[string]server.ServerTool +} + +var ( + _ server.ClientSession = (*fakeSDKSession)(nil) + _ server.SessionWithTools = (*fakeSDKSession)(nil) +) + +func (f *fakeSDKSession) SessionID() string { return f.id } +func (*fakeSDKSession) Initialize() {} +func (*fakeSDKSession) Initialized() bool { return true } +func (*fakeSDKSession) NotificationChannel() chan<- mcp.JSONRPCNotification { return nil } +func (f *fakeSDKSession) GetSessionTools() map[string]server.ServerTool { return f.tools } +func (f *fakeSDKSession) SetSessionTools(t map[string]server.ServerTool) { f.tools = t } + +// TestBuildSessionDataStorage verifies provider selection: nil/empty/"memory" +// (case-insensitive) yields in-process storage; any unknown provider is rejected. +// The "redis" path is covered separately by TestBuildSessionDataStorageRedis, which +// cannot be parallel (it uses t.Setenv). +func TestBuildSessionDataStorage(t *testing.T) { + t.Parallel() + + t.Run("in-process providers", func(t *testing.T) { + t.Parallel() + cases := []struct { + name string + storage *vmcpconfig.SessionStorageConfig + }{ + {"nil config", nil}, + {"empty provider", &vmcpconfig.SessionStorageConfig{Provider: ""}}, + {"memory provider", &vmcpconfig.SessionStorageConfig{Provider: "memory"}}, + {"mixed-case memory provider", &vmcpconfig.SessionStorageConfig{Provider: "Memory"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ds, err := buildSessionDataStorage(context.Background(), &Config{ + SessionTTL: time.Minute, + SessionStorage: tc.storage, + }) + require.NoError(t, err) + require.NotNil(t, ds) + assert.IsType(t, &transportsession.LocalSessionDataStorage{}, ds) + t.Cleanup(func() { _ = ds.Close() }) + }) + } + }) + + t.Run("unsupported provider is rejected", func(t *testing.T) { + t.Parallel() + ds, err := buildSessionDataStorage(context.Background(), &Config{ + SessionTTL: time.Minute, + SessionStorage: &vmcpconfig.SessionStorageConfig{Provider: "postgres"}, + }) + require.Error(t, err) + assert.Nil(t, ds) + assert.ErrorContains(t, err, "unsupported session storage provider") + }) +} + +// TestBuildSessionDataStorageRedis verifies the "redis" provider takes the Redis path +// (reading THV_SESSION_REDIS_PASSWORD). It is a separate, non-parallel test because +// t.Setenv is incompatible with parallel tests (and any parallel ancestor). +func TestBuildSessionDataStorageRedis(t *testing.T) { + t.Setenv(vmcpconfig.RedisPasswordEnvVar, "test-password") + // Unreachable address so the construction-time Ping fails fast, proving the redis + // branch was taken (in-process storage would not error here). + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + ds, err := buildSessionDataStorage(ctx, &Config{ + SessionTTL: time.Minute, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: "127.0.0.1:1", + }, + }) + require.Error(t, err) + assert.Nil(t, ds) + // Bind to the Redis connection-failure wrap so a future config/unsupported-provider + // error can't satisfy this test. ("redis" alone is unsuitable — the unsupported- + // provider error text also lists "redis".) + assert.ErrorContains(t, err, "redis: failed to connect") +} + +// postServeMCP sends a JSON-RPC POST to the given Streamable HTTP base URL. It is the +// package-server-internal analogue of the postMCP helper in the external suite. +func postServeMCP(t *testing.T, baseURL string, body map[string]any, sessionID string) *http.Response { + t.Helper() + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, baseURL+"/mcp", bytes.NewReader(rawBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} diff --git a/pkg/vmcp/server/serve_test.go b/pkg/vmcp/server/serve_test.go new file mode 100644 index 0000000000..e770ec9d81 --- /dev/null +++ b/pkg/vmcp/server/serve_test.go @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth" + asrunner "github.com/stacklok/toolhive/pkg/authserver/runner" + "github.com/stacklok/toolhive/pkg/telemetry" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/core" + "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" +) + +// stubVMCP is a no-op core.VMCP for Serve tests. The Serve skeleton never invokes +// its capability methods; Close records invocation so the shutdown wiring can be +// asserted. +type stubVMCP struct { + closed bool +} + +var _ core.VMCP = (*stubVMCP)(nil) + +func (*stubVMCP) ListTools(context.Context, *auth.Identity) ([]vmcp.Tool, error) { return nil, nil } +func (*stubVMCP) CallTool( + context.Context, *auth.Identity, string, map[string]any, map[string]any, +) (*vmcp.ToolCallResult, error) { + return nil, nil +} +func (*stubVMCP) ListResources(context.Context, *auth.Identity) ([]vmcp.Resource, error) { + return nil, nil +} +func (*stubVMCP) ReadResource(context.Context, *auth.Identity, string) (*vmcp.ResourceReadResult, error) { + return nil, nil +} +func (*stubVMCP) ListPrompts(context.Context, *auth.Identity) ([]vmcp.Prompt, error) { return nil, nil } +func (*stubVMCP) GetPrompt( + context.Context, *auth.Identity, string, map[string]any, +) (*vmcp.PromptGetResult, error) { + return nil, nil +} +func (*stubVMCP) LookupTool(context.Context, *auth.Identity, string) (*vmcp.Tool, error) { + return nil, nil +} +func (*stubVMCP) LookupResource(context.Context, *auth.Identity, string) (*vmcp.Resource, error) { + return nil, nil +} +func (*stubVMCP) LookupPrompt(context.Context, *auth.Identity, string) (*vmcp.Prompt, error) { + return nil, nil +} +func (s *stubVMCP) Close() error { s.closed = true; return nil } + +// stubWatcher is a non-nil Watcher for the drift-guard test; its behavior is not +// exercised there. +type stubWatcher struct{} + +func (stubWatcher) WaitForCacheSync(context.Context) bool { return true } + +// stubServeReporter is a non-nil vmcpstatus.Reporter for the drift-guard test. +type stubServeReporter struct{} + +func (stubServeReporter) ReportStatus(context.Context, *vmcp.Status) error { return nil } +func (stubServeReporter) Start(context.Context) (func(context.Context) error, error) { + return func(context.Context) error { return nil }, nil +} + +// testMinimalSessionManagerConfig returns a minimal valid SessionManagerConfig for +// Serve tests that need a non-nil session-manager config but do not exercise session +// creation. Base is the only required FactoryConfig field; the minimal factory's +// MakeSessionWithID returns an error if a test accidentally triggers registration. +func testMinimalSessionManagerConfig() *sessionmanager.FactoryConfig { + return &sessionmanager.FactoryConfig{Base: testMinimalFactory()} +} + +// testMinimalServeConfig returns a minimal valid ServerConfig for Serve tests that do +// not exercise session creation: a non-nil SessionManagerConfig and an empty +// BackendRegistry, the two required collaborators Serve validates. +func testMinimalServeConfig() *ServerConfig { + return &ServerConfig{ + SessionManagerConfig: testMinimalSessionManagerConfig(), + BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{}), + } +} + +func TestServeAppliesTransportDefaults(t *testing.T) { + t.Parallel() + + // Empty transport fields exercise every default; Port is left zero. + cfg := testMinimalServeConfig() + + srv, err := Serve(context.Background(), &stubVMCP{}, cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + require.NotNil(t, srv) + require.NotNil(t, srv.MCPServer()) + + // Defaults mirror server.New and are applied to the server's own config, + // leaving the caller's ServerConfig untouched. Asserting against the shared + // consts (not literals) keeps this test from being a third copy that can drift. + assert.Equal(t, defaultServerName, srv.config.Name) + assert.Equal(t, defaultServerVersion, srv.config.Version) + assert.Equal(t, defaultHost, srv.config.Host) + assert.Equal(t, defaultEndpointPath, srv.config.EndpointPath) + assert.Equal(t, defaultSessionTTL, srv.config.SessionTTL) + assert.Equal(t, 0, srv.config.Port) // Port 0 => OS-assigned + + assert.Empty(t, cfg.Host, "caller config must not be mutated") + + // Address reflects the defaulted host and unassigned port (no listener yet). + assert.Equal(t, defaultHost+":0", srv.Address()) +} + +func TestServePreservesExplicitConfig(t *testing.T) { + t.Parallel() + + // Distinct values per scalar field so a wrong-source mapping (e.g. Host: + // cfg.GroupRef) is caught here — this test carries value-correctness for the + // pass-through scalars that the presence-only drift guard cannot. + cfg := &ServerConfig{ + Name: "custom", + Version: "9.9.9", + Host: "0.0.0.0", + Port: 8080, + EndpointPath: "/rpc", + GroupRef: "my-group", + SessionTTL: 7 * time.Minute, + StatusReportingInterval: 11 * time.Second, + SessionManagerConfig: testMinimalSessionManagerConfig(), + BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{}), + } + + srv, err := Serve(context.Background(), &stubVMCP{}, cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + + assert.Equal(t, "custom", srv.config.Name) + assert.Equal(t, "9.9.9", srv.config.Version) + assert.Equal(t, "0.0.0.0", srv.config.Host) + assert.Equal(t, 8080, srv.config.Port) + assert.Equal(t, "/rpc", srv.config.EndpointPath) + assert.Equal(t, "my-group", srv.config.GroupRef) + assert.Equal(t, 7*time.Minute, srv.config.SessionTTL) + assert.Equal(t, 11*time.Second, srv.config.StatusReportingInterval) +} + +func TestServeHandlerRegistersUnauthenticatedRoutes(t *testing.T) { + t.Parallel() + + cfg := testMinimalServeConfig() + srv, err := Serve(context.Background(), &stubVMCP{}, cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + + handler, err := srv.Handler(context.Background()) + require.NoError(t, err) + + // These routes are registered as direct mux entries, so they bypass the + // authenticated middleware chain that Handler still builds and mounts at "/" + // (the chain itself is relocated under Serve by a later phase). + for _, path := range []string{"/health", "/ping", "/readyz", "/status", "/api/backends/health"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equalf(t, http.StatusOK, rec.Code, "route %s", path) + } + + // .well-known is always registered; with no AuthInfoHandler it returns a clean + // JSON 404, distinct from the catch-all MCP handler's 406 on a bare GET. + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "application/json") + + // /metrics is only registered when a telemetry provider with a Prometheus + // handler is configured; absent here the request falls through to the catch-all + // MCP handler and must not return 200. + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.NotEqual(t, http.StatusOK, rec.Code) +} + +func TestServeHandlerRegistersMetricsWhenTelemetryEnabled(t *testing.T) { + t.Parallel() + + ctx := context.Background() + provider, err := telemetry.NewProvider(ctx, telemetry.Config{ + ServiceName: "vmcp-serve-test", + ServiceVersion: "0.0.0", + EnablePrometheusMetricsPath: true, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = provider.Shutdown(ctx) }) + + srv, err := Serve(ctx, &stubVMCP{}, &ServerConfig{ + SessionManagerConfig: testMinimalSessionManagerConfig(), + BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{}), + TelemetryProvider: provider, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + + handler, err := srv.Handler(ctx) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestServeStopClosesCore(t *testing.T) { + t.Parallel() + + stub := &stubVMCP{} + srv, err := Serve(context.Background(), stub, testMinimalServeConfig()) + require.NoError(t, err) + + // Stop on a never-started server still runs the shutdown funcs, which release + // the injected core. + require.NoError(t, srv.Stop(context.Background())) + assert.True(t, stub.closed, "Serve must wire core.Close into the shutdown sequence") +} + +func TestServeValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + v core.VMCP + cfg *ServerConfig + }{ + { + name: "nil config", + v: &stubVMCP{}, + cfg: nil, + }, + { + name: "nil vmcp", + v: nil, + cfg: testMinimalServeConfig(), + }, + { + name: "nil session manager config", + v: &stubVMCP{}, + cfg: &ServerConfig{BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{})}, + }, + { + name: "nil backend registry", + v: &stubVMCP{}, + cfg: &ServerConfig{SessionManagerConfig: testMinimalSessionManagerConfig()}, + }, + { + // Both nil: cfg is checked first, so this must fail cleanly (no panic + // from dereferencing the nil cfg) rather than depending on check order. + name: "nil config and nil vmcp", + v: nil, + cfg: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, err := Serve(context.Background(), tc.v, tc.cfg) + require.Error(t, err) + assert.ErrorIs(t, err, vmcp.ErrInvalidConfig) + assert.Nil(t, srv) + }) + } +} + +// TestBuildServeConfigMapsSharedFields guards the ServerConfig -> Config mapping +// against silent drift: every Config field except the documented intentionally- +// unmapped set must be populated by buildServeConfig. If a future field is added to +// Config and forgotten in buildServeConfig, this test fails (the field is zero). +// When a field is deliberately not mapped, add it to intentionallyUnmapped with a +// reason, mirroring the buildServeConfig doc comment. +// +// This is a PRESENCE check only — with every source field non-zero it cannot catch a +// wrong-source mapping or default-value drift. Value correctness is carried by +// TestServePreservesExplicitConfig (pass-through scalars) and +// TestServeAppliesTransportDefaults (the cmp.Or defaults). +func TestBuildServeConfigMapsSharedFields(t *testing.T) { + t.Parallel() + + intentionallyUnmapped := map[string]struct{}{ + "AuthzMiddleware": {}, // authenticated/authz chain relocated by #5441 + "HealthMonitorConfig": {}, // monitor injected pre-built via ServerConfig.HealthMonitor (A2) + "StatusReporter": {}, // set directly on Server; Config.StatusReporter only read by New + "SessionFactory": {}, // session manager built in Serve from ServerConfig.SessionManagerConfig + "OptimizerFactory": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) + "OptimizerConfig": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) + } + + // Every field set to a non-zero value so a dropped mapping surfaces as a zero + // field on the resulting Config. SessionManagerConfig and BackendRegistry are + // ServerConfig-only (consumed directly by Serve, not mapped into Config), so they + // are set for completeness but are not part of this destination-field assertion. + src := &ServerConfig{ + Name: "n", + Version: "v", + GroupRef: "g", + Host: "h", + Port: 1, + EndpointPath: "/e", + SessionTTL: time.Second, + AuthMiddleware: func(h http.Handler) http.Handler { return h }, + AuthInfoHandler: http.NewServeMux(), + AuthServer: &asrunner.EmbeddedAuthServer{}, + HealthMonitor: &health.Monitor{}, + StatusReportingInterval: time.Second, + StatusReporter: stubServeReporter{}, + Watcher: stubWatcher{}, + BackendRegistry: vmcp.NewImmutableRegistry([]vmcp.Backend{}), + SessionStorage: &vmcpconfig.SessionStorageConfig{}, + SessionManagerConfig: testMinimalSessionManagerConfig(), + TelemetryProvider: &telemetry.Provider{}, + AuditConfig: &audit.Config{}, + } + + got := reflect.ValueOf(*buildServeConfig(src)) + gotType := got.Type() + for i := range gotType.NumField() { + name := gotType.Field(i).Name + if _, skip := intentionallyUnmapped[name]; skip { + continue + } + assert.Falsef(t, got.Field(i).IsZero(), "Config.%s was not populated by buildServeConfig", name) + } +} diff --git a/pkg/vmcp/server/server.go b/pkg/vmcp/server/server.go index e48c4789f4..6ee4af99c8 100644 --- a/pkg/vmcp/server/server.go +++ b/pkg/vmcp/server/server.go @@ -27,6 +27,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + tcredis "github.com/stacklok/toolhive-core/redis" "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" asrunner "github.com/stacklok/toolhive/pkg/authserver/runner" @@ -40,6 +41,7 @@ import ( vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/discovery" "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/internal/backendtelemetry" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" "github.com/stacklok/toolhive/pkg/vmcp/router" "github.com/stacklok/toolhive/pkg/vmcp/server/adapter" @@ -118,6 +120,12 @@ const ( sessionOwnerForwardedHeader = "X-ToolHive-Session-Forwarded" sessionOwnerForwardedValue = "1" + // Transport defaults shared by New and Serve (buildServeConfig) so the two + // entry points cannot drift while New is not yet routed through Serve. + defaultServerName = "toolhive-vmcp" + defaultServerVersion = "0.1.0" + defaultHost = "127.0.0.1" + defaultEndpointPath = "/mcp" ) var errSessionOwnerUnavailable = errors.New("session owner unavailable") @@ -368,19 +376,20 @@ func buildSessionDataStorage(ctx context.Context, cfg *Config) (transportsession if keyPrefix == "" { keyPrefix = "thv:vmcp:session:" } - redisCfg := transportsession.RedisConfig{ - Addr: cfg.SessionStorage.Address, - Username: os.Getenv(vmcpconfig.RedisUsernameEnvVar), - Password: os.Getenv(vmcpconfig.RedisPasswordEnvVar), - DB: int(cfg.SessionStorage.DB), - KeyPrefix: keyPrefix, + // toolhive-core's client config has no username source of its own; the + // operator injects the ACL username from sessionStorage.usernameRef. + redisCfg := tcredis.Config{ + Addr: cfg.SessionStorage.Address, + Username: os.Getenv(vmcpconfig.RedisUsernameEnvVar), + Password: os.Getenv(vmcpconfig.RedisPasswordEnvVar), + DB: int(cfg.SessionStorage.DB), } slog.Info("using Redis session storage", "address", cfg.SessionStorage.Address, "db", cfg.SessionStorage.DB, "key_prefix", keyPrefix, ) - return transportsession.NewRedisSessionDataStorage(ctx, redisCfg, cfg.SessionTTL) + return transportsession.NewRedisSessionDataStorage(ctx, redisCfg, keyPrefix, cfg.SessionTTL) } type sessionRegistrationState struct { @@ -416,18 +425,18 @@ func New( ) (*Server, error) { // Apply defaults if cfg.Host == "" { - cfg.Host = "127.0.0.1" + cfg.Host = defaultHost } // Note: Port 0 means "let OS assign random port" - intentionally no default applied here. // CLI provides default via flag (4483), so Port is only 0 in tests for dynamic port assignment. if cfg.EndpointPath == "" { - cfg.EndpointPath = "/mcp" + cfg.EndpointPath = defaultEndpointPath } if cfg.Name == "" { - cfg.Name = "toolhive-vmcp" + cfg.Name = defaultServerName } if cfg.Version == "" { - cfg.Version = "0.1.0" + cfg.Version = defaultServerVersion } if cfg.SessionTTL == 0 { cfg.SessionTTL = defaultSessionTTL @@ -452,7 +461,7 @@ func New( // Create SDK elicitation adapter for workflow engine // This wraps the mark3labs SDK to provide elicitation functionality to the composer - sdkElicitationRequester := newSDKElicitationAdapter(mcpServer) + sdkElicitationRequester := NewSDKElicitationAdapter(mcpServer) // Create elicitation handler for workflow engine // This provides SDK-agnostic elicitation with security validation @@ -465,7 +474,7 @@ func New( var err error // Get initial backends list from registry for telemetry setup initialBackends := backendRegistry.List(ctx) - backendClient, err = monitorBackends( + backendClient, err = backendtelemetry.MonitorBackends( ctx, cfg.TelemetryProvider.MeterProvider(), cfg.TelemetryProvider.TracerProvider(), @@ -1135,6 +1144,43 @@ func (s *Server) SessionManager() *transportsession.Manager { return s.sessionManager } +// MCPServer returns the underlying mark3labs *server.MCPServer instance +// servicing this vMCP server's /mcp endpoint. +// +// Intended for embedders that wrap the vMCP composer in their own pipeline and +// need to drive SDK-level operations (such as RequestElicitation) against the +// same server that handles incoming client traffic. A parallel MCPServer +// constructed by the embedder will not work: ClientSession correlation is +// keyed to the server that received the initialize request. +// +// Trust boundary: this accessor is in-process only; the returned pointer is +// the same instance for the lifetime of the Server and is safe for concurrent +// use per mark3labs guarantees. +// +// Safe operations include RequestElicitation against an active session, +// registering observability hooks, and reading registered +// tools/resources/prompts. Callers MUST NOT call shutdown/close or alter the +// serving lifecycle; that is owned by (*Server).Start and (*Server).Stop. +// Callers SHOULD prefer per-session tool registration over global +// AddTool/SetTools: globally registered tools are served to every session and +// bypass vMCP's per-session capability scoping (they still pass through the +// HTTP auth/authz chain). +// +// Elicitation callers must: +// - Propagate the inbound request ctx so ClientSession resolves, and pass a +// bounded deadline; RequestElicitation blocks on the remote client. +// - Ensure the connected client advertised the "elicitation" capability +// during initialize. +// - Handle accept / decline / cancel responses distinctly per MCP 2025-06-18; +// keep requestedSchema as a flat object of primitives (string / number / +// integer / boolean / enum), which is what conforming clients accept. +// - Never include secrets, credentials, tokens, or internal addressing +// (backend IDs, pod names, routing-table entries) in the prompt message +// or schema: elicitation payloads are surfaced to end-user clients. +func (s *Server) MCPServer() *server.MCPServer { + return s.mcpServer +} + // Ready returns a channel that is closed when the server is ready to accept connections. // This is useful for testing and synchronization. func (s *Server) Ready() <-chan struct{} { @@ -2217,7 +2263,7 @@ func (s *Server) targetSessionsForRefresh( } func canRebuildInBackground(sess sessiontypes.MultiSession) bool { - if sess.GetMetadata()[sessiontypes.MetadataKeyTokenHash] == "" { + if !sessiontypes.HasBoundIdentity(sess.GetMetadata()) { return true } provider, ok := sess.(sessiontypes.CreatorIdentityProvider) diff --git a/pkg/vmcp/server/session_management_integration_test.go b/pkg/vmcp/server/session_management_integration_test.go index 4a8c930d81..3b1eb6eba4 100644 --- a/pkg/vmcp/server/session_management_integration_test.go +++ b/pkg/vmcp/server/session_management_integration_test.go @@ -48,8 +48,8 @@ func newNoopMockFactory(t *testing.T) *sessionfactorymocks.MockMultiSessionFacto t.Helper() ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) - factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { mock := sessionmocks.NewMockMultiSession(ctrl) mock.EXPECT().ID().Return(id).AnyTimes() mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes() @@ -90,13 +90,12 @@ func newMockFactory(t *testing.T, ctrl *gomock.Controller, tools []vmcp.Tool) (* t.Helper() state := &mockFactoryState{} factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) - factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, identity *auth.Identity, allowAnonymous bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { state.makeWithIDCalled.Store(true) - tokenHash := "" - if identity != nil && identity.Token != "" && !allowAnonymous { - tokenHash = "fake-hash-for-testing" - } + // All sessions carry MetadataKeyIdentityBinding so Terminate takes the + // Phase 2 (storage.Delete) path. The sentinel value is sufficient for + // tests that don't validate the binding content. mock := sessionmocks.NewMockMultiSession(ctrl) mock.EXPECT().ID().Return(id).AnyTimes() mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes() @@ -105,7 +104,7 @@ func newMockFactory(t *testing.T, ctrl *gomock.Controller, tools []vmcp.Tool) (* mock.EXPECT().GetData().Return(nil).AnyTimes() mock.EXPECT().SetData(gomock.Any()).AnyTimes() mock.EXPECT().GetMetadata().Return(map[string]string{ - vmcpsession.MetadataKeyTokenHash: tokenHash, + vmcpsession.MetadataKeyIdentityBinding: "unauthenticated", }).AnyTimes() mock.EXPECT().SetMetadata(gomock.Any(), gomock.Any()).AnyTimes() toolsCopy := make([]vmcp.Tool, len(tools)) diff --git a/pkg/vmcp/server/session_telemetry.go b/pkg/vmcp/server/session_telemetry.go new file mode 100644 index 0000000000..e8b02e3f21 --- /dev/null +++ b/pkg/vmcp/server/session_telemetry.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/metric" +) + +// sessionTelemetryInstrumentationName matches the vMCP backend telemetry meter. +const sessionTelemetryInstrumentationName = "github.com/stacklok/toolhive/pkg/vmcp" + +// monitorSessionCardinality registers observable gauges for the main vMCP +// session layers so operators can distinguish transport/session churn from +// live SDK-session growth and post-initialize registration backlog. +func monitorSessionCardinality( + meterProvider metric.MeterProvider, + srv *Server, +) error { + meter := meterProvider.Meter(sessionTelemetryInstrumentationName) + + activeTransportSessions, err := meter.Int64ObservableGauge( + "toolhive_vmcp_active_transport_sessions", + metric.WithDescription("Number of active transport-layer MCP sessions tracked by the vMCP"), + ) + if err != nil { + return fmt.Errorf("failed to create active transport sessions gauge: %w", err) + } + + activeSDKSessions, err := meter.Int64ObservableGauge( + "toolhive_vmcp_active_sdk_sessions", + metric.WithDescription("Number of live SDK client sessions currently registered on this vMCP replica"), + ) + if err != nil { + return fmt.Errorf("failed to create active SDK sessions gauge: %w", err) + } + + inFlightRegistrations, err := meter.Int64ObservableGauge( + "toolhive_vmcp_inflight_session_registrations", + metric.WithDescription("Number of session registrations currently executing the post-initialize capability injection hook"), + ) + if err != nil { + return fmt.Errorf("failed to create in-flight session registrations gauge: %w", err) + } + + _, err = meter.RegisterCallback( + func(_ context.Context, observer metric.Observer) error { + observer.ObserveInt64(activeTransportSessions, int64(srv.sessionManager.Count())) + observer.ObserveInt64(activeSDKSessions, srv.activeClientSessionCount()) + observer.ObserveInt64(inFlightRegistrations, srv.inFlightRegistrationCount()) + return nil + }, + activeTransportSessions, + activeSDKSessions, + inFlightRegistrations, + ) + if err != nil { + return fmt.Errorf("failed to register session cardinality callback: %w", err) + } + + return nil +} + +func (s *Server) activeClientSessionCount() int64 { + var count int64 + s.activeClientSessions.Range(func(_, _ any) bool { + count++ + return true + }) + return count +} + +func (s *Server) inFlightRegistrationCount() int64 { + var count int64 + s.inFlightRegistrations.Range(func(_, _ any) bool { + count++ + return true + }) + return count +} diff --git a/pkg/vmcp/server/telemetry_test.go b/pkg/vmcp/server/session_telemetry_test.go similarity index 100% rename from pkg/vmcp/server/telemetry_test.go rename to pkg/vmcp/server/session_telemetry_test.go diff --git a/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go b/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go index d0b957d954..20f28a8b6e 100644 --- a/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go +++ b/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + tcredis "github.com/stacklok/toolhive-core/redis" "github.com/stacklok/toolhive/pkg/auth" transportsession "github.com/stacklok/toolhive/pkg/transport/session" "github.com/stacklok/toolhive/pkg/vmcp" @@ -27,9 +28,6 @@ import ( sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" ) -// hmacSecret is a fixed 32-byte secret used across all integration tests. -var hmacSecret = []byte("test-hmac-secret-32bytes-exactly") - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -50,10 +48,10 @@ func newSharedRedisStorage(t *testing.T, mr *miniredis.Miniredis) transportsessi t.Helper() storage, err := transportsession.NewRedisSessionDataStorage( context.Background(), - transportsession.RedisConfig{ - Addr: mr.Addr(), - KeyPrefix: "test:vmcp:session:", + tcredis.Config{ + Addr: mr.Addr(), }, + "test:vmcp:session:", time.Hour, ) require.NoError(t, err) @@ -62,9 +60,8 @@ func newSharedRedisStorage(t *testing.T, mr *miniredis.Miniredis) transportsessi } // newTestManagerWithSharedStorage creates a Manager backed by the given -// DataStorage, a real session factory with the package-level hmacSecret, and -// an ImmutableRegistry containing backends. Cleanup is registered via -// t.Cleanup. +// DataStorage, a real session factory, and an ImmutableRegistry containing +// backends. Cleanup is registered via t.Cleanup. func newTestManagerWithSharedStorage(t *testing.T, storage transportsession.DataStorage, backends []*vmcp.Backend) *Manager { t.Helper() backendList := make([]vmcp.Backend, len(backends)) @@ -74,7 +71,6 @@ func newTestManagerWithSharedStorage(t *testing.T, storage transportsession.Data registry := vmcp.NewImmutableRegistry(backendList) factory := vmcpsession.NewSessionFactory( newUnauthenticatedAuthRegistry(t), - vmcpsession.WithHMACSecret(hmacSecret), ) sm, cleanup, err := New(storage, &FactoryConfig{Base: factory}, registry, nil) require.NoError(t, err) @@ -214,13 +210,26 @@ func TestHorizontalScaling_CrossPodHijackPrevention(t *testing.T) { storage := newSharedRedisStorage(t, mr) backend := startMCPBackend(t, "backend-alpha", "echo") + // Both alice and eve need Claims with iss+sub so the identity-binding + // decorator can extract their (iss, sub) pairs (Token is not used for binding + // in the #5306 model; Claims are the canonical source). identity := &auth.Identity{ - PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, - Token: "alice-bearer-token", + PrincipalInfo: auth.PrincipalInfo{ + Subject: "alice", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }, + }, } wrongCaller := &auth.Identity{ - PrincipalInfo: auth.PrincipalInfo{Subject: "eve"}, - Token: "eve-bearer-token", + PrincipalInfo: auth.PrincipalInfo{ + Subject: "eve", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "eve", + }, + }, } // Pod A: create session bound to alice. diff --git a/pkg/vmcp/server/sessionmanager/session_manager.go b/pkg/vmcp/server/sessionmanager/session_manager.go index 1f13b30993..eb468d46f6 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager.go +++ b/pkg/vmcp/server/sessionmanager/session_manager.go @@ -319,18 +319,11 @@ func (sm *Manager) CreateSession( // Resolve the caller identity (may be nil for anonymous access). identity, _ := auth.IdentityFromContext(ctx) - // Note: Token hash and salt are computed and stored by the session factory - // (MakeSessionWithID below). Token binding enforcement happens at the session - // level via validateCaller(), which uses HMAC-SHA256 with a per-session salt. - // List and filter the currently eligible backends. backends := sm.currentEligibleBackends(ctx) // Build the fully-formed MultiSession using the SDK-assigned session ID. - // Sessions created with an identity are bound to that identity (allowAnonymous=false). - // Sessions created without an identity allow anonymous access (allowAnonymous=true). - allowAnonymous := sessiontypes.ShouldAllowAnonymous(identity) - sess, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, allowAnonymous, backends) + sess, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, backends) if err != nil { sm.cleanupFailedPlaceholder(sessionID, placeholder) return nil, fmt.Errorf("Manager.CreateSession: failed to create multi-session: %w", err) @@ -438,7 +431,7 @@ func (sm *Manager) RefreshSession( unlock() backends := sm.currentEligibleBackends(ctx) - refreshed, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, allowAnonymous, backends) + refreshed, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, backends) if err != nil { return nil, fmt.Errorf("Manager.RefreshSession: failed to rebuild multi-session: %w", err) } @@ -570,36 +563,37 @@ func (sm *Manager) Terminate(sessionID string) (isNotAllowed bool, err error) { return false, fmt.Errorf("Manager.Terminate: failed to load session %q: %w", sessionID, loadErr) } - // Placeholder session (not yet upgraded to MultiSession). - // - // This handles the race condition where a client sends DELETE between - // Generate() (Phase 1) and CreateSession() (Phase 2). The two-phase - // pattern creates a window where the session exists as a placeholder: - // - // 1. Client sends initialize → Generate() creates placeholder - // 2. Client sends DELETE before OnRegisterSession hook fires - // 3. We mark the placeholder as terminated (don't delete it) - // 4. CreateSession() hook fires → sees terminated flag → fails fast - // - // Without this branch, CreateSession() would open backend HTTP connections - // for a session the client already terminated, silently resurrecting it. - // - // We mark (not delete) so Validate() can return isTerminated=true, which - // lets the SDK distinguish "actively terminated" from "never existed". - // TTL cleanup will remove the placeholder later. + if _, isFullSession := metadata[sessiontypes.MetadataKeyIdentityBinding]; isFullSession { + // Phase 2 (full MultiSession): delete from storage. The cache entry will be + // evicted lazily on the next Get when checkSession finds the session gone. + if deleteErr := sm.storage.Delete(ctx, sessionID); deleteErr != nil { + return false, fmt.Errorf("Manager.Terminate: failed to delete session from storage: %w", deleteErr) + } + slog.Info("Manager.Terminate: session terminated", "session_id", sessionID) + return false, nil + } + + // Phase 1 (placeholder): mark terminated so CreateSession fast-fails and + // Validate returns isTerminated=true during the TTL window. + // Use Update (SET XX) rather than Upsert so we never resurrect a key that + // was concurrently deleted or expired between the Load above and this write. + // (false, nil) means already gone — treat as success. metadata[MetadataKeyTerminated] = MetadataValTrue - if storeErr := sm.storage.Upsert(ctx, sessionID, metadata); storeErr != nil { + updated, storeErr := sm.storage.Update(ctx, sessionID, metadata) + if storeErr != nil { slog.Warn("Manager.Terminate: failed to persist terminated flag for placeholder; attempting delete fallback", "session_id", sessionID, "error", storeErr) - // Use a fresh context: if ctx expired (deadline exceeded), the same - // context would cause the fallback delete to fail immediately too. deleteCtx, deleteCancel := context.WithTimeout(context.Background(), terminateTimeout) - defer deleteCancel() if deleteErr := sm.storage.Delete(deleteCtx, sessionID); deleteErr != nil { + deleteCancel() return false, fmt.Errorf( "Manager.Terminate: failed to persist terminated flag and delete placeholder: storeErr=%v, deleteErr=%w", storeErr, deleteErr) } + deleteCancel() + } else if !updated { + // Session expired or was concurrently deleted between Load and Update — already gone. + slog.Debug("Manager.Terminate: placeholder already gone before terminated flag could be set", "session_id", sessionID) } slog.Info("Manager.Terminate: session terminated", "session_id", sessionID) @@ -796,16 +790,17 @@ func (sm *Manager) loadSession(ctx context.Context, sessionID string) (vmcpsessi } // Don't restore placeholder sessions (Phase 2 never ran). - // PreventSessionHijacking always writes MetadataKeyTokenHash during Phase 2 - // (empty sentinel for anonymous, non-empty hash for authenticated). Its - // absence means Generate() stored this record but CreateSession() never - // completed — treat it as "not found" rather than "corrupted". + // BindSession always writes MetadataKeyIdentityBinding during Phase 2 + // (the unauthenticated sentinel for anonymous sessions, a bound (iss, sub) + // binding for authenticated ones). Its absence means Generate() stored + // this record but CreateSession() never completed — treat it as "not + // found" rather than "corrupted". // // Note: this is intentionally different from RestoreSession's fail-closed // check (absent key → error). Here we know a placeholder's empty metadata // is valid storage state produced by Generate(), so we return the // SDK-standard ErrSessionNotFound instead of an error. - if _, hashPresent := metadata[sessiontypes.MetadataKeyTokenHash]; !hashPresent { + if _, bindingPresent := metadata[sessiontypes.MetadataKeyIdentityBinding]; !bindingPresent { return nil, transportsession.ErrSessionNotFound } @@ -1027,7 +1022,7 @@ var errRefreshWithoutCallerToken = errors.New("bound session has no caller token const metadataKeyRestoredFromBackendIDs = "vmcp.session.restored_from_backend_ids" func isBoundSession(sess vmcpsession.MultiSession) bool { - return sess.GetMetadata()[sessiontypes.MetadataKeyTokenHash] != "" + return sessiontypes.HasBoundIdentity(sess.GetMetadata()) } func keepStoredBackendIDs(restored vmcpsession.MultiSession, stored map[string]string) { diff --git a/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go b/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go index 837c055102..c5364a2696 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go @@ -84,7 +84,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) sessionID := "restore-metadata-persist-session" freshMeta := map[string]string{ - sessiontypes.MetadataKeyTokenHash: "", + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", vmcpsession.MetadataKeyBackendIDs: "backend-a", vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "fresh-session-id", } @@ -94,7 +94,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { sm, storage := newTestSessionManager(t, factory, newFakeRegistry()) _, err := storage.Create(context.Background(), sessionID, map[string]string{ - sessiontypes.MetadataKeyTokenHash: "", + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", vmcpsession.MetadataKeyBackendIDs: "backend-a", vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "stale-session-id", }) @@ -115,7 +115,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) sessionID := "restore-concurrent-delete-session" - restored := newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + restored := newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyIdentityBinding: "unauthenticated"}) restored.EXPECT().Close().Return(nil).Times(1) factory.EXPECT(). RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). @@ -126,7 +126,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = cleanup(context.Background()) }) - _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyIdentityBinding: "unauthenticated"}) require.NoError(t, err) multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) @@ -142,7 +142,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { sessionID := "restore-update-error-session" factory.EXPECT(). RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). - Return(newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}), nil). + Return(newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyIdentityBinding: "unauthenticated"}), nil). Times(1) inner := newTestSessionDataStorage(t) @@ -150,7 +150,7 @@ func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = cleanup(context.Background()) }) - _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyIdentityBinding: "unauthenticated"}) require.NoError(t, err) multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) @@ -210,11 +210,13 @@ func newMetadataTestSession( const ( tokenlessRestoreSessionID = "550e8400-e29b-41d4-a716-446655440900" tokenlessRestoreSubject = "user-123" + // tokenlessRestoreBinding is binding.Format("https://idp.example", tokenlessRestoreSubject). + tokenlessRestoreBinding = "https://idp.example\x00" + tokenlessRestoreSubject ) func fullStoredMetadata() map[string]string { return map[string]string{ - sessiontypes.MetadataKeyTokenHash: "subject-hash", + sessiontypes.MetadataKeyIdentityBinding: tokenlessRestoreBinding, vmcpsession.MetadataKeyBackendIDs: "global,global_memory-mcp,p2", vmcpsession.MetadataKeyBackendSessionPrefix + "global": "global-session", vmcpsession.MetadataKeyBackendSessionPrefix + "global_memory-mcp": "memory-session", @@ -224,7 +226,7 @@ func fullStoredMetadata() map[string]string { func tokenlessRestoredMetadata() map[string]string { return map[string]string{ - sessiontypes.MetadataKeyTokenHash: "subject-hash", + sessiontypes.MetadataKeyIdentityBinding: tokenlessRestoreBinding, vmcpsession.MetadataKeyBackendIDs: "global,p2", vmcpsession.MetadataKeyBackendSessionPrefix + "global": "global-session-restored", vmcpsession.MetadataKeyBackendSessionPrefix + "p2": "p2-session-restored", @@ -326,7 +328,7 @@ func TestSessionManager_RefreshSessionNeverDowngradesBoundSession(t *testing.T) ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) - factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) sm, _ := newTestSessionManager(t, factory, &fakeBackendRegistry{backends: []vmcp.Backend{{ID: "global_memory-mcp"}}}) restored := newMetadataTestSession(ctrl, tokenlessRestoredMetadata(), @@ -346,13 +348,13 @@ func TestSessionManager_RefreshSessionNeverDowngradesBoundSession(t *testing.T) ctrl := gomock.NewController(t) anonymousMeta := map[string]string{ - sessiontypes.MetadataKeyTokenHash: "", - vmcpsession.MetadataKeyBackendIDs: "global", + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + vmcpsession.MetadataKeyBackendIDs: "global", } rebuilt := newMetadataTestSession(ctrl, anonymousMeta, nil) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), tokenlessRestoreSessionID, gomock.Nil(), true, gomock.Any()). + MakeSessionWithID(gomock.Any(), tokenlessRestoreSessionID, gomock.Nil(), gomock.Any()). Return(rebuilt, nil).Times(1) sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index 16e4c6dd6d..bd3078ae5f 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -64,7 +64,7 @@ func newMockFactory(t *testing.T, ctrl *gomock.Controller, sess vmcpsession.Mult t.Helper() factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil).AnyTimes() return factory } @@ -74,7 +74,7 @@ func newMockFactoryWithError(t *testing.T, ctrl *gomock.Controller, err error) * t.Helper() factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, err).AnyTimes() return factory } @@ -324,8 +324,8 @@ func TestSessionManager_CreateSession(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) var createdSess *sessionmocks.MockMultiSession factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { createdSess = newMockSession(t, ctrl, id, tools) return createdSess, nil }).AnyTimes() @@ -391,8 +391,8 @@ func TestSessionManager_CreateSession(t *testing.T) { factoryCalled := false factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { factoryCalled = true sess := newMockSession(t, ctrl, id, tools) return sess, nil @@ -426,8 +426,8 @@ func TestSessionManager_CreateSession(t *testing.T) { factoryCalled := false factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { factoryCalled = true sess := newMockSession(t, ctrl, id, tools) return sess, nil @@ -464,8 +464,8 @@ func TestSessionManager_CreateSession(t *testing.T) { var createdSess *sessionmocks.MockMultiSession factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { // Sleep to simulate slow backend initialization, creating a window // where the client can terminate the session after the first check passes. time.Sleep(50 * time.Millisecond) @@ -518,8 +518,8 @@ func TestSessionManager_CreateSession(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, backends []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, backends []*vmcp.Backend) (vmcpsession.MultiSession, error) { require.Len(t, backends, 2) assert.Equal(t, "healthy", backends[0].ID) assert.Equal(t, "degraded", backends[1].ID) @@ -680,15 +680,13 @@ func TestSessionManager_RefreshSession(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), sessionID, gomock.Any(), false, gomock.Any()). + MakeSessionWithID(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). DoAndReturn(func( _ context.Context, id string, gotIdentity *auth.Identity, - allowAnonymous bool, backends []*vmcp.Backend, ) (vmcpsession.MultiSession, error) { - require.False(t, allowAnonymous) require.NotNil(t, gotIdentity) assert.Equal(t, identity.Subject, gotIdentity.Subject) assert.Equal(t, identity.Token, gotIdentity.Token) @@ -838,8 +836,8 @@ func TestSessionManager_Terminate(t *testing.T) { var createdSess *sessionmocks.MockMultiSession factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { createdSess = newMockSession(t, ctrl, id, tools) // Close() will be called exactly once during Terminate createdSess.EXPECT().Close().Return(nil).Times(1) @@ -870,8 +868,8 @@ func TestSessionManager_Terminate(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) sess.EXPECT().Close().Return(nil).Times(1) return sess, nil @@ -1043,8 +1041,8 @@ func TestSessionManager_GetMultiSession(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, tools) return sess, nil }).Times(1) @@ -1069,7 +1067,7 @@ func TestSessionManager_GetMultiSession(t *testing.T) { // Cross-pod restore path: session is in storage but not in the in-memory // cache (simulates pod restart or eviction). loadSession is called on Get. - t.Run("restore path: placeholder in storage (absent token hash) is treated as not found", func(t *testing.T) { + t.Run("restore path: placeholder in storage (absent identity binding) is treated as not found", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -1081,23 +1079,23 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sessionID := "restore-placeholder-session" // Write placeholder metadata directly to storage, bypassing the cache. - // Generate() stores an empty map with no token hash. + // Generate() stores an empty map with no identity binding. require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, map[string]string{})) - // loadSession detects absent MetadataKeyTokenHash → ErrSessionNotFound. + // loadSession detects absent MetadataKeyIdentityBinding → ErrSessionNotFound. multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) assert.False(t, ok, "placeholder should not be restorable") assert.Nil(t, multiSess) }) - t.Run("restore path: fully-initialized zero-backend session (has token hash) is restored", func(t *testing.T) { + t.Run("restore path: fully-initialized zero-backend session (has identity binding) is restored", func(t *testing.T) { t.Parallel() tools := []vmcp.Tool{{Name: "zero-backend-tool", Description: "tool with no backends"}} ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) // MakeSessionWithID is only for Phase 2; unused in the restore path. - factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Times(0) sessionID := "restore-zero-backend-session" @@ -1109,12 +1107,13 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) - // Metadata matching what populateBackendMetadata now writes for a - // Phase-2-complete session with zero backends: MetadataKeyBackendIDs - // is always written (empty string for zero backends). + // Metadata matching what BindSession and populateBackendMetadata write + // for a Phase-2-complete anonymous session with zero backends: + // MetadataKeyIdentityBinding holds the unauthenticated sentinel; + // MetadataKeyBackendIDs is always written (empty string for zero backends). initializedMeta := map[string]string{ - sessiontypes.MetadataKeyTokenHash: "", // anonymous sentinel — present but empty - vmcpsession.MetadataKeyBackendIDs: "", // always written; empty = zero backends + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", // anonymous sentinel + vmcpsession.MetadataKeyBackendIDs: "", // always written; empty = zero backends } require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, initializedMeta)) @@ -1135,7 +1134,7 @@ func TestSessionManager_GetMultiSession(t *testing.T) { // RestoreSession. This test documents that backward-compat behaviour. ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) - factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Times(0) sessionID := "restore-legacy-session" @@ -1147,9 +1146,10 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) - // Legacy metadata: token hash present but MetadataKeyBackendIDs absent. + // Metadata with identity binding but MetadataKeyBackendIDs absent + // (sessions written before populateBackendMetadata always wrote the key). legacyMeta := map[string]string{ - sessiontypes.MetadataKeyTokenHash: "", // Phase 2 completion marker + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", // Phase 2 completion marker // MetadataKeyBackendIDs intentionally absent (legacy record) } require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, legacyMeta)) @@ -1159,6 +1159,140 @@ func TestSessionManager_GetMultiSession(t *testing.T) { require.NotNil(t, multiSess) assert.Equal(t, sessionID, multiSess.ID()) }) + + t.Run("restore path: restored metadata is persisted back to storage", func(t *testing.T) { + t.Parallel() + + // Simulate a backend that doesn't honor Mcp-Session-Id hints (e.g. SSE + // transport): RestoreSession assigns a fresh per-backend session ID. + // loadSession must write the restored session's metadata back to Redis so + // that stale per-backend session IDs do not persist indefinitely in storage. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-metadata-persist-session" + + // The restored session returns fresh per-backend session metadata. + freshMeta := map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "fresh-session-id", + } + restored := sessionmocks.NewMockMultiSession(ctrl) + restored.EXPECT().ID().Return(sessionID).AnyTimes() + restored.EXPECT().GetMetadata().Return(freshMeta).AnyTimes() + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + sm, storage := newTestSessionManager(t, factory, newFakeRegistry()) + + // Seed storage with stale per-backend session ID. + staleMeta := map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "stale-session-id", + } + _, err := sm.storage.Create(context.Background(), sessionID, staleMeta) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) + require.True(t, ok, "session must be restored") + require.NotNil(t, multiSess) + + // Verify storage now contains the fresh metadata written by loadSession. + storedMeta, loadErr := storage.Load(context.Background(), sessionID) + require.NoError(t, loadErr) + assert.Equal(t, freshMeta, storedMeta, + "loadSession must persist restored session metadata back to storage") + }) + + t.Run("restore path: concurrent delete between RestoreSession and Update returns ErrSessionNotFound", func(t *testing.T) { + t.Parallel() + + // Simulate a Terminate / TTL expiry that races with loadSession's + // metadata write-back: deleteBeforeUpdateStorage deletes the key just + // before the first Update, so Update returns (false, nil). + // loadSession must treat this as ErrSessionNotFound and NOT cache the + // restored session. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-concurrent-delete-session" + restored := sessionmocks.NewMockMultiSession(ctrl) + restored.EXPECT().ID().Return(sessionID).AnyTimes() + restored.EXPECT().GetMetadata().Return(map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + }).AnyTimes() + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + // loadSession calls Close on the restored session when a concurrent + // delete is detected (Update returns false, nil). + restored.EXPECT().Close().Return(nil).Times(1) + + // Build Manager with the wrapping storage. + innerStorage := newTestSessionDataStorage(t) + racyStorage := &deleteBeforeUpdateStorage{DataStorage: innerStorage} + sm, cleanup, err := New(racyStorage, &FactoryConfig{Base: factory}, newFakeRegistry(), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + // Seed the inner storage with a valid session record. + _, err = innerStorage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + }) + require.NoError(t, err) + + // GetMultiSession triggers loadSession; the racing delete causes + // Update to return (false, nil) → ErrSessionNotFound → (nil, false). + multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) + assert.False(t, ok, "session deleted before metadata write-back must not be cached") + assert.Nil(t, multiSess) + }) + + t.Run("restore path: transient Update error is non-fatal, session is still returned", func(t *testing.T) { + t.Parallel() + + // A transient Redis write failure during loadSession's metadata write-back + // must not prevent the restored session from being cached and served. + // The session is still usable on this pod; checkSession will detect any + // metadata drift on the next liveness check and evict if necessary. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-update-error-session" + restored := newMockSession(t, ctrl, sessionID, nil) + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + innerStorage := newTestSessionDataStorage(t) + faultyStorage := &errorOnUpdateStorage{DataStorage: innerStorage} + sm, cleanup, err := New(faultyStorage, &FactoryConfig{Base: factory}, newFakeRegistry(), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + _, err = innerStorage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + }) + require.NoError(t, err) + + // Write failure must be non-fatal: session is still returned and cached. + multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) + assert.True(t, ok, "transient Update error must not prevent session from being served") + assert.NotNil(t, multiSess) + assert.Equal(t, sessionID, multiSess.ID()) + }) } // --------------------------------------------------------------------------- @@ -1201,8 +1335,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { return newMockSession(t, ctrl, id, tools), nil }).Times(1) @@ -1261,8 +1395,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { return newMockSession(t, ctrl, id, tools), nil }).Times(1) @@ -1314,8 +1448,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { } factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, tools) sess.EXPECT().CallTool(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(callToolResult, nil).Times(1) @@ -1355,8 +1489,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, tools) sess.EXPECT().CallTool(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, errors.New("backend exploded")).Times(1) @@ -1394,8 +1528,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { callCount++ sess := newMockSession(t, ctrl, id, tools) if callCount == 1 { @@ -1449,8 +1583,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { return newMockSession(t, ctrl, id, tools), nil }).Times(1) @@ -1485,8 +1619,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { var capturedMeta map[string]any factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, tools) sess.EXPECT().CallTool(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ *auth.Identity, _ string, _ map[string]any, meta map[string]any) (*vmcp.ToolCallResult, error) { @@ -1551,8 +1685,8 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { authErr := tc.authError factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, tools) sess.EXPECT().CallTool(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, authErr).Times(1) @@ -1699,8 +1833,8 @@ func TestSessionManager_GetAdaptedResources(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) // Override default Resources() AnyTimes with a specific return sess.EXPECT().Resources().Return(resources).AnyTimes() @@ -1751,8 +1885,8 @@ func TestSessionManager_GetAdaptedResources(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) sess.EXPECT().Resources().Return(resources).AnyTimes() sess.EXPECT().ReadResource(gomock.Any(), gomock.Any(), "file:///data.txt"). @@ -1799,8 +1933,8 @@ func TestSessionManager_GetAdaptedResources(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) sess.EXPECT().Resources().Return(resources).AnyTimes() sess.EXPECT().ReadResource(gomock.Any(), gomock.Any(), "file:///broken.txt"). @@ -1846,8 +1980,8 @@ func TestSessionManager_GetAdaptedResources(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) sess.EXPECT().Resources().Return(resources).AnyTimes() sess.EXPECT().ReadResource(gomock.Any(), gomock.Any(), "file:///binary.bin"). @@ -1909,8 +2043,8 @@ func TestSessionManager_GetAdaptedResources(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := newMockSession(t, ctrl, id, nil) sess.EXPECT().Resources().Return(resources).AnyTimes() sess.EXPECT().ReadResource(gomock.Any(), gomock.Any(), "file:///protected.txt"). @@ -1989,8 +2123,8 @@ func TestSessionManager_GetAdaptedPrompts(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { // Create mock directly (without newMockSession) so there is no // pre-existing Prompts().Return(nil).AnyTimes() that would win // the FIFO expectation race over our specific prompts list. @@ -2050,8 +2184,8 @@ func TestSessionManager_GetAdaptedPrompts(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := sessionmocks.NewMockMultiSession(ctrl) sess.EXPECT().ID().Return(id).AnyTimes() sess.EXPECT().GetMetadata().Return(map[string]string{}).AnyTimes() @@ -2092,8 +2226,8 @@ func TestSessionManager_GetAdaptedPrompts(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := sessionmocks.NewMockMultiSession(ctrl) sess.EXPECT().ID().Return(id).AnyTimes() sess.EXPECT().GetMetadata().Return(map[string]string{}).AnyTimes() @@ -2143,8 +2277,8 @@ func TestSessionManager_GetAdaptedPrompts(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { sess := sessionmocks.NewMockMultiSession(ctrl) sess.EXPECT().ID().Return(id).AnyTimes() sess.EXPECT().GetMetadata().Return(map[string]string{}).AnyTimes() @@ -2197,8 +2331,8 @@ func TestSessionManager_DecorateSession(t *testing.T) { ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { return newMockSession(t, ctrl, id, tools), nil }).Times(1) @@ -2260,10 +2394,17 @@ func TestSessionManager_DecorateSession(t *testing.T) { // decorator fn, so the re-check that follows fn() sees it is gone. ctrl := gomock.NewController(t) factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + // The mock session carries MetadataKeyIdentityBinding so that: + // 1. CreateSession stores it in storage (via sess.GetMetadata()), keeping + // cache and storage in sync for checkSession's maps.Equal comparison. + // 2. Terminate sees the key and takes the Phase 2 path (storage.Delete). + bindingMeta := map[string]string{sessiontypes.MetadataKeyIdentityBinding: "unauthenticated"} factory.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { - sess := newMockSession(t, ctrl, id, nil) + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) { + sess := sessionmocks.NewMockMultiSession(ctrl) + sess.EXPECT().ID().Return(id).AnyTimes() + sess.EXPECT().GetMetadata().Return(bindingMeta).AnyTimes() sess.EXPECT().Close().Return(nil).AnyTimes() return sess, nil }).Times(1) @@ -2302,7 +2443,7 @@ func TestSessionManager_CheckSession(t *testing.T) { t.Helper() ctrl := gomock.NewController(t) f := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) - f.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + f.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes().Return(nil, nil) f.EXPECT().RestoreSession(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes().Return(nil, nil) @@ -2559,6 +2700,13 @@ func TestNotifyBackendExpired(t *testing.T) { _, err := sm.CreateSession(t.Context(), sessionID) require.NoError(t, err) + // Seed MetadataKeyIdentityBinding into storage so Terminate recognises this + // as a Phase 2 (full MultiSession) and deletes rather than marks terminated. + _, err = storage.Update(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + }) + require.NoError(t, err) + _, err = sm.Terminate(sessionID) require.NoError(t, err) @@ -2673,6 +2821,179 @@ func TestNotifyBackendExpired(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// Tests: Phase 2 marker migration (#5306) +// --------------------------------------------------------------------------- + +// TestLoadSession_Phase2Marker_UsesIdentityBindingKey documents that the +// Phase-2 detection key for the restore path is MetadataKeyIdentityBinding, +// not the legacy MetadataKeyTokenHash. Sessions stored with only the legacy +// key are treated as not found (ErrSessionNotFound) and the client must +// re-initialize. Sessions stored with MetadataKeyIdentityBinding are restored +// normally. +func TestLoadSession_Phase2Marker_UsesIdentityBindingKey(t *testing.T) { + t.Parallel() + + t.Run("legacy session (only MetadataKeyTokenHash) returns not found", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + // RestoreSession must NOT be called for legacy sessions on the restore path. + factory.EXPECT().RestoreSession(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) + + sessionID := "legacy-only-token-hash-session" + // Seed storage with only the legacy key — no MetadataKeyIdentityBinding. + _, err := sm.storage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + }) + require.NoError(t, err) + + // loadSession must treat absent MetadataKeyIdentityBinding as a legacy session + // and return (nil, false) — not attempting RestoreSession. + multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) + assert.False(t, ok, "legacy session with only MetadataKeyTokenHash must not be restored") + assert.Nil(t, multiSess) + }) + + t.Run("session with MetadataKeyIdentityBinding is restored normally", func(t *testing.T) { + t.Parallel() + + tools := []vmcp.Tool{{Name: "restored-tool", Description: "a restored tool"}} + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "identity-binding-session" + restored := newMockSession(t, ctrl, sessionID, tools) + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) + + _, err := sm.storage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + vmcpsession.MetadataKeyBackendIDs: "", + }) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(context.Background(), sessionID) + require.True(t, ok, "session with MetadataKeyIdentityBinding must be restorable") + require.NotNil(t, multiSess) + assert.Equal(t, sessionID, multiSess.ID()) + }) +} + +// TestTerminate_Phase2DetectionUsesIdentityBindingKey verifies that Terminate +// uses MetadataKeyIdentityBinding (not the legacy MetadataKeyTokenHash) to +// distinguish Phase 2 sessions (full MultiSession → Delete) from Phase 1 +// placeholders (→ mark terminated). +func TestTerminate_Phase2DetectionUsesIdentityBindingKey(t *testing.T) { + t.Parallel() + + t.Run("session with MetadataKeyIdentityBinding takes delete path", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + sess := newMockSession(t, ctrl, "s", nil) + sess.EXPECT().Close().Return(nil).AnyTimes() + factory := newMockFactory(t, ctrl, sess) + registry := newFakeRegistry() + sm, storage := newTestSessionManager(t, factory, registry) + + sessionID := sm.Generate() + require.NotEmpty(t, sessionID) + + // Write MetadataKeyIdentityBinding into storage to simulate a Phase 2 session. + _, err := storage.Update(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyIdentityBinding: "unauthenticated", + }) + require.NoError(t, err) + + // Terminate must take the Phase 2 path: storage.Delete (not marked terminated). + isNotAllowed, err := sm.Terminate(sessionID) + require.NoError(t, err) + assert.False(t, isNotAllowed) + + // Session must be deleted from storage, not just marked terminated. + _, loadErr := storage.Load(context.Background(), sessionID) + assert.ErrorIs(t, loadErr, transportsession.ErrSessionNotFound, + "Phase 2 Terminate must delete the session from storage") + }) + + t.Run("placeholder without MetadataKeyIdentityBinding takes mark-terminated path", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + sess := newMockSession(t, ctrl, "", nil) + factory := newMockFactory(t, ctrl, sess) + registry := newFakeRegistry() + sm, storage := newTestSessionManager(t, factory, registry) + + sessionID := sm.Generate() + require.NotEmpty(t, sessionID) + + // No MetadataKeyIdentityBinding in storage — this is a Phase 1 placeholder. + isNotAllowed, err := sm.Terminate(sessionID) + require.NoError(t, err) + assert.False(t, isNotAllowed) + + // Session must remain in storage but marked as terminated (not deleted). + metadata, loadErr := storage.Load(context.Background(), sessionID) + require.NoError(t, loadErr, "placeholder must remain in storage (TTL will clean it)") + assert.Equal(t, MetadataValTrue, metadata[MetadataKeyTerminated], + "Phase 1 Terminate must mark the session terminated, not delete it") + }) +} + +// TestTerminate_LegacyFormatSession_TakesPlaceholderPath is a B5 documentation +// test. It verifies that a legacy session stored in Redis with only the old +// MetadataKeyTokenHash key (no MetadataKeyIdentityBinding) causes Terminate to +// take the placeholder (mark-terminated) path rather than deleting the session. +// +// This is intentional: without the identity binding key, the Manager cannot +// tell whether the record is a real Phase-2 session or a corrupted/partial +// record. Treating it as a placeholder (soft termination) is safe — the TTL +// will eventually clean it up. The comment at session_manager.go line 485 +// references this test. +func TestTerminate_LegacyFormatSession_TakesPlaceholderPath(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + sess := newMockSession(t, ctrl, "", nil) + factory := newMockFactory(t, ctrl, sess) + registry := newFakeRegistry() + sm, storage := newTestSessionManager(t, factory, registry) + + sessionID := sm.Generate() + require.NotEmpty(t, sessionID) + + // Seed storage with only the legacy key — simulates a pre-#5306 session in Redis. + // MetadataKeyIdentityBinding is absent. + _, err := storage.Update(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + }) + require.NoError(t, err) + + // Terminate must take the placeholder path: mark as terminated, NOT delete. + isNotAllowed, err := sm.Terminate(sessionID) + require.NoError(t, err) + assert.False(t, isNotAllowed) + + // Session must still exist in storage — marked terminated, not deleted. + // (Storage cleanup happens via TTL or the next GET → checkSession → eviction.) + metadata, loadErr := storage.Load(context.Background(), sessionID) + require.NoError(t, loadErr, + "legacy session must remain in storage after Terminate (not deleted)") + assert.Equal(t, MetadataValTrue, metadata[MetadataKeyTerminated], + "legacy session Terminate must set MetadataKeyTerminated rather than deleting") +} + // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- diff --git a/pkg/vmcp/server/status.go b/pkg/vmcp/server/status.go index f7f6581e01..b29f8335e8 100644 --- a/pkg/vmcp/server/status.go +++ b/pkg/vmcp/server/status.go @@ -60,8 +60,19 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // buildStatusResponse builds the StatusResponse from server state. // Uses the provided context for request cancellation and tracing propagation. func (s *Server) buildStatusResponse(ctx context.Context) StatusResponse { - // Get current backends from registry (supports dynamic backend changes) - backends := s.backendRegistry.List(ctx) + // Get current backends from registry (supports dynamic backend changes). + // The registry is owned by the core and may not be wired into the transport + // yet (the Serve skeleton, before later Phase 2 tasks relocate it); a nil + // registry yields an empty backend view rather than a panic. This is expected + // only on a Serve-built skeleton — on the server.New path the registry is a + // required arg, so a nil there is a wiring bug; log at Debug so it is + // observable when investigating without spamming routine /status polls. + var backends []vmcp.Backend + if s.backendRegistry != nil { + backends = s.backendRegistry.List(ctx) + } else { + slog.Debug("status: backend registry not wired; reporting empty backend view") + } backendStatuses := make([]BackendStatus, 0, len(backends)) // Get live health states from the health monitor (if enabled) so that @@ -93,7 +104,7 @@ func (s *Server) buildStatusResponse(ctx context.Context) StatusResponse { } backendStatuses = append(backendStatuses, status) - if healthStatus == vmcp.BackendHealthy || healthStatus == vmcp.BackendUnauthenticated { + if healthStatus == vmcp.BackendHealthy { hasHealthyBackend = true } } diff --git a/pkg/vmcp/server/telemetry_integration_test.go b/pkg/vmcp/server/telemetry_integration_test.go index c8e8308e64..c6f5976ba7 100644 --- a/pkg/vmcp/server/telemetry_integration_test.go +++ b/pkg/vmcp/server/telemetry_integration_test.go @@ -111,7 +111,7 @@ func newBackendAwareTestFactory(tools []vmcp.Tool, rt *vmcp.RoutingTable) (*back } func (f *backendAwareTestFactory) MakeSessionWithID( - _ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend, + _ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend, ) (vmcpsession.MultiSession, error) { return &backendAwareTestSession{ Session: transportsession.NewStreamableSession(id), @@ -388,7 +388,7 @@ func TestIntegration_TelemetryMiddleware(t *testing.T) { assert.Contains(t, metrics, "toolhive_mcp_request_duration", "Should record request duration histogram") - // --- Backend metrics (from monitorBackends in vmcp/server/telemetry.go) --- + // --- Backend metrics (from backendtelemetry.MonitorBackends) --- // Backend request counter — recorded when the tool call was routed to the backend assert.Contains(t, metrics, "toolhive_vmcp_backend_requests", diff --git a/pkg/vmcp/server/testfactory_test.go b/pkg/vmcp/server/testfactory_test.go index aba748c299..e9b7e62145 100644 --- a/pkg/vmcp/server/testfactory_test.go +++ b/pkg/vmcp/server/testfactory_test.go @@ -27,7 +27,7 @@ type minimalTestFactory struct{} var _ vmcpsession.MultiSessionFactory = (*minimalTestFactory)(nil) func (*minimalTestFactory) MakeSessionWithID( - _ context.Context, _ string, _ *auth.Identity, _ bool, _ []*vmcp.Backend, + _ context.Context, _ string, _ *auth.Identity, _ []*vmcp.Backend, ) (vmcpsession.MultiSession, error) { return nil, fmt.Errorf("minimalTestFactory: MakeSessionWithID not implemented in test helper") } diff --git a/pkg/vmcp/session/binding/binding.go b/pkg/vmcp/session/binding/binding.go new file mode 100644 index 0000000000..da6527b8b9 --- /dev/null +++ b/pkg/vmcp/session/binding/binding.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package binding is the single owner of the identity-binding format used by +// vMCP session storage. An identity binding encodes the OIDC principal that +// created a session as a single opaque string suitable for storage in a +// key/value store such as Redis or Valkey. +// +// # Format +// +// A bound identity is encoded as iss + "\x00" + sub. NUL is rejected from +// either half by Format and Parse: OIDC Core does not formally forbid NUL in +// sub, but no real-world issuer emits one, and accepting it would let a +// corrupted or adversarial value re-split during Parse. +// +// Sessions created before any auth middleware ran use the literal +// "unauthenticated" sentinel. The sentinel must not contain '\x00' so it +// cannot collide with any bound form; any future format change must keep +// the sentinel and the bound-form value sets non-overlapping. +// +// # Trust boundary +// +// Bindings are stored plaintext at rest. They are PII but not credentials — +// they identify but do not authenticate. They carry no freshness signal (no +// exp, no nonce) and are NOT a substitute for token validation: callers must +// only compare a stored binding against a freshly-validated (iss, sub) pair +// from the current request's token. +package binding + +import ( + "errors" + "strings" +) + +// UnauthenticatedSentinel is the binding value stored for sessions that were +// created without an authenticated identity (auth middleware not present or +// identity nil). +const UnauthenticatedSentinel = "unauthenticated" + +// ErrInvalidBinding is returned by Format when either input is empty or +// contains a NUL byte. +var ErrInvalidBinding = errors.New("invalid identity binding") + +// Format returns the canonical on-the-wire form of an identity binding: +// iss + "\x00" + sub. Returns ErrInvalidBinding when either input is empty +// or contains a NUL byte. +func Format(iss, sub string) (string, error) { + if iss == "" || sub == "" { + return "", ErrInvalidBinding + } + if strings.ContainsRune(iss, '\x00') || strings.ContainsRune(sub, '\x00') { + return "", ErrInvalidBinding + } + return iss + "\x00" + sub, nil +} + +// Parse splits an on-the-wire binding into its (iss, sub) components. +// Returns ok=true only when s contains exactly one NUL and both halves are +// non-empty. Returns ok=false for the unauthenticated sentinel, for malformed +// input, and for empty strings. Callers must check ok; the empty-string +// return values are not meaningful when ok=false. +func Parse(s string) (iss, sub string, ok bool) { + iss, sub, found := strings.Cut(s, "\x00") + if !found || iss == "" || sub == "" { + return "", "", false + } + // strings.Cut splits on the first NUL, so iss cannot contain one. Sub may + // still carry trailing NULs from a malformed input; reject those. + if strings.ContainsRune(sub, '\x00') { + return "", "", false + } + return iss, sub, true +} + +// IsUnauthenticated reports whether s is the literal unauthenticated sentinel. +func IsUnauthenticated(s string) bool { + return s == UnauthenticatedSentinel +} diff --git a/pkg/vmcp/session/binding/binding_test.go b/pkg/vmcp/session/binding/binding_test.go new file mode 100644 index 0000000000..67048dd1c7 --- /dev/null +++ b/pkg/vmcp/session/binding/binding_test.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package binding_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" +) + +func TestFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + iss string + sub string + want string + wantErr bool + }{ + { + name: "typical OIDC issuer and subject", + iss: "https://idp.example", + sub: "user-42", + want: "https://idp.example\x00user-42", + }, + { + name: "issuer with path", + iss: "https://accounts.google.com/o/oauth2", + sub: "109876543210987654321", + want: "https://accounts.google.com/o/oauth2\x00109876543210987654321", + }, + { + name: "minimal non-empty inputs", + iss: "i", + sub: "s", + want: "i\x00s", + }, + { + name: "email-like subject", + iss: "https://auth.example.com", + sub: "alice@example.com", + want: "https://auth.example.com\x00alice@example.com", + }, + {name: "empty iss", iss: "", sub: "sub", wantErr: true}, + {name: "empty sub", iss: "iss", sub: "", wantErr: true}, + {name: "NUL in iss", iss: "a\x00b", sub: "sub", wantErr: true}, + {name: "NUL in sub", iss: "iss", sub: "a\x00b", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := binding.Format(tt.iss, tt.sub) + if tt.wantErr { + assert.Empty(t, got) + require.ErrorIs(t, err, binding.ErrInvalidBinding) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + + gotIss, gotSub, ok := binding.Parse(got) + require.True(t, ok, "Parse must return ok=true for a value produced by Format") + assert.Equal(t, tt.iss, gotIss) + assert.Equal(t, tt.sub, gotSub) + }) + } +} + +func TestParse_RejectsInvalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {name: "empty string", input: ""}, + {name: "no NUL separator", input: "nothing-here"}, + {name: "empty iss half", input: "\x00sub"}, + {name: "empty sub half", input: "iss\x00"}, + // Defense-in-depth: a value that splits into three parts on NUL must be + // rejected so a corrupt or adversarial store write cannot smuggle extra + // data past Parse. + {name: "trailing NUL with extra data", input: "iss\x00sub\x00trailer"}, + // Callers must call IsUnauthenticated before Parse on the restore path; + // the sentinel must not accidentally parse as a bound binding. + {name: "unauthenticated sentinel", input: binding.UnauthenticatedSentinel}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + iss, sub, ok := binding.Parse(tt.input) + assert.False(t, ok) + assert.Empty(t, iss) + assert.Empty(t, sub) + }) + } +} + +func TestIsUnauthenticated(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want bool + }{ + {name: "sentinel", input: binding.UnauthenticatedSentinel, want: true}, + {name: "empty string", input: "", want: false}, + {name: "arbitrary string", input: "foo", want: false}, + {name: "bound binding", input: "iss\x00sub", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, binding.IsUnauthenticated(tt.input)) + }) + } +} diff --git a/pkg/vmcp/session/connector_integration_test.go b/pkg/vmcp/session/connector_integration_test.go index 1a3731e6fb..e0d57b68cb 100644 --- a/pkg/vmcp/session/connector_integration_test.go +++ b/pkg/vmcp/session/connector_integration_test.go @@ -6,7 +6,6 @@ package session import ( "bytes" "context" - "encoding/hex" "encoding/json" "fmt" "io" @@ -28,7 +27,7 @@ import ( vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" - "github.com/stacklok/toolhive/pkg/vmcp/session/internal/security" + internalbk "github.com/stacklok/toolhive/pkg/vmcp/session/internal/backend" sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" ) @@ -218,7 +217,7 @@ func TestSessionFactory_Integration_CapabilityDiscovery(t *testing.T) { } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) require.NotNil(t, sess) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -247,7 +246,7 @@ func TestSessionFactory_Integration_CallTool(t *testing.T) { } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -279,7 +278,6 @@ func TestSessionFactory_Integration_PerWorkloadRequestTimeout(t *testing.T) { context.Background(), uuid.New().String(), nil, - true, []*vmcp.Backend{backend}, ) require.NoError(t, err) @@ -303,7 +301,7 @@ func TestSessionFactory_Integration_ReconnectsExpiredBackendSession(t *testing.T } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -332,7 +330,7 @@ func TestSessionFactory_Integration_ReconnectsOnSessionlessBadRequest(t *testing } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -369,7 +367,7 @@ func TestSessionFactory_Integration_ReconnectsOnInvalidDuringInitializationError } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -393,7 +391,7 @@ func TestSessionFactory_Integration_DoesNotRetryAmbiguousBackendFailure(t *testi } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -415,7 +413,7 @@ func TestSessionFactory_Integration_ReadResource(t *testing.T) { } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -438,7 +436,7 @@ func TestSessionFactory_Integration_GetPrompt(t *testing.T) { } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -467,7 +465,7 @@ func TestSessionFactory_Integration_MultipleBackends(t *testing.T) { } factory := NewSessionFactory(newUnauthenticatedRegistry(t)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, backends) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) @@ -477,21 +475,46 @@ func TestSessionFactory_Integration_MultipleBackends(t *testing.T) { } // --------------------------------------------------------------------------- -// Subject-binding integration tests — HMAC rejection for ReadResource / GetPrompt +// Identity-binding integration tests — CallerRejection for ReadResource / GetPrompt // --------------------------------------------------------------------------- // TestTokenBinding_CallerRejection verifies that the hijack-prevention decorator // is applied to all three protected methods (CallTool, ReadResource, GetPrompt): -// each rejects a wrong subject (ErrUnauthorizedCaller) and a nil caller -// (ErrNilCaller) before any backend routing occurs, so nilBackendConnector suffices. -func TestTokenBinding_CallerRejection(t *testing.T) { +// each rejects a wrong caller (ErrUnauthorizedCaller) and a nil caller +// (ErrNilCaller) before any backend routing occurs. +// +// The identity binding is derived from Claims["iss"] and Claims["sub"] per the +// new #5306 model (no HMAC secret required). +func TestIdentityBinding_CallerRejection(t *testing.T) { t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: "alice-token"} - wrongCaller := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "bob"}, Token: "wrong-token"} + // Both alice and bob need valid Claims so the binding decorator can extract + // their (iss, sub) pairs. alice creates the session; bob is the wrong caller. + alice := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "alice", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }, + }, + } + wrongCaller := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "bob", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "bob", + }, + }, + } - factory := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret([]byte("test-hmac-secret-exactly-32bytes"))) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) + // No backend connector needed: auth validation fires before any routing. + connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { + return nil, nil, nil + } + factory := newSessionFactoryWithConnector(connector) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), alice, nil) require.NoError(t, err) t.Cleanup(func() { _ = sess.Close() }) @@ -514,7 +537,7 @@ func TestTokenBinding_CallerRejection(t *testing.T) { } for _, fn := range callFns { - t.Run(fn.name+"/wrong token", func(t *testing.T) { + t.Run(fn.name+"/wrong caller", func(t *testing.T) { t.Parallel() assert.ErrorIs(t, fn.call(wrongCaller), sessiontypes.ErrUnauthorizedCaller) }) @@ -528,7 +551,7 @@ func TestTokenBinding_CallerRejection(t *testing.T) { // TestTokenBinding_ReadResource_And_GetPrompt_WithRealBackend verifies that a // bound session accepts ReadResource and GetPrompt calls from the correct caller // when a real backend is connected. -func TestTokenBinding_ReadResource_And_GetPrompt_WithRealBackend(t *testing.T) { +func TestIdentityBinding_ReadResource_And_GetPrompt_WithRealBackend(t *testing.T) { t.Parallel() baseURL := startInProcessMCPServer(t) @@ -539,15 +562,24 @@ func TestTokenBinding_ReadResource_And_GetPrompt_WithRealBackend(t *testing.T) { TransportType: "streamable-http", } - const rawToken = "alice-real-token" - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: rawToken} + // Identity with Claims so binding.Format(iss, sub) succeeds and the session + // is bound to the caller identity. + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "alice", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }, + }, + } - factory := NewSessionFactory(newUnauthenticatedRegistry(t), WithHMACSecret([]byte("test-hmac-secret-exactly-32bytes"))) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, []*vmcp.Backend{backend}) + factory := NewSessionFactory(newUnauthenticatedRegistry(t)) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sess.Close()) }) - t.Run("allows ReadResource with correct token", func(t *testing.T) { + t.Run("allows ReadResource with correct caller", func(t *testing.T) { t.Parallel() result, err := sess.ReadResource(context.Background(), identity, "test://data") require.NoError(t, err) @@ -556,7 +588,7 @@ func TestTokenBinding_ReadResource_And_GetPrompt_WithRealBackend(t *testing.T) { assert.Equal(t, "hello", result.Contents[0].Text) }) - t.Run("allows GetPrompt with correct token", func(t *testing.T) { + t.Run("allows GetPrompt with correct caller", func(t *testing.T) { t.Parallel() result, err := sess.GetPrompt(context.Background(), identity, "greet", nil) require.NoError(t, err) @@ -567,71 +599,43 @@ func TestTokenBinding_ReadResource_And_GetPrompt_WithRealBackend(t *testing.T) { }) } -// TestTokenBinding_DifferentSecretsProduceDifferentHashes verifies that two -// session factories configured with different HMAC secrets store different token -// hashes for the same raw bearer token. This is the key isolation property that -// prevents sessions from one secret epoch from being validated against another. -func TestTokenBinding_DifferentSecretsProduceDifferentHashes(t *testing.T) { - t.Parallel() - - const rawToken = "shared-token-same-for-both" - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: rawToken} - - factoryA := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret([]byte("secret-A-exactly-32-bytes-long!!"))) - factoryB := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret([]byte("secret-B-exactly-32-bytes-long!!"))) - - sessA, err := factoryA.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = sessA.Close() }) - - sessB, err := factoryB.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = sessB.Close() }) - - hashA := sessA.GetMetadata()[MetadataKeyTokenHash] - hashB := sessB.GetMetadata()[MetadataKeyTokenHash] - - assert.NotEmpty(t, hashA) - assert.NotEmpty(t, hashB) - assert.NotEqual(t, hashA, hashB, - "different HMAC secrets must produce different token hashes for the same input token") -} - -// TestRestoreHijackPrevention_Integration_RoundTrip verifies the full +// TestTokenBinding_RestoreSession_RoundTrip verifies the full // store-then-restore flow across a real factory-created session: // -// 1. Create a session via the factory (writes tokenHash + tokenSalt to metadata). -// 2. Extract the persisted values. -// 3. Wrap a fresh base session with RestoreHijackPrevention using those values. -// 4. Confirm the restored decorator accepts the original token and rejects others. -func TestRestoreHijackPrevention_Integration_RoundTrip(t *testing.T) { +// 1. Create a session via the factory (writes MetadataKeyIdentityBinding to metadata). +// 2. Extract the persisted binding. +// 3. Restore the session via RestoreSession using the persisted metadata. +// 4. Confirm the restored decorator accepts the original caller and rejects others. +func TestIdentityBinding_RestoreSession_RoundTrip(t *testing.T) { t.Parallel() - const rawToken = "integration-token" - hmacSecret := []byte("test-hmac-secret-exactly-32bytes") - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: rawToken} + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "alice", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }, + }, + } - factory := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret(hmacSecret)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) + connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { + return nil, nil, nil + } + factory := newSessionFactoryWithConnector(connector) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, nil) require.NoError(t, err) t.Cleanup(func() { _ = sess.Close() }) // Extract persisted values — these simulate what would be read back from Redis. meta := sess.GetMetadata() - persistedHash := meta[MetadataKeyTokenHash] - persistedSalt := meta[sessiontypes.MetadataKeyTokenSalt] - require.NotEmpty(t, persistedHash, "factory must write tokenHash to metadata") - require.NotEmpty(t, persistedSalt, "factory must write tokenSalt to metadata") - - // Simulate "Pod B": restore the decorator from persisted metadata. - // We use a nil-connector session as the inner session (no real backend needed - // to test auth path). - innerSess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = innerSess.Close() }) + storedBinding := meta[MetadataKeyIdentityBinding] + require.NotEmpty(t, storedBinding, "factory must write MetadataKeyIdentityBinding to metadata") - restored, err := security.RestoreHijackPrevention(innerSess, persistedHash, persistedSalt, hmacSecret) + // Simulate "Pod B": restore the session from persisted metadata. + restored, err := factory.RestoreSession(context.Background(), uuid.New().String(), meta, nil) require.NoError(t, err) + t.Cleanup(func() { _ = restored.Close() }) ctx := context.Background() @@ -641,8 +645,16 @@ func TestRestoreHijackPrevention_Integration_RoundTrip(t *testing.T) { require.NotErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) require.NotErrorIs(t, err, sessiontypes.ErrNilCaller) - // A different caller is rejected at the auth layer — before any backend routing. - wrongCaller := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "eve"}, Token: "eve-token"} + // A different caller is rejected at the auth layer. + wrongCaller := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "eve", + Claims: map[string]any{ + "iss": "https://idp.example", + "sub": "eve", + }, + }, + } _, err = restored.CallTool(ctx, wrongCaller, "any-tool", nil, nil) require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) @@ -651,70 +663,6 @@ func TestRestoreHijackPrevention_Integration_RoundTrip(t *testing.T) { require.ErrorIs(t, err, sessiontypes.ErrNilCaller) } -// TestRestoreHijackPrevention_Integration_CrossReplicaSecretMismatch verifies -// that a session restored on a replica with a different HMAC secret rejects -// the original caller's token, documenting the operational requirement that -// all replicas must share the same secret. -func TestRestoreHijackPrevention_Integration_CrossReplicaSecretMismatch(t *testing.T) { - t.Parallel() - - secretA := []byte("secret-A-exactly-32-bytes-long!!") - secretB := []byte("secret-B-exactly-32-bytes-long!!") - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: "alice-token"} - - // Pod A creates the session with secretA, persisting the hash. - factoryA := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret(secretA)) - sessA, err := factoryA.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = sessA.Close() }) - - persistedHash := sessA.GetMetadata()[MetadataKeyTokenHash] - persistedSalt := sessA.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - - // Pod B restores with secretB — the persisted hash was computed with secretA, - // so validation will produce a different HMAC and reject the caller. - factoryB := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret(secretB)) - innerSess, err := factoryB.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = innerSess.Close() }) - - restored, err := security.RestoreHijackPrevention(innerSess, persistedHash, persistedSalt, secretB) - require.NoError(t, err) - - _, err = restored.CallTool(context.Background(), identity, "any-tool", nil, nil) - require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller, - "cross-replica secret mismatch must reject the original caller") -} - -// TestTokenBinding_MetadataEncoding verifies that the token hash and salt stored -// in session metadata are valid hex strings of the expected lengths: -// - token hash: 64 hex chars (32-byte HMAC-SHA256) -// - token salt: 32 hex chars (16-byte random salt) -func TestTokenBinding_MetadataEncoding(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token-123"} - - factory := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret([]byte("test-hmac-secret-exactly-32bytes"))) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = sess.Close() }) - - tokenHash := sess.GetMetadata()[MetadataKeyTokenHash] - require.NotEmpty(t, tokenHash) - assert.Len(t, tokenHash, 64, "HMAC-SHA256 hex-encoded hash must be 64 characters") - hashBytes, err := hex.DecodeString(tokenHash) - require.NoError(t, err, "token hash must be valid hex") - assert.Len(t, hashBytes, 32, "decoded token hash must be 32 bytes") - - tokenSalt := sess.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - require.NotEmpty(t, tokenSalt) - saltBytes, err := hex.DecodeString(tokenSalt) - require.NoError(t, err, "token salt must be valid hex") - assert.Len(t, saltBytes, 16, "decoded token salt must be 16 bytes") -} - // startInProcessMCPServerWithHeaderCapture starts an in-process MCP server and // returns the base URL along with a function that returns all Mcp-Session-Id // header values received by the server from clients. @@ -777,7 +725,7 @@ func TestSessionFactory_Integration_RestoreSession_SendsStoredSessionHintToBacke // Create the original session — the backend assigns a session ID over // streamable-HTTP and we store it in metadata. - orig, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + orig, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) t.Cleanup(func() { _ = orig.Close() }) diff --git a/pkg/vmcp/session/decorating_factory.go b/pkg/vmcp/session/decorating_factory.go index f536e9ef0f..2254634a23 100644 --- a/pkg/vmcp/session/decorating_factory.go +++ b/pkg/vmcp/session/decorating_factory.go @@ -51,10 +51,9 @@ func (f *decoratingMultiSessionFactory) MakeSessionWithID( ctx context.Context, id string, identity *auth.Identity, - allowAnonymous bool, backends []*vmcp.Backend, ) (MultiSession, error) { - sess, err := f.base.MakeSessionWithID(ctx, id, identity, allowAnonymous, backends) + sess, err := f.base.MakeSessionWithID(ctx, id, identity, backends) if err != nil { return nil, err } diff --git a/pkg/vmcp/session/decorating_factory_test.go b/pkg/vmcp/session/decorating_factory_test.go index 361ad261e9..ae4776ed85 100644 --- a/pkg/vmcp/session/decorating_factory_test.go +++ b/pkg/vmcp/session/decorating_factory_test.go @@ -34,7 +34,7 @@ func TestNewDecoratingFactory_DecoratorsAppliedInOrder(t *testing.T) { sess := sessionmocks.NewMockMultiSession(ctrl) base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) var order []int @@ -48,7 +48,7 @@ func TestNewDecoratingFactory_DecoratorsAppliedInOrder(t *testing.T) { } factory := session.NewDecoratingFactory(base, dec1, dec2) - _, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + _, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) require.NoError(t, err) assert.Equal(t, []int{1, 2}, order) } @@ -62,7 +62,7 @@ func TestNewDecoratingFactory_DecoratorError_ClosesSession(t *testing.T) { base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) decErr := errors.New("decorator boom") @@ -70,7 +70,7 @@ func TestNewDecoratingFactory_DecoratorError_ClosesSession(t *testing.T) { return nil, decErr }) - _, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + _, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) require.ErrorIs(t, err, decErr) } @@ -85,7 +85,7 @@ func TestNewDecoratingFactory_SecondDecoratorError_ClosesCurrentSession(t *testi base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) decErr := errors.New("second decorator boom") @@ -93,7 +93,7 @@ func TestNewDecoratingFactory_SecondDecoratorError_ClosesCurrentSession(t *testi dec2 := func(_ context.Context, _ session.MultiSession) (session.MultiSession, error) { return nil, decErr } factory := session.NewDecoratingFactory(base, dec1, dec2) - _, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + _, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) require.ErrorIs(t, err, decErr) } @@ -106,14 +106,14 @@ func TestNewDecoratingFactory_NilReturnWithNoError_ClosesSession(t *testing.T) { base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) factory := session.NewDecoratingFactory(base, func(_ context.Context, _ session.MultiSession) (session.MultiSession, error) { return nil, nil // buggy decorator }) - _, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + _, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "nil session") } @@ -127,7 +127,7 @@ func TestNewDecoratingFactory_CloseErrorOnDecoratorFailure_DoesNotSuppressOrigin base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) decErr := errors.New("decorator error") @@ -135,7 +135,7 @@ func TestNewDecoratingFactory_CloseErrorOnDecoratorFailure_DoesNotSuppressOrigin return nil, decErr }) - _, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + _, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) // The original decorator error, not the close error, is returned. require.ErrorIs(t, err, decErr) } @@ -149,14 +149,14 @@ func TestNewDecoratingFactory_HappyPath_ReturnsFinalSession(t *testing.T) { base := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) base.EXPECT(). - MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(sess, nil) factory := session.NewDecoratingFactory(base, func(_ context.Context, _ session.MultiSession) (session.MultiSession, error) { return finalSess, nil }, ) - got, err := factory.MakeSessionWithID(context.Background(), "id", nil, true, nil) + got, err := factory.MakeSessionWithID(context.Background(), "id", nil, nil) require.NoError(t, err) assert.Equal(t, finalSess, got) } diff --git a/pkg/vmcp/session/default_session_test.go b/pkg/vmcp/session/default_session_test.go index dfa9cb126e..ff99cc5716 100644 --- a/pkg/vmcp/session/default_session_test.go +++ b/pkg/vmcp/session/default_session_test.go @@ -549,7 +549,7 @@ func TestNewSessionFactory_MakeSession(t *testing.T) { t.Parallel() factory := newSessionFactoryWithConnector(successConnector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) require.NotNil(t, sess) @@ -567,9 +567,9 @@ func TestNewSessionFactory_MakeSession(t *testing.T) { t.Parallel() factory := newSessionFactoryWithConnector(successConnector) - s1, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + s1, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) - s2, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + s2, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) assert.NotEqual(t, s1.ID(), s2.ID()) @@ -582,7 +582,7 @@ func TestNewSessionFactory_MakeSession(t *testing.T) { t.Parallel() factory := newSessionFactoryWithConnector(successConnector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, nil) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, nil) require.NoError(t, err) require.NotNil(t, sess) @@ -598,7 +598,7 @@ func TestNewSessionFactory_MakeSession(t *testing.T) { factory := newSessionFactoryWithConnector(successConnector) // Mix of valid and nil entries; nil must not cause a panic. backends := []*vmcp.Backend{nil, backend, nil} - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, backends) require.NoError(t, err) require.NotNil(t, sess) @@ -626,7 +626,7 @@ func TestNewSessionFactory_PartialInitialisation(t *testing.T) { } factory := newSessionFactoryWithConnector(connector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, backends) require.NoError(t, err, "partial init must not return an error") require.NotNil(t, sess) @@ -685,7 +685,7 @@ func TestNewSessionFactory_ConnectorReturnsNilWithoutError(t *testing.T) { } factory := newSessionFactoryWithConnector(wrappedConnector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err) require.NotNil(t, sess) assert.Empty(t, sess.Tools()) @@ -712,7 +712,7 @@ func TestNewSessionFactory_ConnectorReturnsConnWithError(t *testing.T) { } factory := newSessionFactoryWithConnector(connector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err, "partial failure must not abort the session") require.NotNil(t, sess) assert.Empty(t, sess.Tools()) @@ -741,7 +741,7 @@ func TestNewSessionFactory_CapabilityNameConflictIsResolvedDeterministically(t * } factory := newSessionFactoryWithConnector(connector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, backends) require.NoError(t, err) require.NotNil(t, sess) defer func() { require.NoError(t, sess.Close()) }() @@ -771,7 +771,7 @@ func TestNewSessionFactory_AllBackendsFail(t *testing.T) { } factory := newSessionFactoryWithConnector(connector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err, "all-fail must still return a valid (empty) session") require.NotNil(t, sess) @@ -795,7 +795,7 @@ func TestNewSessionFactory_BackendInitTimeout(t *testing.T) { } factory := newSessionFactoryWithConnector(connector, WithBackendInitTimeout(50*time.Millisecond)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, []*vmcp.Backend{backend}) require.NoError(t, err, "timeout is a partial failure, not a hard error") require.NotNil(t, sess) @@ -844,7 +844,7 @@ func TestNewSessionFactory_ParallelInit(t *testing.T) { } factory := newSessionFactoryWithConnector(connector, WithMaxBackendInitConcurrency(3)) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, backends) require.NoError(t, err) // All backends must have been initialised. @@ -872,46 +872,54 @@ func TestNewSessionFactory_MakeSession_Metadata(t *testing.T) { } tests := []struct { - name string - connector backendConnector - identity *auth.Identity - backends []*vmcp.Backend - wantSubject string // non-empty → assert equal; empty → assert key absent - wantBackendIDs string // always asserted equal (key is always written, "" for zero backends) + name string + connector backendConnector + identity *auth.Identity + backends []*vmcp.Backend + wantIdentityBinding string // expected MetadataKeyIdentityBinding value + wantBackendIDs string // always asserted equal (key is always written, "" for zero backends) }{ { - name: "sets identity subject and backend IDs", - connector: successConnector, - identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}}, - backends: []*vmcp.Backend{backend1}, - wantSubject: "user-123", - wantBackendIDs: "b1", + // Identity with Subject but no Claims — no extractable (iss, sub) + // pair → binding is the unauthenticated sentinel. + name: "writes unauthenticated sentinel when identity has no iss/sub claims", + connector: successConnector, + identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-123"}}, + backends: []*vmcp.Backend{backend1}, + wantIdentityBinding: "unauthenticated", + wantBackendIDs: "b1", }, { - name: "omits subject when identity is nil", - connector: successConnector, - identity: nil, - backends: []*vmcp.Backend{backend1}, - wantBackendIDs: "b1", + // Nil identity → unauthenticated sentinel. + name: "writes unauthenticated sentinel when identity is nil", + connector: successConnector, + identity: nil, + backends: []*vmcp.Backend{backend1}, + wantIdentityBinding: "unauthenticated", + wantBackendIDs: "b1", }, { - name: "omits subject when subject is empty", - connector: successConnector, - identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: ""}}, - backends: []*vmcp.Backend{backend1}, - wantBackendIDs: "b1", + // Empty Subject, no Claims → unauthenticated sentinel. + name: "writes unauthenticated sentinel when identity is empty", + connector: successConnector, + identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: ""}}, + backends: []*vmcp.Backend{backend1}, + wantIdentityBinding: "unauthenticated", + wantBackendIDs: "b1", }, { - name: "backend IDs are sorted", - connector: successConnector, - backends: []*vmcp.Backend{backend2, backend1}, // intentionally reversed - wantBackendIDs: "b1,b2", + name: "backend IDs are sorted", + connector: successConnector, + backends: []*vmcp.Backend{backend2, backend1}, // intentionally reversed + wantIdentityBinding: "unauthenticated", + wantBackendIDs: "b1,b2", }, { - name: "writes empty backend IDs when no backends connect", - connector: failConnector, - backends: []*vmcp.Backend{backend1}, - wantBackendIDs: "", // key present, value empty — explicit zero-backend sentinel + name: "writes empty backend IDs when no backends connect", + connector: failConnector, + backends: []*vmcp.Backend{backend1}, + wantIdentityBinding: "unauthenticated", + wantBackendIDs: "", // key present, value empty — explicit zero-backend sentinel }, } @@ -920,19 +928,17 @@ func TestNewSessionFactory_MakeSession_Metadata(t *testing.T) { t.Parallel() factory := newSessionFactoryWithConnector(tt.connector) - sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), tt.identity, true, tt.backends) + sess, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), tt.identity, tt.backends) require.NoError(t, err) require.NotNil(t, sess) defer func() { require.NoError(t, sess.Close()) }() meta := sess.GetMetadata() - if tt.wantSubject != "" { - assert.Equal(t, tt.wantSubject, meta[MetadataKeyIdentitySubject]) - } else { - _, ok := meta[MetadataKeyIdentitySubject] - assert.False(t, ok, "identity subject key should be absent") - } + // MetadataKeyIdentityBinding is always written by BindSession. + bindingVal, bindingPresent := meta[MetadataKeyIdentityBinding] + assert.True(t, bindingPresent, "MetadataKeyIdentityBinding must always be written") + assert.Equal(t, tt.wantIdentityBinding, bindingVal) // MetadataKeyBackendIDs is always written (even "" for zero backends). backendIDsVal, backendIDsPresent := meta[MetadataKeyBackendIDs] @@ -1156,11 +1162,11 @@ func TestMakeSessionWithID_InvalidIDReturnsError(t *testing.T) { return nil, nil, nil }) - _, err := f.MakeSessionWithID(context.Background(), "", nil, true, nil) + _, err := f.MakeSessionWithID(context.Background(), "", nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "must not be empty") - _, err = f.MakeSessionWithID(context.Background(), "bad id", nil, true, nil) + _, err = f.MakeSessionWithID(context.Background(), "bad id", nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid character") } diff --git a/pkg/vmcp/session/factory.go b/pkg/vmcp/session/factory.go index b2c5b6c9bb..1a0b43ec08 100644 --- a/pkg/vmcp/session/factory.go +++ b/pkg/vmcp/session/factory.go @@ -19,6 +19,7 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/aggregator" vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" "github.com/stacklok/toolhive/pkg/vmcp/session/internal/backend" "github.com/stacklok/toolhive/pkg/vmcp/session/internal/security" sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" @@ -28,11 +29,6 @@ const ( defaultMaxBackendInitConcurrency = 10 defaultBackendInitTimeout = 30 * time.Second - // MetadataKeyIdentitySubject is the transport-session metadata key that - // holds the subject claim of the authenticated caller (identity.Subject). - // Set at session creation; empty for anonymous callers. - MetadataKeyIdentitySubject = "vmcp.identity.subject" - // MetadataKeyBackendIDs is the transport-session metadata key that holds // a comma-separated, sorted list of successfully-connected backend IDs. // The key is always written, even as an empty string for zero-backend @@ -46,18 +42,6 @@ const ( MetadataKeyBackendSessionPrefix = "vmcp.backend.session." ) -var ( - // defaultHMACSecret is the fallback HMAC secret used when WithHMACSecret is not provided. - // WARNING: This is INSECURE and should ONLY be used for testing/development. - // Production deployments MUST provide a secure secret via WithHMACSecret option. - // - // NOTE: In multi-replica deployments, all replicas must use the same HMAC secret, - // injected via the VMCP_SESSION_HMAC_SECRET environment variable. If replicas use - // different secrets, cross-pod token validation will silently reject legitimate - // callers. The default insecure secret must NOT be used in production. - defaultHMACSecret = []byte("insecure-default-for-testing-only-change-in-production") -) - // MultiSessionFactory creates new MultiSessions for connecting clients. type MultiSessionFactory interface { // MakeSessionWithID creates a new MultiSession with a specific session ID. @@ -67,9 +51,8 @@ type MultiSessionFactory interface { // The id parameter must be non-empty and should be a valid MCP session ID // (visible ASCII characters, 0x21 to 0x7E per the MCP specification). // - // The allowAnonymous parameter controls whether the session allows nil caller - // identity. If false, all session method calls must provide a valid caller - // that matches the session creator's identity. + // Whether the session allows anonymous (nil) caller identity is derived + // internally from identity via ShouldAllowAnonymous. // // All other behaviour (partial initialisation, bounded concurrency, etc.) // is identical to MakeSession. @@ -77,18 +60,19 @@ type MultiSessionFactory interface { ctx context.Context, id string, identity *auth.Identity, - allowAnonymous bool, backends []*vmcp.Backend, ) (MultiSession, error) // RestoreSession reconstructs a live MultiSession from persisted metadata. // It reconnects to the backends whose IDs are listed in storedMetadata under // MetadataKeyBackendIDs, rebuilds the routing table, and reapplies the - // hijack-prevention decorator using the stored token hash and salt. + // session-binding decorator from the stored identity binding. // // Use this when the node-local session cache misses — for example after a // pod restart or when a request is routed to a different pod. It is more // expensive than a cache hit because it opens new backend connections. + // Because MCP clients cannot be serialised, sticky sessions (session affinity + // at the load balancer) minimise how often this path is taken. // // allBackends is the current backend list from the registry; RestoreSession // filters it to the subset originally included in this session. @@ -141,7 +125,6 @@ type defaultMultiSessionFactory struct { backendInitTimeout time.Duration backendRequestTimeout time.Duration perWorkloadRequestTimeouts map[string]time.Duration - hmacSecret []byte // Server-managed secret for HMAC-SHA256 token hashing aggregator aggregator.Aggregator // Optional: applies tool transforms (overrides, conflict resolution, filter) onCapabilityChange func(backendID string) // Late-bound callback for capability-change notifications } @@ -193,27 +176,6 @@ func WithBackendRequestTimeouts( } } -// WithHMACSecret sets the server-managed secret used for HMAC-SHA256 token hashing. -// The secret should be 32+ bytes and loaded from secure configuration (e.g., environment -// variable, secret management system). -// -// The secret is defensively copied to prevent external modification after assignment. -// Empty or nil secrets are rejected (function is a no-op) to prevent accidental security downgrades. -// -// If not set, a default insecure secret is used (NOT RECOMMENDED for production). -func WithHMACSecret(secret []byte) MultiSessionFactoryOption { - return func(f *defaultMultiSessionFactory) { - // Reject empty/nil secrets to prevent silent security downgrade - if len(secret) == 0 { - slog.Warn("WithHMACSecret: empty or nil secret rejected, falling back to default insecure secret", - "recommendation", "provide a secure secret via VMCP_SESSION_HMAC_SECRET environment variable") - return - } - // Make a defensive copy to prevent external modification - f.hmacSecret = append([]byte(nil), secret...) - } -} - // WithAggregator configures the factory to apply per-backend tool overrides, // conflict resolution, and advertising filters when building sessions. // If not set, raw backend tool names are used unchanged. @@ -241,7 +203,6 @@ func NewSessionFactory(registry vmcpauth.OutgoingAuthRegistry, opts ...MultiSess f := &defaultMultiSessionFactory{ maxConcurrency: defaultMaxBackendInitConcurrency, backendInitTimeout: defaultBackendInitTimeout, - hmacSecret: defaultHMACSecret, } for _, opt := range opts { opt(f) @@ -269,7 +230,6 @@ func newSessionFactoryWithConnector(connector backendConnector, opts ...MultiSes connector: connector, maxConcurrency: defaultMaxBackendInitConcurrency, backendInitTimeout: defaultBackendInitTimeout, - hmacSecret: defaultHMACSecret, // Initialize with default (insecure) secret } for _, opt := range opts { opt(f) @@ -416,29 +376,11 @@ func (f *defaultMultiSessionFactory) MakeSessionWithID( ctx context.Context, id string, identity *auth.Identity, - allowAnonymous bool, backends []*vmcp.Backend, ) (MultiSession, error) { if err := validateSessionID(id); err != nil { return nil, err } - - // Validate allowAnonymous is consistent with identity to prevent security footguns. - // If identity has a token, allowAnonymous must be false (caller wants a bound session). - // If identity is nil or has no token, allowAnonymous should be true (anonymous session). - if identity != nil && identity.Token != "" && allowAnonymous { - return nil, fmt.Errorf( - "invalid session configuration: cannot create anonymous session " + - "(allowAnonymous=true) with bearer token (identity.Token is non-empty)", - ) - } - if (identity == nil || identity.Token == "") && !allowAnonymous { - return nil, fmt.Errorf( - "invalid session configuration: cannot create bound session " + - "(allowAnonymous=false) without bearer token (identity is nil or has empty token)", - ) - } - return f.makeSession(ctx, id, identity, backends) } @@ -473,17 +415,15 @@ func populateBackendMetadata(transportSess transportsession.Session, results []i transportSess.SetMetadata(MetadataKeyBackendSessionPrefix+r.target.WorkloadID, sessID) } } - // Always write MetadataKeyBackendIDs, even for zero-backend sessions (""). - // This distinguishes an explicit zero-backend state from absent/corrupted metadata - // in RestoreSession, preventing filterBackendsByStoredIDs from silently - // falling back to all backends when the key is missing. + // Always write MetadataKeyBackendIDs — key presence distinguishes explicit + // zero-backend from absent/corrupted metadata (see const doc). transportSess.SetMetadata(MetadataKeyBackendIDs, strings.Join(ids, ",")) } // makeBaseSession initialises backends and assembles a defaultMultiSession -// WITHOUT applying the hijack-prevention security wrapper. +// WITHOUT applying the session-binding security wrapper. // Callers are responsible for wrapping the result with the appropriate decorator -// (PreventSessionHijacking for new sessions, RestoreHijackPrevention for restored ones). +// (BindSession for new sessions, RestoreSessionBinding for restored ones). func (f *defaultMultiSessionFactory) makeBaseSession( ctx context.Context, sessID string, @@ -555,9 +495,6 @@ func (f *defaultMultiSessionFactory) makeBaseSession( } transportSess := transportsession.NewStreamableSession(sessID) - if identity != nil && identity.Subject != "" { - transportSess.SetMetadata(MetadataKeyIdentitySubject, identity.Subject) - } populateBackendMetadata(transportSess, results) return &defaultMultiSession{ @@ -575,7 +512,7 @@ func (f *defaultMultiSessionFactory) makeBaseSession( } // makeSession is the shared implementation for MakeSession and MakeSessionWithID. -// It builds the base session via makeBaseSession, then applies the hijack-prevention +// It builds the base session via makeBaseSession, then applies the session-binding // security wrapper using the caller's identity. func (f *defaultMultiSessionFactory) makeSession( ctx context.Context, @@ -588,9 +525,10 @@ func (f *defaultMultiSessionFactory) makeSession( return nil, err } - // Apply hijack prevention: computes token binding, stores metadata, and wraps - // the session with validation logic. - decorated, err := security.PreventSessionHijacking(baseSession, f.hmacSecret, identity) + // Apply session binding: extracts the (iss, sub) identity tuple, stores it in + // session metadata under MetadataKeyIdentityBinding, and wraps the session with + // validation logic that checks every subsequent caller against that binding. + decorated, err := security.BindSession(baseSession, identity) if err != nil { _ = baseSession.Close() return nil, err @@ -600,8 +538,10 @@ func (f *defaultMultiSessionFactory) makeSession( // RestoreSession implements MultiSessionFactory. // It reconnects to the backends whose IDs are listed in storedMetadata, rebuilds -// the routing table, and reapplies the hijack-prevention decorator from the stored -// token hash and salt — without recomputing them from a (unavailable) token. +// the routing table, and reapplies the session-binding decorator from the stored +// identity binding. Because the original bearer token is not available at restore +// time, identity is reconstructed from the (iss, sub) tuple in +// MetadataKeyIdentityBinding. func (f *defaultMultiSessionFactory) RestoreSession( ctx context.Context, id string, @@ -625,15 +565,54 @@ func (f *defaultMultiSessionFactory) RestoreSession( // Filter allBackends to the subset originally connected in this session. filteredBackends := filterBackendsByStoredIDs(allBackends, storedBackendIDs) + // Reconstruct identity from the stored identity binding so makeBaseSession + // can pass it to backend connectors (e.g. for outgoing auth injection). + // The original bearer token is not available at restore time — only the + // (iss, sub) tuple stored in MetadataKeyIdentityBinding is used. + storedBinding, hasBinding := storedMetadata[sessiontypes.MetadataKeyIdentityBinding] + if !hasBinding { + // Legacy token-hash key present confirms not corrupted — safe to invalidate. + if _, hasLegacy := storedMetadata[sessiontypes.MetadataKeyTokenHash]; hasLegacy { + slog.Warn("RestoreSession: legacy session missing identity binding; invalidating", + "reason", "legacy_session_missing_identity_binding", + ) + return nil, transportsession.ErrSessionNotFound + } + return nil, fmt.Errorf("RestoreSession: %q metadata key absent (corrupted session metadata)", + sessiontypes.MetadataKeyIdentityBinding) + } + + // Restore identity from the stored (iss, sub) binding. Token is intentionally + // empty — the original bearer token is not persisted. Outgoing-auth strategies + // must derive credentials from Claims or a token store keyed by tsid; a + // strategy that reads identity.Token will reject the request with an + // identity-has-no-token error. The anonymous sentinel (identity == nil) is + // handled by the IsUnauthenticated guard below. var identity *auth.Identity - if caller, ok := auth.IdentityFromContext(ctx); ok && ownsStoredSession(caller, storedMetadata) { + if !binding.IsUnauthenticated(storedBinding) { + iss, sub, ok := binding.Parse(storedBinding) + if !ok { + return nil, fmt.Errorf("RestoreSession: stored identity binding is malformed: %q", storedBinding) + } + identity = &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: sub, + Claims: map[string]any{ + "iss": iss, + "sub": sub, + }, + }, + } + } + + // DocPlanner fork: when the request restoring the session comes from the + // session's owner, restore with the owner's live identity so outgoing-auth + // strategies get a current token. Any other caller's identity is cleared + // from the context so it cannot leak into backend requests. + if caller, ok := auth.IdentityFromContext(ctx); ok && ownsStoredSession(caller, storedBinding) { identity = caller } else { ctx = context.WithValue(ctx, auth.IdentityContextKey{}, (*auth.Identity)(nil)) - if subject := storedMetadata[MetadataKeyIdentitySubject]; subject != "" { - identity = &auth.Identity{} - identity.Subject = subject - } } // Extract stored per-backend session IDs as hints so each backend can @@ -646,56 +625,49 @@ func (f *defaultMultiSessionFactory) RestoreSession( } // Build the base session (backend connections + routing table) without the - // security wrapper. The wrapper is applied separately using stored hash/salt. + // security wrapper. The wrapper is applied separately below. baseSession, err := f.makeBaseSession(ctx, id, identity, filteredBackends, sessionHints) if err != nil { return nil, fmt.Errorf("RestoreSession: failed to rebuild backend connections: %w", err) } - // Restore only the security keys (token hash and salt) from stored metadata. - // MetadataKeyIdentitySubject is already set by makeBaseSession via the - // reconstructed identity. MetadataKeyBackendIDs and the per-backend session - // keys (MetadataKeyBackendSessionPrefix.*) are freshly computed by - // makeBaseSession from the actual reconnected backends; overwriting them with - // stored values would make metadata inconsistent if any backend failed to - // reconnect during restore. - for _, key := range []string{ - sessiontypes.MetadataKeyOwnerURL, - sessiontypes.MetadataKeyTokenHash, - sessiontypes.MetadataKeyTokenSalt, - } { - if v, ok := storedMetadata[key]; ok { - baseSession.SetMetadata(key, v) - } + // Restore only the identity-binding key (and, in the fork, the session-owner + // URL used for cross-replica forwarding) from stored metadata. The other + // keys (MetadataKeyBackendIDs, MetadataKeyBackendSessionPrefix.*) are + // freshly computed by makeBaseSession from the actual reconnected backends; + // overwriting them with stored values would make metadata inconsistent if + // any backend failed to reconnect during restore. + baseSession.SetMetadata(sessiontypes.MetadataKeyIdentityBinding, storedBinding) + if ownerURL, ok := storedMetadata[sessiontypes.MetadataKeyOwnerURL]; ok { + baseSession.SetMetadata(sessiontypes.MetadataKeyOwnerURL, ownerURL) } - // Recreate the hijack-prevention decorator using the stored hash and salt, - // not by recomputing from identity.Token (which is unavailable at restore time). - // - // Fail closed if the token-hash key is entirely absent from stored metadata: - // PreventSessionHijacking always writes the key (empty string for anonymous, - // non-empty for authenticated), so an absent key indicates corrupted or - // truncated metadata — not a legitimately anonymous session. - storedHash, hashKeyPresent := storedMetadata[sessiontypes.MetadataKeyTokenHash] - if !hashKeyPresent { - _ = baseSession.Close() - return nil, fmt.Errorf("RestoreSession: token hash metadata key absent (corrupted session metadata)") - } - storedSalt := storedMetadata[sessiontypes.MetadataKeyTokenSalt] - restored, err := security.RestoreHijackPrevention(baseSession, storedHash, storedSalt, f.hmacSecret) + restored, err := security.RestoreSessionBinding(baseSession, storedBinding) if err != nil { _ = baseSession.Close() - return nil, fmt.Errorf("RestoreSession: failed to restore hijack prevention: %w", err) + return nil, fmt.Errorf("RestoreSession: failed to restore session binding: %w", err) } return restored, nil } -func ownsStoredSession(caller *auth.Identity, storedMetadata map[string]string) bool { - if caller == nil || caller.Token == "" || caller.Subject == "" { +// ownsStoredSession reports whether caller is the authenticated owner of a +// session whose stored identity binding is storedBinding. Only callers with a +// live token qualify; the binding is compared on (iss, sub) like the session +// binding decorator does. +func ownsStoredSession(caller *auth.Identity, storedBinding string) bool { + if caller == nil || caller.Token == "" || binding.IsUnauthenticated(storedBinding) { + return false + } + iss, issOK := caller.Claims["iss"].(string) + sub, subOK := caller.Claims["sub"].(string) + if !issOK || !subOK { + return false + } + callerBinding, err := binding.Format(iss, sub) + if err != nil { return false } - return storedMetadata[sessiontypes.MetadataKeyTokenHash] != "" && - caller.Subject == storedMetadata[MetadataKeyIdentitySubject] + return callerBinding == storedBinding } // filterBackendsByStoredIDs returns the subset of allBackends whose ID appears in diff --git a/pkg/vmcp/session/factory_metadata_test.go b/pkg/vmcp/session/factory_metadata_test.go index 81f1ed50fc..4d26808cda 100644 --- a/pkg/vmcp/session/factory_metadata_test.go +++ b/pkg/vmcp/session/factory_metadata_test.go @@ -41,7 +41,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { {ID: "backend-a"}, {ID: "backend-b"}, } - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, backends) require.NoError(t, err) meta := sess.GetMetadata() @@ -56,7 +56,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { t.Parallel() factory := newSessionFactoryWithConnector(nilBackendConnector()) - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, nil) + sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, nil) require.NoError(t, err) meta := sess.GetMetadata() @@ -85,7 +85,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { {ID: "backend-ok"}, {ID: "backend-fail"}, } - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, backends) + sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, backends) require.NoError(t, err) meta := sess.GetMetadata() @@ -123,7 +123,7 @@ func TestRestoreSession_FreshlyPopulatesMetadataKeyBackendIDs(t *testing.T) { sessionID := "restore-test-session" // Create the initial session so we have a real token hash in metadata. - original, err := factory.MakeSessionWithID(t.Context(), sessionID, nil, true, backends) + original, err := factory.MakeSessionWithID(t.Context(), sessionID, nil, backends) require.NoError(t, err) t.Cleanup(func() { _ = original.Close() }) @@ -195,7 +195,7 @@ func TestRestoreSession_PassesStoredSessionHintToConnector(t *testing.T) { } // Create the original session — connector receives empty hints. - original, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, backends) + original, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, backends) require.NoError(t, err) t.Cleanup(func() { _ = original.Close() }) @@ -241,7 +241,7 @@ func TestMakeSession_PassesEmptySessionHintToConnector(t *testing.T) { } factory := newSessionFactoryWithConnector(connector) - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, []*vmcp.Backend{{ID: "backend-a"}}) + sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, []*vmcp.Backend{{ID: "backend-a"}}) require.NoError(t, err) t.Cleanup(func() { _ = sess.Close() }) diff --git a/pkg/vmcp/session/identity_binding_test.go b/pkg/vmcp/session/identity_binding_test.go new file mode 100644 index 0000000000..0363e0bf59 --- /dev/null +++ b/pkg/vmcp/session/identity_binding_test.go @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + transportsession "github.com/stacklok/toolhive/pkg/transport/session" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" + internalbk "github.com/stacklok/toolhive/pkg/vmcp/session/internal/backend" + sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" +) + +// nilBackendConnector is a connector that returns (nil, nil, nil), causing the +// backend to be skipped during init. This lets us exercise session-metadata +// logic without real backend connections. +func nilBackendConnector() backendConnector { + return func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { + return nil, nil, nil + } +} + +// identityWithClaims builds an *auth.Identity whose Claims map is set verbatim +// from claims. Used in tests that need specific claim values without setting +// the Subject field (binding extraction reads only Claims["iss"] and Claims["sub"]). +func identityWithClaims(token string, claims map[string]any) *auth.Identity { + return &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: claims}, + Token: token, + } +} + +func TestMakeSession_StoresIdentityBinding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identity *auth.Identity + wantBinding string + }{ + { + name: "authenticated_oidc", + identity: identityWithClaims("bearer-token", map[string]any{"iss": "https://idp.example", "sub": "user-42"}), + wantBinding: "https://idp.example\x00user-42", + }, + { + name: "nil_identity_anonymous", + identity: nil, + wantBinding: binding.UnauthenticatedSentinel, + }, + { + // LocalUserMiddleware sets Token="" and populates Claims with + // iss="toolhive-local" and sub=. + name: "local_user_shape", + identity: identityWithClaims("", map[string]any{"iss": "toolhive-local", "sub": "alice"}), + wantBinding: "toolhive-local\x00alice", + }, + { + // AnonymousMiddleware (dev-only) sets Token="" with iss="toolhive-local" + // and sub="anonymous". All such sessions share one binding — intentional. + name: "anonymous_middleware_shape", + identity: identityWithClaims("", map[string]any{"iss": "toolhive-local", "sub": "anonymous"}), + wantBinding: "toolhive-local\x00anonymous", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + factory := newSessionFactoryWithConnector(nilBackendConnector()) + sess, err := factory.MakeSessionWithID( + t.Context(), uuid.New().String(), tt.identity, nil, + ) + require.NoError(t, err) + require.NotNil(t, sess) + t.Cleanup(func() { _ = sess.Close() }) + + meta := sess.GetMetadata() + assert.Equal(t, tt.wantBinding, meta[MetadataKeyIdentityBinding], + "MetadataKeyIdentityBinding must equal expected binding") + }) + } +} + +// TestMakeSession_RejectsBoundSessionWithoutIdentifyingClaims verifies the +// ordering invariant from the factory through to BindSession: creating a session +// with an identity that carries no valid (iss, sub) pair returns an error from +// MakeSessionWithID. +func TestMakeSession_RejectsBoundSessionWithoutIdentifyingClaims(t *testing.T) { + t.Parallel() + + factory := newSessionFactoryWithConnector(nilBackendConnector()) + + // Token is present but Claims are empty, so BindSession's extractBindingID fails. + identity := identityWithClaims("x", map[string]any{}) + + _, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), identity, nil) + require.Error(t, err, "session creation must fail when bound identity lacks identifying claims") +} + +func TestRestoreSession_ErrorCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + storedMetadata map[string]string + wantNotFoundErr bool // true → must be ErrSessionNotFound; false → must NOT be + wantErrContains string + }{ + { + // A session carrying the legacy MetadataKeyTokenHash but no + // MetadataKeyIdentityBinding is invalidated with ErrSessionNotFound + // so the MCP client can re-initialize. + name: "legacy_token_hash_only", + storedMetadata: map[string]string{ + MetadataKeyBackendIDs: "", + sessiontypes.MetadataKeyTokenHash: "deadbeefdeadbeef", + }, + wantNotFoundErr: true, + }, + { + // Genuinely corrupted metadata (no binding, no legacy key) must + // NOT masquerade as a session-not-found; it is a distinct error. + name: "absent_identity_binding_key", + storedMetadata: map[string]string{ + MetadataKeyBackendIDs: "", + }, + wantNotFoundErr: false, + wantErrContains: "absent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + factory := newSessionFactoryWithConnector(nilBackendConnector()) + _, err := factory.RestoreSession(t.Context(), uuid.New().String(), tt.storedMetadata, nil) + require.Error(t, err) + + if tt.wantNotFoundErr { + require.True(t, errors.Is(err, transportsession.ErrSessionNotFound), + "legacy token-hash-only session must return ErrSessionNotFound") + } else { + assert.False(t, errors.Is(err, transportsession.ErrSessionNotFound), + "corrupted metadata must not return ErrSessionNotFound") + } + if tt.wantErrContains != "" { + assert.Contains(t, err.Error(), tt.wantErrContains) + } + }) + } +} + +// TestRestoreSession_PopulatesBothSubjectFieldAndClaims verifies that after +// RestoreSession the reconstructed identity's binding is stored and the +// decorator accepts a matching caller. +func TestRestoreSession_PopulatesBothSubjectFieldAndClaims(t *testing.T) { + t.Parallel() + + factory := newSessionFactoryWithConnector(nilBackendConnector()) + + const storedBinding = "https://idp.example\x00alice" + storedMetadata := map[string]string{ + MetadataKeyBackendIDs: "", + sessiontypes.MetadataKeyIdentityBinding: storedBinding, + } + + sess, err := factory.RestoreSession(t.Context(), uuid.New().String(), storedMetadata, nil) + require.NoError(t, err) + require.NotNil(t, sess) + t.Cleanup(func() { _ = sess.Close() }) + + meta := sess.GetMetadata() + assert.Equal(t, storedBinding, meta[sessiontypes.MetadataKeyIdentityBinding]) + + // Call a tool with the expected identity to verify the decorator accepts it. + caller := identityWithClaims("any-token", map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }) + _, err = sess.CallTool(t.Context(), caller, "nonexistent", nil, nil) + // ErrToolNotFound is acceptable — it means auth passed. + if err != nil { + assert.ErrorIs(t, err, ErrToolNotFound, + "restored session must accept matching caller; any error must be ErrToolNotFound (not auth)") + } +} + +// TestRestoreSession_ReconstructsIdentityWithEmptyTokenButPopulatedClaims pins +// the contract that RestoreSession passes a reconstructed identity to the +// backendConnector whose Token field is intentionally empty (the bearer token is +// not persisted), but whose Claims["iss"] and Claims["sub"] are populated from +// the stored identity binding. +func TestRestoreSession_ReconstructsIdentityWithEmptyTokenButPopulatedClaims(t *testing.T) { + t.Parallel() + + const ( + origIss = "https://idp.example" + origSub = "carol" + ) + + var capturedIdentity *auth.Identity + capturingConnector := func( + _ context.Context, + _ *vmcp.BackendTarget, + id *auth.Identity, + _ string, + ) (internalbk.Session, *vmcp.CapabilityList, error) { + capturedIdentity = id + return nil, nil, nil + } + + // Step 1: create the original session with an authenticated identity. + originalIdentity := identityWithClaims("bearer-AT1", map[string]any{ + "iss": origIss, + "sub": origSub, + }) + + factory := newSessionFactoryWithConnector(capturingConnector) + multiSess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), originalIdentity, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = multiSess.Close() }) + + // Step 2: capture the persisted metadata (simulates what Redis would hold). + meta := multiSess.GetMetadata() + require.NotEmpty(t, meta[sessiontypes.MetadataKeyIdentityBinding], + "factory must write MetadataKeyIdentityBinding to metadata") + + capturedIdentity = nil + + // Step 3: restore the session on "Pod B" with a backend present so the + // connector is actually invoked. + backend := &vmcp.Backend{ + ID: "test-backend", + Name: "test-backend", + } + storedMeta := make(map[string]string, len(meta)+1) + for k, v := range meta { + storedMeta[k] = v + } + storedMeta[MetadataKeyBackendIDs] = backend.ID + + restored, err := factory.RestoreSession(t.Context(), uuid.New().String(), storedMeta, []*vmcp.Backend{backend}) + require.NoError(t, err) + t.Cleanup(func() { _ = restored.Close() }) + + // Step 4: the connector must receive an identity with an empty Token but + // populated Claims. Any outgoing-auth strategy that reads identity.Token + // will silently produce unauthenticated backend requests after a pod restart. + require.NotNil(t, capturedIdentity, "connector must be called with a non-nil identity for an authenticated session") + assert.Empty(t, capturedIdentity.Token, + "restored identity.Token must be empty — bearer token is not persisted across pod restarts") + assert.Equal(t, origIss, capturedIdentity.Claims["iss"], + "restored identity.Claims[iss] must match original issuer") + assert.Equal(t, origSub, capturedIdentity.Claims["sub"], + "restored identity.Claims[sub] must match original subject") + assert.Equal(t, origSub, capturedIdentity.Subject, + "restored identity.Subject must match original subject") +} diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go index 4234b73989..d73258daaf 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -19,11 +19,13 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/versions" "github.com/stacklok/toolhive/pkg/vmcp" vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" "github.com/stacklok/toolhive/pkg/vmcp/conversion" + "github.com/stacklok/toolhive/pkg/vmcp/headerforward" ) const ( @@ -59,36 +61,42 @@ func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return a.base.RoundTrip(reqClone) } -// identityRoundTripper propagates the caller's identity to outgoing backend requests. +// identityRoundTripper propagates a fallback identity to outgoing backend +// requests when the request context carries none. It is the session-backed +// twin of identityPropagatingRoundTripper in pkg/vmcp/client/client.go, which +// holds the canonical description of the #5323 fallback-only identity +// invariant. +// +// Differences from the canonical twin: +// - No isHealthCheck propagation. Health probes do not flow through +// session-backed clients — they go through the per-call client in +// pkg/vmcp/client. If a future change routes health probes through this +// connector, mirror the isHealthCheck pattern from client.go so the +// Close() DELETE built from context.Background() retains the marker. +// +// Consolidation is tracked by #5333; until then, keep the fallback-only +// invariant in sync across both implementations. type identityRoundTripper struct { - base http.RoundTripper - identity *auth.Identity + base http.RoundTripper + // fallbackIdentity is injected only when req.Context() carries no identity. + // It must never override a non-nil identity present on the request context. + // + // Shared across all concurrent RoundTrip invocations on this transport; the + // pointed-to *auth.Identity (including UpstreamTokens) MUST be treated as + // immutable — see pkg/auth/identity.go. + fallbackIdentity *auth.Identity } -// RoundTrip makes sure every backend request carries an identity for the auth -// strategies to consume. -// -// The identity already present in the request context wins: the incoming auth -// middleware populates it per request, so its caller token and upstream tokens -// are current. The identity captured when the client was created is only a -// fallback for background work that has no caller in the context (health -// checks, session refresh, session restore). Always preferring the captured -// identity would keep injecting the tokens that were valid when the session -// was built, and those expire long before a long-lived session does. +// RoundTrip preserves any identity already on req.Context() and only injects +// the captured fallback when the request context carries no identity. func (i *identityRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if identity := i.effectiveIdentity(req.Context()); identity != nil { - req = req.Clone(auth.WithIdentity(req.Context(), identity)) + if _, hasIdentity := auth.IdentityFromContext(req.Context()); !hasIdentity && i.fallbackIdentity != nil { + ctx := auth.WithIdentity(req.Context(), i.fallbackIdentity) + req = req.Clone(ctx) } return i.base.RoundTrip(req) } -func (i *identityRoundTripper) effectiveIdentity(ctx context.Context) *auth.Identity { - if current, ok := auth.IdentityFromContext(ctx); ok && current != nil { - return current - } - return i.identity -} - // Compile-time assertion: mcpSession must implement Session. var _ Session = (*mcpSession)(nil) @@ -372,6 +380,11 @@ func (c *mcpSession) GetPrompt( // when the backend sends a tools/list_changed or resources/list_changed // notification. Pass nil to ignore capability change notifications. // +// A single secrets.EnvironmentProvider is constructed once per connector and +// shared across every session it creates; its lifetime matches the connector's. +// It is consumed by BuildHeaderForwardTripper to resolve secret-backed entries +// in target.HeaderForward. +// // defaultRequestTimeout applies to streamable-HTTP backends. Entries in // perWorkloadRequestTimeouts override it by BackendTarget.WorkloadID. Invalid // or absent values retain the built-in timeout. @@ -389,6 +402,7 @@ func NewHTTPConnector( // The restore-time session hint is intentionally not forwarded: backends // that reject unknown Mcp-Session-Id values would fail the restore, and // lost backend sessions are already recovered through reconnect. + provider := secrets.NewEnvironmentProvider() return func( ctx context.Context, target *vmcp.BackendTarget, @@ -401,7 +415,7 @@ func NewHTTPConnector( perWorkloadRequestTimeouts, ) c, caps, backendSessionID, err := connectMCPClient( - ctx, target, identity, registry, requestTimeout, + ctx, target, identity, registry, requestTimeout, provider, ) if err != nil { return nil, nil, err @@ -412,7 +426,7 @@ func NewHTTPConnector( if _, ok := c.GetTransport().(*mcptransport.StreamableHTTP); ok { reconnect = func(reconnectCtx context.Context) (*mcpclient.Client, string, error) { newClient, _, sessionID, reconnectErr := connectMCPClient( - reconnectCtx, target, identity, registry, requestTimeout, + reconnectCtx, target, identity, registry, requestTimeout, provider, ) if reconnectErr == nil { registerCapabilityChangeNotifications(newClient, target, onCapabilityChange) @@ -453,8 +467,9 @@ func connectMCPClient( identity *auth.Identity, registry vmcpauth.OutgoingAuthRegistry, requestTimeout time.Duration, + provider secrets.Provider, ) (*mcpclient.Client, *vmcp.CapabilityList, string, error) { - c, err := createMCPClient(target, identity, registry, requestTimeout) + c, err := createMCPClient(ctx, target, identity, registry, requestTimeout, provider) if err != nil { return nil, nil, "", fmt.Errorf("failed to create MCP client for backend %s: %w", target.WorkloadID, err) } @@ -477,11 +492,17 @@ func connectMCPClient( // createMCPClient builds and starts a mark3labs MCP client for target. // The transport is started with context.Background() so its lifetime is bound // to client.Close(), not to any caller-supplied init context. +// +// ctx is used only to resolve secret-backed entries in target.HeaderForward at +// client-creation time; the transport itself is started with context.Background() +// as described above. provider supplies values for those secret-backed headers. func createMCPClient( + ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, registry vmcpauth.OutgoingAuthRegistry, requestTimeout time.Duration, + provider secrets.Provider, ) (*mcpclient.Client, error) { // Resolve and validate the auth strategy once at client creation time. strategyName := authtypes.StrategyTypeUnauthenticated @@ -498,7 +519,15 @@ func createMCPClient( slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID) - // Build shared transport chain: auth → identity propagation. + // Build shared transport chain (innermost first → outermost): + // http.DefaultTransport → authRoundTripper → identityRoundTripper → headerForwardRoundTripper + // On an outbound request, the outermost stage runs first: header-forward + // injects its headers onto a request that does not yet carry auth/identity + // headers, then inner stages run and call Set() unconditionally so any + // overlapping name they care about (Authorization, identity headers) wins on + // the wire. Restricted header names (Host, hop-by-hop, X-Forwarded-*) are + // rejected at resolve time by resolveHeaderForward, so user-supplied + // HeaderForward cannot inject them in the first place. // The per-transport sections below may add a size-limiting wrapper on top. base := http.RoundTripper(http.DefaultTransport) base = &authRoundTripper{ @@ -507,7 +536,16 @@ func createMCPClient( authConfig: target.AuthConfig, target: target, } - base = &identityRoundTripper{base: base, identity: identity} + // The identity captured here is a fallback used only when the per-request + // context carries no identity (e.g. mcp-go's Close() DELETE built from + // context.Background()). Normal backend requests preserve the freshly + // refreshed identity placed on the request context by + // auth.TokenValidator.Middleware (see issue #5323). + base = &identityRoundTripper{base: base, fallbackIdentity: identity} + base, err = headerforward.BuildHeaderForwardTripper(ctx, base, target.HeaderForward, provider, target.WorkloadID) + if err != nil { + return nil, fmt.Errorf("failed to build header-forward transport for backend %s: %w", target.WorkloadID, err) + } var c *mcpclient.Client switch target.TransportType { @@ -636,40 +674,66 @@ func initAndQueryCapabilities( if serverCaps.Resources != nil { resResult, listErr := c.ListResources(ctx, mcp.ListResourcesRequest{}) - if listErr != nil { + switch { + case errors.Is(listErr, mcp.ErrMethodNotFound): + // Tolerate JSON-RPC -32601 here so a backend that advertises the + // resources capability but does not implement resources/list (e.g. + // Atlassian Rovo, see #5231) still contributes its tools instead of + // being dropped. HTTP-level method absence is intentionally fatal. + slog.Warn("backend advertised resources capability but does not implement resources/list", + "backendID", target.WorkloadID, + "name", target.WorkloadName, + "baseURL", target.BaseURL, + "method", "resources/list", + ) + case listErr != nil: return nil, fmt.Errorf("list resources failed: %w", listErr) - } - for _, r := range resResult.Resources { - caps.Resources = append(caps.Resources, vmcp.Resource{ - URI: r.URI, - Name: r.Name, - Description: r.Description, - MimeType: r.MIMEType, - BackendID: target.WorkloadID, - }) + default: + for _, r := range resResult.Resources { + caps.Resources = append(caps.Resources, vmcp.Resource{ + URI: r.URI, + Name: r.Name, + Description: r.Description, + MimeType: r.MIMEType, + BackendID: target.WorkloadID, + }) + } } } if serverCaps.Prompts != nil { promptsResult, listErr := c.ListPrompts(ctx, mcp.ListPromptsRequest{}) - if listErr != nil { + switch { + case errors.Is(listErr, mcp.ErrMethodNotFound): + // Tolerate JSON-RPC -32601 here so a backend that advertises the + // prompts capability but does not implement prompts/list (e.g. + // Atlassian Rovo, see #5231) still contributes its tools instead of + // being dropped. HTTP-level method absence is intentionally fatal. + slog.Warn("backend advertised prompts capability but does not implement prompts/list", + "backendID", target.WorkloadID, + "name", target.WorkloadName, + "baseURL", target.BaseURL, + "method", "prompts/list", + ) + case listErr != nil: return nil, fmt.Errorf("list prompts failed: %w", listErr) - } - for _, p := range promptsResult.Prompts { - args := make([]vmcp.PromptArgument, len(p.Arguments)) - for j, a := range p.Arguments { - args[j] = vmcp.PromptArgument{ - Name: a.Name, - Description: a.Description, - Required: a.Required, + default: + for _, p := range promptsResult.Prompts { + args := make([]vmcp.PromptArgument, len(p.Arguments)) + for j, a := range p.Arguments { + args[j] = vmcp.PromptArgument{ + Name: a.Name, + Description: a.Description, + Required: a.Required, + } } + caps.Prompts = append(caps.Prompts, vmcp.Prompt{ + Name: p.Name, + Description: p.Description, + Arguments: args, + BackendID: target.WorkloadID, + }) } - caps.Prompts = append(caps.Prompts, vmcp.Prompt{ - Name: p.Name, - Description: p.Description, - Arguments: args, - BackendID: target.WorkloadID, - }) } } diff --git a/pkg/vmcp/session/internal/backend/mcp_session_capabilities_test.go b/pkg/vmcp/session/internal/backend/mcp_session_capabilities_test.go new file mode 100644 index 0000000000..f1c16f8e80 --- /dev/null +++ b/pkg/vmcp/session/internal/backend/mcp_session_capabilities_test.go @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + mcpclient "github.com/mark3labs/mcp-go/client" + mcptransport "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// fakeBackend is a minimal JSON-RPC over streamable-HTTP fake server. It lets +// each test declare exactly which capabilities to advertise in the initialize +// response and how to respond to each list method, including JSON-RPC error +// codes. This is the surface relied on by initAndQueryCapabilities, and +// constructing it directly avoids the mcp-go server's automatic capability +// inference (which would not let us simulate the spec-violation case where a +// capability is advertised but the corresponding list method returns -32601). +type fakeBackend struct { + t *testing.T + + // Capabilities to advertise in the initialize response. + advertiseTools bool + advertiseResources bool + advertisePrompts bool + + // Optional overrides for list responses. If nil, the server returns an + // empty list of the corresponding type. To inject a JSON-RPC error, + // populate listResourcesErr / listPromptsErr / listToolsErr. + listResourcesErr *jsonRPCError + listPromptsErr *jsonRPCError + listToolsErr *jsonRPCError + + // Tools/resources/prompts to return when the corresponding list method + // is not configured to error. + tools []mcp.Tool + resources []mcp.Resource + prompts []mcp.Prompt + + // methodCalls counts how many times each method was invoked, so tests can + // assert that (e.g.) tools/list was reached after a recoverable + // resources/list failure. + mu sync.Mutex + methodCalls map[string]int + + // headersByMethod records the inbound request headers keyed by JSON-RPC + // method name. Tests asserting transport-chain behavior (e.g. HeaderForward) + // use headersFor(method) to inspect the headers a backend actually saw. + headersByMethod map[string]http.Header +} + +type jsonRPCError struct { + code int + message string +} + +// newFakeBackend wires up an httptest.Server speaking just enough JSON-RPC for +// the streamable-HTTP transport to complete Initialize and the three list +// methods. It returns the server URL to point a client at. +func newFakeBackend(t *testing.T, fb *fakeBackend) string { + t.Helper() + fb.t = t + fb.methodCalls = make(map[string]int) + fb.headersByMethod = make(map[string]http.Header) + + mux := http.NewServeMux() + mux.HandleFunc("/mcp", fb.handle) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts.URL + "/mcp" +} + +func (f *fakeBackend) callCount(method string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.methodCalls[method] +} + +// headersFor returns a clone of the inbound HTTP headers recorded for the most +// recent JSON-RPC request with the given method, or nil if no such request was +// seen. Cloning under the mutex keeps the caller safe from concurrent writes. +// +// The helper accepts any MCP method name; in-tree callers currently converge +// on tools/call, which trips unparam — but the helper must stay +// method-agnostic so future tests can inspect headers for Initialize, etc. +// +//nolint:unparam // method-agnostic test helper; see doc comment above. +func (f *fakeBackend) headersFor(method string) http.Header { + f.mu.Lock() + defer f.mu.Unlock() + h := f.headersByMethod[method] + if h == nil { + return nil + } + return h.Clone() +} + +// handle implements the JSON-RPC subset needed for backend init. The +// streamable-HTTP transport sends POST requests with Accept: +// application/json, text/event-stream — we always reply with +// application/json since we only need single-shot responses. +func (f *fakeBackend) handle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + // The streamable-HTTP transport opens a GET to listen for server-pushed + // notifications. Returning 405 cleanly tells it the server doesn't + // support push, which is fine for our tests. + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + f.t.Errorf("fakeBackend: read body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + + var msg struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(body, &msg); err != nil { + f.t.Errorf("fakeBackend: decode: %v body=%s", err, string(body)) + w.WriteHeader(http.StatusBadRequest) + return + } + + f.mu.Lock() + f.methodCalls[msg.Method]++ + f.headersByMethod[msg.Method] = r.Header.Clone() + f.mu.Unlock() + + // Notifications (no id, e.g. notifications/initialized) get an empty 202. + if len(msg.ID) == 0 || string(msg.ID) == "null" { + w.WriteHeader(http.StatusAccepted) + return + } + + switch msg.Method { + case string(mcp.MethodInitialize): + // Streamable-HTTP servers assign a session id during initialize. + w.Header().Set("Mcp-Session-Id", "test-session") + f.writeInitializeResult(w, msg.ID) + case string(mcp.MethodToolsList): + if f.listToolsErr != nil { + f.writeError(w, msg.ID, f.listToolsErr) + return + } + f.writeResult(w, msg.ID, map[string]any{"tools": f.tools}) + case string(mcp.MethodResourcesList): + if f.listResourcesErr != nil { + f.writeError(w, msg.ID, f.listResourcesErr) + return + } + f.writeResult(w, msg.ID, map[string]any{"resources": f.resources}) + case string(mcp.MethodPromptsList): + if f.listPromptsErr != nil { + f.writeError(w, msg.ID, f.listPromptsErr) + return + } + f.writeResult(w, msg.ID, map[string]any{"prompts": f.prompts}) + case string(mcp.MethodToolsCall): + // Minimal CallToolResult with a single text content. Tests that exercise + // the post-initialize transport chain (e.g. HeaderForward) need a method + // they can invoke after Initialize completes; tools/call is the cheapest. + f.writeResult(w, msg.ID, map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": "ok"}, + }, + "isError": false, + }) + default: + f.writeError(w, msg.ID, &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}) + } +} + +func (f *fakeBackend) writeInitializeResult(w http.ResponseWriter, id json.RawMessage) { + caps := map[string]any{} + if f.advertiseTools { + caps["tools"] = map[string]any{} + } + if f.advertiseResources { + caps["resources"] = map[string]any{} + } + if f.advertisePrompts { + caps["prompts"] = map[string]any{} + } + f.writeResult(w, id, map[string]any{ + "protocolVersion": mcp.LATEST_PROTOCOL_VERSION, + "capabilities": caps, + "serverInfo": map[string]any{"name": "fake-backend", "version": "0.0.0"}, + }) +} + +func (f *fakeBackend) writeResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": json.RawMessage(id), + "result": result, + }); err != nil { + f.t.Errorf("fakeBackend: encode result: %v", err) + } +} + +func (f *fakeBackend) writeError(w http.ResponseWriter, id json.RawMessage, e *jsonRPCError) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": json.RawMessage(id), + "error": map[string]any{ + "code": e.code, + "message": e.message, + }, + }); err != nil { + f.t.Errorf("fakeBackend: encode error: %v", err) + } +} + +// newTestClient builds a streamable-HTTP mark3labs client pointing at url and +// runs Start() so the transport is ready for initAndQueryCapabilities. Cleanup +// is registered via t.Cleanup. +func newTestClient(t *testing.T, url string) *mcpclient.Client { + t.Helper() + c, err := mcpclient.NewStreamableHttpClient(url, mcptransport.WithHTTPBasicClient(&http.Client{})) + require.NoError(t, err) + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// TestInitAndQueryCapabilities_RecoversFromMethodNotFound verifies the issue +// #5231 fix: when a backend advertises resources or prompts capability but +// returns JSON-RPC -32601 to the corresponding list method, init must succeed +// with an empty capability set rather than aborting and dropping the backend's +// tools. +func TestInitAndQueryCapabilities_RecoversFromMethodNotFound(t *testing.T) { + t.Parallel() + + helloTool := mcp.Tool{Name: "hello"} + greetResource := mcp.Resource{ + URI: "file:///greet.txt", + Name: "greet", + Description: "greeting fixture", + MIMEType: "text/plain", + } + echoPrompt := mcp.Prompt{ + Name: "echo", + Description: "echoes its input", + Arguments: []mcp.PromptArgument{ + {Name: "msg", Description: "what to echo", Required: true}, + }, + } + + tests := []struct { + name string + fb *fakeBackend + wantTools int + wantResources int + wantPrompts int + wantToolsCalls int + wantResListCalls int + wantPromptCalls int + assertCaps func(t *testing.T, caps *vmcp.CapabilityList) + }{ + { + name: "resources/list -32601 after server advertised resources", + fb: &fakeBackend{ + advertiseTools: true, + advertiseResources: true, + tools: []mcp.Tool{helloTool}, + listResourcesErr: &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}, + }, + wantTools: 1, + wantResources: 0, + wantPrompts: 0, + wantToolsCalls: 1, + wantResListCalls: 1, + }, + { + name: "prompts/list -32601 after server advertised prompts", + fb: &fakeBackend{ + advertiseTools: true, + advertisePrompts: true, + tools: []mcp.Tool{helloTool}, + listPromptsErr: &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}, + }, + wantTools: 1, + wantResources: 0, + wantPrompts: 0, + wantToolsCalls: 1, + wantPromptCalls: 1, + }, + { + name: "both resources/list and prompts/list -32601", + fb: &fakeBackend{ + advertiseTools: true, + advertiseResources: true, + advertisePrompts: true, + tools: []mcp.Tool{helloTool}, + listResourcesErr: &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}, + listPromptsErr: &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}, + }, + wantTools: 1, + wantResources: 0, + wantPrompts: 0, + wantToolsCalls: 1, + wantResListCalls: 1, + wantPromptCalls: 1, + }, + { + name: "resources/list success populates caps with backend ID", + fb: &fakeBackend{ + advertiseTools: true, + advertiseResources: true, + tools: []mcp.Tool{helloTool}, + resources: []mcp.Resource{greetResource}, + }, + wantTools: 1, + wantResources: 1, + wantPrompts: 0, + wantToolsCalls: 1, + wantResListCalls: 1, + assertCaps: func(t *testing.T, caps *vmcp.CapabilityList) { + t.Helper() + require.Len(t, caps.Resources, 1) + got := caps.Resources[0] + assert.Equal(t, greetResource.URI, got.URI) + assert.Equal(t, greetResource.Name, got.Name) + assert.Equal(t, greetResource.Description, got.Description) + assert.Equal(t, greetResource.MIMEType, got.MimeType) + assert.Equal(t, "fake-backend", got.BackendID) + }, + }, + { + name: "prompts/list success populates caps with arguments and backend ID", + fb: &fakeBackend{ + advertiseTools: true, + advertisePrompts: true, + tools: []mcp.Tool{helloTool}, + prompts: []mcp.Prompt{echoPrompt}, + }, + wantTools: 1, + wantResources: 0, + wantPrompts: 1, + wantToolsCalls: 1, + wantPromptCalls: 1, + assertCaps: func(t *testing.T, caps *vmcp.CapabilityList) { + t.Helper() + require.Len(t, caps.Prompts, 1) + got := caps.Prompts[0] + assert.Equal(t, echoPrompt.Name, got.Name) + assert.Equal(t, echoPrompt.Description, got.Description) + assert.Equal(t, "fake-backend", got.BackendID) + require.Len(t, got.Arguments, 1) + assert.Equal(t, echoPrompt.Arguments[0].Name, got.Arguments[0].Name) + assert.Equal(t, echoPrompt.Arguments[0].Description, got.Arguments[0].Description) + assert.True(t, got.Arguments[0].Required) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + url := newFakeBackend(t, tc.fb) + c := newTestClient(t, url) + target := &vmcp.BackendTarget{ + WorkloadID: "fake-backend", + WorkloadName: "fake-backend", + BaseURL: url, + TransportType: "streamable-http", + } + + caps, err := initAndQueryCapabilities(context.Background(), c, target) + require.NoError(t, err, "init must succeed when list methods return -32601") + require.NotNil(t, caps) + + assert.Len(t, caps.Tools, tc.wantTools) + assert.Len(t, caps.Resources, tc.wantResources) + assert.Len(t, caps.Prompts, tc.wantPrompts) + + // tools/list must have been reached — proving the backend's tool + // surface remains usable even after a recoverable list-method + // failure on resources/prompts. + if tc.wantToolsCalls > 0 { + assert.Equal(t, tc.wantToolsCalls, tc.fb.callCount(string(mcp.MethodToolsList))) + } + if tc.wantResListCalls > 0 { + assert.Equal(t, tc.wantResListCalls, tc.fb.callCount(string(mcp.MethodResourcesList))) + } + if tc.wantPromptCalls > 0 { + assert.Equal(t, tc.wantPromptCalls, tc.fb.callCount(string(mcp.MethodPromptsList))) + } + if tc.assertCaps != nil { + tc.assertCaps(t, caps) + } + }) + } +} + +// TestInitAndQueryCapabilities_FatalErrors verifies the regression guards +// called out in the issue's acceptance criteria: +// - tools/list returning -32601 still aborts init (a backend with no tool +// surface is not useful to expose). +// - non-(-32601) errors from resources/list and prompts/list still abort init +// (we are not silencing arbitrary failures). +func TestInitAndQueryCapabilities_FatalErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fb *fakeBackend + errSubstr string + }{ + { + name: "tools/list -32601 remains fatal", + fb: &fakeBackend{ + advertiseTools: true, + listToolsErr: &jsonRPCError{code: mcp.METHOD_NOT_FOUND, message: "Method not found"}, + }, + errSubstr: "list tools failed", + }, + { + name: "resources/list non-(-32601) remains fatal", + fb: &fakeBackend{ + advertiseTools: true, + advertiseResources: true, + listResourcesErr: &jsonRPCError{code: mcp.INTERNAL_ERROR, message: "boom"}, + }, + errSubstr: "list resources failed", + }, + { + name: "prompts/list non-(-32601) remains fatal", + fb: &fakeBackend{ + advertiseTools: true, + advertisePrompts: true, + listPromptsErr: &jsonRPCError{code: mcp.INVALID_PARAMS, message: "bad params"}, + }, + errSubstr: "list prompts failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + url := newFakeBackend(t, tc.fb) + c := newTestClient(t, url) + target := &vmcp.BackendTarget{ + WorkloadID: "fake-backend", + WorkloadName: "fake-backend", + BaseURL: url, + TransportType: "streamable-http", + } + + _, err := initAndQueryCapabilities(context.Background(), c, target) + require.Error(t, err) + assert.ErrorContains(t, err, tc.errSubstr) + }) + } +} diff --git a/pkg/vmcp/session/internal/backend/mcp_session_header_forward_test.go b/pkg/vmcp/session/internal/backend/mcp_session_header_forward_test.go new file mode 100644 index 0000000000..081f2429f9 --- /dev/null +++ b/pkg/vmcp/session/internal/backend/mcp_session_header_forward_test.go @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// TestHTTPSession_AppliesHeaderForwardToPostInitializeRequests asserts the +// invariants of the HeaderForward transport stage on the session-side +// connector (pkg/vmcp/session/internal/backend). Fixes #5289. +// +// The three subtests cover, in order: +// - plaintext: a user-configured AddPlaintextHeaders entry reaches the +// backend on post-initialize traffic (tools/call). +// - overlap precedence: when an inner auth stage writes the same header +// name that HeaderForward configures, the inner stage's value lands on +// the wire — proving the chain ordering documented on +// headerForwardRoundTripper.RoundTrip. +// - restricted-name rejection: NewHTTPConnector fails fast when +// HeaderForward names a restricted header, proving the +// resolveHeaderForward guard is wired into the session-side connector. +// +// Secret resolution requires t.Setenv and lives in its own non-parallel test +// below (TestHTTPSession_HeaderForward_ResolvesSecretsFromEnv). +func TestHTTPSession_AppliesHeaderForwardToPostInitializeRequests(t *testing.T) { + t.Parallel() + + t.Run("plaintext header reaches backend on tools/call", func(t *testing.T) { + t.Parallel() + + const ( + headerName = "X-MCP-Toolsets" + headerValue = "projects,issues,pull_requests,users,repos" + ) + + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + target := &vmcp.BackendTarget{ + WorkloadID: "header-forward-backend", + WorkloadName: "header-forward-backend", + BaseURL: url, + TransportType: "streamable-http", + HeaderForward: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + headerName: headerValue, + }, + }, + } + + sess := connectAndCallEcho(t, target) + t.Cleanup(func() { _ = sess.Close() }) + + got := fb.headersFor(string(mcp.MethodToolsCall)) + require.NotNil(t, got, "backend never received a tools/call request") + assert.Equal(t, headerValue, got.Get(headerName), + "HeaderForward.AddPlaintextHeaders must reach the backend on post-initialize requests") + }) + + t.Run("inner auth stage wins on overlapping header name", func(t *testing.T) { + t.Parallel() + + // The chain runs outer→inner on the outbound request: + // headerForwardRoundTripper → identityRoundTripper → authRoundTripper → http.DefaultTransport + // headerForwardRoundTripper sets X-Test-Identity first (request didn't + // have it). The inner authRoundTripper's Authenticate() then runs and + // calls Set() unconditionally, overwriting. We assert the auth value + // is the one on the wire. + const ( + headerName = "X-Test-Identity" + headerForwardVal = "from-header-forward" + authStrategyValue = "from-auth" + ) + + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + registry := vmcpauth.NewDefaultOutgoingAuthRegistry() + require.NoError(t, registry.RegisterStrategy( + "test-header-setter", + &testHeaderSettingStrategy{name: "test-header-setter", header: headerName, value: authStrategyValue}, + )) + + target := &vmcp.BackendTarget{ + WorkloadID: "overlap-backend", + WorkloadName: "overlap-backend", + BaseURL: url, + TransportType: "streamable-http", + AuthConfig: &authtypes.BackendAuthStrategy{Type: "test-header-setter"}, + HeaderForward: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + headerName: headerForwardVal, + }, + }, + } + + connector := NewHTTPConnector(registry, nil, 0, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sess, _, err := connector(ctx, target, nil, "") + require.NoError(t, err) + t.Cleanup(func() { _ = sess.Close() }) + + _, err = sess.CallTool(ctx, "echo", map[string]any{}, nil) + require.NoError(t, err) + + got := fb.headersFor(string(mcp.MethodToolsCall)) + require.NotNil(t, got, "backend never received a tools/call request") + assert.Equal(t, authStrategyValue, got.Get(headerName), + "inner auth stage must overwrite HeaderForward on overlapping header names") + }) + + t.Run("restricted header in HeaderForward fails connector at startup", func(t *testing.T) { + t.Parallel() + + // resolveHeaderForward rejects names in middleware.RestrictedHeaders. + // Asserts the guard is reachable from the session-side connector — a + // misconfigured backend surfaces at session creation, not silently as + // a missing header on every request. + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + target := &vmcp.BackendTarget{ + WorkloadID: "restricted-header-backend", + WorkloadName: "restricted-header-backend", + BaseURL: url, + TransportType: "streamable-http", + HeaderForward: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + // X-Forwarded-For is in middleware.RestrictedHeaders — letting + // user config inject it would enable identity-spoofing of the + // caller's IP to the backend. + "X-Forwarded-For": "1.2.3.4", + }, + }, + } + + registry := newTestRegistry(t) + connector := NewHTTPConnector(registry, nil, 0, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, err := connector(ctx, target, nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "restricted", + "connector must reject restricted header names in HeaderForward config") + }) +} + +// TestHTTPSession_HeaderForward_ResolvesSecretsFromEnv asserts that an +// AddHeadersFromSecret entry is resolved via the EnvironmentProvider that +// NewHTTPConnector constructs internally and reaches the backend on +// post-initialize traffic. +// +// Lives in its own non-parallel top-level test because t.Setenv (the only +// way to inject a value into the connector's env-backed secrets.Provider +// without changing production APIs) cannot be used inside a parallel test +// tree. +func TestHTTPSession_HeaderForward_ResolvesSecretsFromEnv(t *testing.T) { + const ( + headerName = "X-GitHub-Auth" + secretID = "GITHUB_PAT" + secretEnvName = secrets.EnvVarPrefix + secretID + secretValue = "ghp_test_value_12345" + ) + t.Setenv(secretEnvName, secretValue) + + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + target := &vmcp.BackendTarget{ + WorkloadID: "secret-header-backend", + WorkloadName: "secret-header-backend", + BaseURL: url, + TransportType: "streamable-http", + HeaderForward: &vmcp.HeaderForwardConfig{ + AddHeadersFromSecret: map[string]string{ + headerName: secretID, + }, + }, + } + + sess := connectAndCallEcho(t, target) + t.Cleanup(func() { _ = sess.Close() }) + + got := fb.headersFor(string(mcp.MethodToolsCall)) + require.NotNil(t, got, "backend never received a tools/call request") + assert.Equal(t, secretValue, got.Get(headerName), + "HeaderForward.AddHeadersFromSecret must be resolved via the env provider and reach the backend") +} + +// connectAndCallEcho builds an HTTP session for target via the default test +// registry, makes a single tools/call("echo"), and returns the open session. +// The caller is responsible for closing the session (typically via t.Cleanup). +func connectAndCallEcho(t *testing.T, target *vmcp.BackendTarget) Session { + t.Helper() + + registry := newTestRegistry(t) + connector := NewHTTPConnector(registry, nil, 0, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + sess, caps, err := connector(ctx, target, nil, "") + require.NoError(t, err, "connector must initialise the backend successfully") + require.NotNil(t, sess, "connector returned nil session") + require.NotNil(t, caps, "connector returned nil capability list") + + _, err = sess.CallTool(ctx, "echo", map[string]any{}, nil) + require.NoError(t, err, "post-initialize CallTool must succeed") + + return sess +} + +// testHeaderSettingStrategy is a vmcpauth.Strategy stand-in that unconditionally +// writes a single header onto every outbound request. Used to drive the +// overlap-precedence assertion: when HeaderForward configures the same name, +// this strategy (called from the inner authRoundTripper) must win on the wire. +type testHeaderSettingStrategy struct { + name string + header string + value string +} + +func (s *testHeaderSettingStrategy) Name() string { return s.name } + +func (s *testHeaderSettingStrategy) Authenticate(_ context.Context, req *http.Request, _ *authtypes.BackendAuthStrategy) error { + req.Header.Set(s.header, s.value) + return nil +} + +func (*testHeaderSettingStrategy) Validate(_ *authtypes.BackendAuthStrategy) error { return nil } diff --git a/pkg/vmcp/session/internal/backend/mcp_session_identity_refresh_test.go b/pkg/vmcp/session/internal/backend/mcp_session_identity_refresh_test.go new file mode 100644 index 0000000000..e0c3aca52b --- /dev/null +++ b/pkg/vmcp/session/internal/backend/mcp_session_identity_refresh_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// TestHTTPSession_PerRequestIdentity_DrivesUpstreamAuthHeader is the +// higher-level regression test for issue #5323. It exercises the full +// transport chain — headerForwardRoundTripper → identityRoundTripper → +// authRoundTripper (UpstreamInjectStrategy) → http.DefaultTransport — and +// asserts that the upstream Authorization header on each tool call reflects +// the identity placed on the per-request context, NOT the identity captured +// at session-init time. +// +// Without the fix, the captured session-init identity would override the +// per-request identity on every outbound tool call, and the upstream would +// see the stale bearer token on the second CallTool. The test would then +// observe the same token on both calls — the symptom users experienced as +// "forced re-auth every ~24h" once the captured access token expired. +// +// Scope: this is the higher-level test called out by the AC5 review feedback. +// It stops short of wiring auth.TokenValidator.Middleware end-to-end (that +// would pull in pkg/auth/upstreamtoken and a JWT signer) and instead injects +// the fresh identity directly onto the per-request context, which is exactly +// what TokenValidator.Middleware does on every authenticated request. +func TestHTTPSession_PerRequestIdentity_DrivesUpstreamAuthHeader(t *testing.T) { + t.Parallel() + + const ( + providerName = "test-provider" + // staleToken is captured at session-creation time. If the + // (pre-fix) bug is reintroduced, the upstream will see this on + // every tool call. + staleToken = "stale-access-token" + // freshToken is placed on the per-request context immediately + // before each tool call. The post-fix transport must let it + // reach the upstream unchanged. + freshToken = "fresh-access-token" + ) + + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + registry := vmcpauth.NewDefaultOutgoingAuthRegistry() + require.NoError(t, registry.RegisterStrategy( + authtypes.StrategyTypeUpstreamInject, + strategies.NewUpstreamInjectStrategy(), + )) + + target := &vmcp.BackendTarget{ + WorkloadID: "identity-refresh-backend", + WorkloadName: "identity-refresh-backend", + BaseURL: url, + TransportType: "streamable-http", + AuthConfig: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUpstreamInject, + UpstreamInject: &authtypes.UpstreamInjectConfig{ProviderName: providerName}, + }, + } + + // staleIdentity is captured at session-init time and stored as the + // fallback. A pre-fix transport would re-inject this on every backend + // request, regardless of what is on the per-request context. + staleIdentity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "user-1"}, + UpstreamTokens: map[string]string{providerName: staleToken}, + } + + connector := NewHTTPConnector(registry, nil, 0, nil) + initCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + sess, _, err := connector(initCtx, target, staleIdentity, "") + require.NoError(t, err, "connector must initialise the backend successfully") + t.Cleanup(func() { _ = sess.Close() }) + + // CallTool with a fresh identity on the per-request context. This + // simulates what auth.TokenValidator.Middleware does on every + // authenticated incoming request: the identity it places on the + // context carries upstream tokens that were transparently refreshed + // by upstreamtoken.InProcessService.GetAllValidTokens. + freshIdentity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "user-1"}, + UpstreamTokens: map[string]string{providerName: freshToken}, + } + callCtx, cancelCall := context.WithTimeout( + auth.WithIdentity(context.Background(), freshIdentity), + 5*time.Second, + ) + t.Cleanup(cancelCall) + + _, err = sess.CallTool(callCtx, "echo", map[string]any{}, nil) + require.NoError(t, err, "tools/call with fresh identity must succeed") + + got := fb.headersFor(string(mcp.MethodToolsCall)) + require.NotNil(t, got, "backend never received a tools/call request") + + // REGRESSION GUARD (#5323): the upstream must see the FRESH token from + // the per-request context, not the stale token captured at session-init. + assert.Equal(t, "Bearer "+freshToken, got.Get("Authorization"), + "per-request identity must drive the upstream Authorization header (#5323)") + assert.NotEqual(t, "Bearer "+staleToken, got.Get("Authorization"), + "captured session-init identity must not override per-request identity (#5323)") +} + +// TestHTTPSession_FallbackIdentity_UsedWhenContextHasNoIdentity verifies the +// complementary invariant: when the per-request context carries no identity +// (e.g. mcp-go's Close() DELETE built from context.Background()), the captured +// session-init identity is used as a fallback so the teardown DELETE remains +// authenticated. This keeps the streamable-HTTP Close() path working without +// a 401-on-teardown regression. +func TestHTTPSession_FallbackIdentity_UsedWhenContextHasNoIdentity(t *testing.T) { + t.Parallel() + + const ( + providerName = "test-provider" + fallbackTokenVal = "fallback-token" + ) + + fb := &fakeBackend{advertiseTools: true, tools: []mcp.Tool{{Name: "echo"}}} + url := newFakeBackend(t, fb) + + registry := vmcpauth.NewDefaultOutgoingAuthRegistry() + require.NoError(t, registry.RegisterStrategy( + authtypes.StrategyTypeUpstreamInject, + strategies.NewUpstreamInjectStrategy(), + )) + + target := &vmcp.BackendTarget{ + WorkloadID: "identity-fallback-backend", + WorkloadName: "identity-fallback-backend", + BaseURL: url, + TransportType: "streamable-http", + AuthConfig: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUpstreamInject, + UpstreamInject: &authtypes.UpstreamInjectConfig{ProviderName: providerName}, + }, + } + + fallbackIdentity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "user-1"}, + UpstreamTokens: map[string]string{providerName: fallbackTokenVal}, + } + + connector := NewHTTPConnector(registry, nil, 0, nil) + initCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + sess, _, err := connector(initCtx, target, fallbackIdentity, "") + require.NoError(t, err, "connector must initialise the backend successfully") + t.Cleanup(func() { _ = sess.Close() }) + + // CallTool with a bare context.Background() — no identity attached. + // This models the teardown path where the SDK rebuilds the request + // from a fresh background context. + callCtx, cancelCall := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancelCall) + + _, err = sess.CallTool(callCtx, "echo", map[string]any{}, nil) + require.NoError(t, err, "tools/call must succeed via fallback identity") + + got := fb.headersFor(string(mcp.MethodToolsCall)) + require.NotNil(t, got, "backend never received a tools/call request") + + // The captured fallback must reach the upstream when the request + // context has nothing to override it with. + assert.Equal(t, "Bearer "+fallbackTokenVal, got.Get("Authorization"), + "fallback identity must be injected when req.Context() carries none") +} diff --git a/pkg/vmcp/session/internal/backend/mcp_session_test.go b/pkg/vmcp/session/internal/backend/mcp_session_test.go index b399d1a3c7..e28852e42d 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session_test.go +++ b/pkg/vmcp/session/internal/backend/mcp_session_test.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "net/http" "testing" "time" @@ -15,7 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/vmcp" vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" @@ -47,7 +46,7 @@ func TestCreateMCPClient_UnsupportedTransport(t *testing.T) { TransportType: transport, } - _, err := createMCPClient(target, nil, newTestRegistry(t), time.Second) + _, err := createMCPClient(context.Background(), target, nil, newTestRegistry(t), time.Second, secrets.NewEnvironmentProvider()) require.Error(t, err) assert.ErrorIs(t, err, vmcp.ErrUnsupportedTransport, "transport %q should return ErrUnsupportedTransport", transport) @@ -104,58 +103,3 @@ func TestIsBackendSessionLostError(t *testing.T) { }) } } - -type identityCapturingRoundTripper struct { - seen *auth.Identity - ok bool -} - -func (rt *identityCapturingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - rt.seen, rt.ok = auth.IdentityFromContext(req.Context()) - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: req}, nil -} - -func TestIdentityRoundTripper_PrefersRequestContextIdentity(t *testing.T) { - t.Parallel() - - captured := &auth.Identity{UpstreamTokens: map[string]string{"cognito": "stale"}} - fresh := &auth.Identity{UpstreamTokens: map[string]string{"cognito": "fresh"}} - - tests := []struct { - name string - ctxIdentity *auth.Identity - captured *auth.Identity - want *auth.Identity - }{ - {name: "request identity wins over the captured one", ctxIdentity: fresh, captured: captured, want: fresh}, - {name: "captured identity is the fallback without a caller", captured: captured, want: captured}, - {name: "no identity at all leaves the request untouched"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - base := &identityCapturingRoundTripper{} - rt := &identityRoundTripper{base: base, identity: tc.captured} - - ctx := context.Background() - if tc.ctxIdentity != nil { - ctx = auth.WithIdentity(ctx, tc.ctxIdentity) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://backend.local/mcp", http.NoBody) - require.NoError(t, err) - - resp, err := rt.RoundTrip(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - - if tc.want == nil { - assert.False(t, base.ok, "no identity should be injected") - return - } - require.True(t, base.ok, "identity should be injected") - assert.Same(t, tc.want, base.seen) - }) - } -} diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go index 40c1135e85..e626d57daa 100644 --- a/pkg/vmcp/session/internal/backend/roundtripper_test.go +++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go @@ -188,13 +188,94 @@ func TestAuthRoundTripper_AuthStrategyReceivesClonedRequest(t *testing.T) { // --------------------------------------------------------------------------- // identityRoundTripper // --------------------------------------------------------------------------- +// +// See identityPropagatingRoundTripper in pkg/vmcp/client/client.go for the +// canonical description of the #5323 fallback-only identity invariant. The +// session-backed twin in mcp_session.go omits health-check propagation +// because health probes do not flow through this connector; if that ever +// changes, mirror the health-check test set from client_test.go here. +// +// Tests below are grouped to reflect this hierarchy: +// 1. Per-request identity (normal path) — *_PerRequestIdentity_* +// 2. Fallback identity (no-identity-on-context) — *_FallbackIdentity_* + +// --- Per-request identity (normal path) ----------------------------------- + +// TestIdentityRoundTripper_PerRequestIdentity_FlowsThroughUnchanged verifies +// the normal per-request flow: identity is on req.Context() (placed by +// TokenValidator.Middleware), no fallback is configured, and the transport +// leaves the identity untouched so the freshly refreshed upstream tokens +// reach the downstream auth strategies. This is the path taken on every +// authenticated tool call. +func TestIdentityRoundTripper_PerRequestIdentity_FlowsThroughUnchanged(t *testing.T) { + t.Parallel() + + base := &okTransport{} + rt := &identityRoundTripper{base: base, fallbackIdentity: nil} + + fresh := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "fresh-user"}, + UpstreamTokens: map[string]string{"provider": "fresh-token"}, + } + ctx := auth.WithIdentity(context.Background(), fresh) + orig := newTestRequest(ctx, t) + + _, err := rt.RoundTrip(orig) + require.NoError(t, err) + + require.NotNil(t, base.received) + got, ok := auth.IdentityFromContext(base.received.Context()) + require.True(t, ok) + assert.Equal(t, "fresh-user", got.Subject) + assert.Equal(t, "fresh-token", got.UpstreamTokens["provider"]) +} + +// TestIdentityRoundTripper_PerRequestIdentity_NotOverriddenByFallback is the +// regression test for issue #5323: a fresh identity already on req.Context() +// (placed there by TokenValidator.Middleware on every incoming request) must +// survive the transport untouched, even when a stale fallback identity is +// captured. Overriding it would silently re-inject stale upstream tokens on +// every backend call, forcing daily re-auth. +func TestIdentityRoundTripper_PerRequestIdentity_NotOverriddenByFallback(t *testing.T) { + t.Parallel() + + stale := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "stale-user"}, + UpstreamTokens: map[string]string{"provider": "stale-token"}, + } + base := &okTransport{} + rt := &identityRoundTripper{base: base, fallbackIdentity: stale} + + fresh := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "fresh-user"}, + UpstreamTokens: map[string]string{"provider": "fresh-token"}, + } + ctx := auth.WithIdentity(context.Background(), fresh) + orig := newTestRequest(ctx, t) + + _, err := rt.RoundTrip(orig) + require.NoError(t, err) + + require.NotNil(t, base.received) + got, ok := auth.IdentityFromContext(base.received.Context()) + require.True(t, ok, "identity from req.Context() must be present downstream") + assert.Equal(t, "fresh-user", got.Subject, + "identity on req.Context() must not be overridden by the captured fallback (#5323)") + assert.Equal(t, "fresh-token", got.UpstreamTokens["provider"], + "fresh upstream tokens must reach auth strategies unchanged (#5323)") +} + +// --- Fallback identity (teardown / no-identity-on-context path) ------------ -func TestIdentityRoundTripper_WithIdentity_PropagatesIdentityInContext(t *testing.T) { +// TestIdentityRoundTripper_FallbackIdentity_InjectedWhenContextLacksIdentity +// verifies that the captured fallback identity is injected when req.Context() +// carries no identity (e.g. mcp-go's Close() DELETE built from context.Background()). +func TestIdentityRoundTripper_FallbackIdentity_InjectedWhenContextLacksIdentity(t *testing.T) { t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-42"}} + fallback := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-42"}} base := &okTransport{} - rt := &identityRoundTripper{base: base, identity: identity} + rt := &identityRoundTripper{base: base, fallbackIdentity: fallback} orig := newTestRequest(context.Background(), t) resp, err := rt.RoundTrip(orig) @@ -202,10 +283,10 @@ func TestIdentityRoundTripper_WithIdentity_PropagatesIdentityInContext(t *testin require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) - // Downstream request must carry the identity in its context. + // Downstream request must carry the fallback identity in its context. require.NotNil(t, base.received) got, ok := auth.IdentityFromContext(base.received.Context()) - require.True(t, ok, "identity not found in downstream request context") + require.True(t, ok, "fallback identity not found in downstream request context") assert.Equal(t, "user-42", got.Subject) // Original request context must be unmodified. @@ -213,11 +294,11 @@ func TestIdentityRoundTripper_WithIdentity_PropagatesIdentityInContext(t *testin assert.False(t, origOk, "original request context was mutated") } -func TestIdentityRoundTripper_NilIdentity_ContextUnchanged(t *testing.T) { +func TestIdentityRoundTripper_FallbackIdentity_NilFallback_ContextUnchanged(t *testing.T) { t.Parallel() base := &okTransport{} - rt := &identityRoundTripper{base: base, identity: nil} + rt := &identityRoundTripper{base: base, fallbackIdentity: nil} orig := newTestRequest(context.Background(), t) resp, err := rt.RoundTrip(orig) @@ -228,21 +309,22 @@ func TestIdentityRoundTripper_NilIdentity_ContextUnchanged(t *testing.T) { // No identity should be present in the downstream context. require.NotNil(t, base.received) _, ok := auth.IdentityFromContext(base.received.Context()) - assert.False(t, ok, "identity unexpectedly found in context when nil identity was configured") + assert.False(t, ok, "identity unexpectedly found in context when no fallback and no req-ctx identity") } -func TestIdentityRoundTripper_WithIdentity_ClonesRequest(t *testing.T) { +func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing.T) { t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-99"}} + fallback := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user-99"}} base := &okTransport{} - rt := &identityRoundTripper{base: base, identity: identity} + rt := &identityRoundTripper{base: base, fallbackIdentity: fallback} orig := newTestRequest(context.Background(), t) _, err := rt.RoundTrip(orig) require.NoError(t, err) - // A non-nil identity must cause the request to be cloned. + // When the fallback is injected, the request must be cloned so the original + // request's context remains untouched. require.NotNil(t, base.received) - assert.NotSame(t, orig, base.received, "non-nil identity should clone the request") + assert.NotSame(t, orig, base.received, "fallback injection should clone the request") } diff --git a/pkg/vmcp/session/internal/security/hijack_prevention_test.go b/pkg/vmcp/session/internal/security/hijack_prevention_test.go index 456bc9fe6a..4f5ad9b67c 100644 --- a/pkg/vmcp/session/internal/security/hijack_prevention_test.go +++ b/pkg/vmcp/session/internal/security/hijack_prevention_test.go @@ -5,22 +5,19 @@ package security import ( "context" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" ) -var ( - // Test HMAC secret and salt for consistent test results - testSecret = []byte("test-secret") - testTokenSalt = []byte("test-salt-123456") // 16 bytes -) - // mockSession is a minimal implementation of MultiSession for testing. // It embeds the interface so only the methods exercised by tests need to be defined. type mockSession struct { @@ -56,211 +53,241 @@ func (*mockSession) GetPrompt(_ context.Context, _ *auth.Identity, _ string, _ m func (*mockSession) Close() error { return nil } -// TestValidateCaller_EdgeCases tests edge cases in caller validation logic. -func TestValidateCaller_EdgeCases(t *testing.T) { +// newDecoratedSession creates a mockSession wrapped with BindSession using the given identity. +func newDecoratedSession(t *testing.T, identity *auth.Identity) sessiontypes.MultiSession { + t.Helper() + base := newMockSession("test-session") + decorated, err := BindSession(base, identity) + require.NoError(t, err) + require.NotNil(t, decorated) + return decorated +} + +// authedIdentity builds an authenticated identity with the given issuer and subject. +// token is used as the raw bearer token (may be any non-empty string). +func authedIdentity(token, iss, sub string) *auth.Identity { + return &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Claims: map[string]any{ + "iss": iss, + "sub": sub, + }, + }, + Token: token, + } +} + +// identityWithClaims builds an *auth.Identity whose Claims map is set verbatim +// from claims. Used in tests that need malformed claim values (missing keys, +// non-string values, NUL bytes) that authedIdentity cannot express. +func identityWithClaims(token string, claims map[string]any) *auth.Identity { + return &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: claims}, + Token: token, + } +} + +// TestBindSession_AcceptsRefreshedTokenWithSameIdentity is the regression test +// for issue #5306. A caller presenting a new bearer token (refreshed access +// token) but the same (iss, sub) identity must be accepted because validation +// is now identity-based, not token-hash-based. +func TestBindSession_AcceptsRefreshedTokenWithSameIdentity(t *testing.T) { + t.Parallel() + + const iss = "https://idp.example" + const sub = "user-42" + + // Session created with AT1. + creator := authedIdentity("AT1", iss, sub) + decorated := newDecoratedSession(t, creator) + + // Subsequent call arrives with AT2 (refreshed token) but same (iss, sub). + refreshed := authedIdentity("AT2", iss, sub) + _, err := decorated.CallTool(context.Background(), refreshed, "tool", nil, nil) + require.NoError(t, err, "refreshed token with same identity must be accepted") +} + +func TestBindSession_RejectsInvalidIdentityAtCreation(t *testing.T) { t.Parallel() tests := []struct { - name string - allowAnonymous bool - boundTokenHash string - caller *auth.Identity - wantErr error + name string + identity *auth.Identity + }{ + {name: "missing_sub_claim", identity: identityWithClaims("tok", map[string]any{"iss": "https://idp.example"})}, + {name: "missing_iss_claim", identity: identityWithClaims("tok", map[string]any{"sub": "alice"})}, + {name: "both_claims_empty", identity: identityWithClaims("tok", map[string]any{})}, + {name: "non_string_iss", identity: identityWithClaims("tok", map[string]any{"iss": 42, "sub": "alice"})}, + {name: "non_string_sub", identity: identityWithClaims("tok", map[string]any{"iss": "https://idp.example", "sub": true})}, + {name: "nul_byte_in_iss", identity: identityWithClaims("tok", map[string]any{"iss": "bad\x00iss", "sub": "alice"})}, + {name: "nul_byte_in_sub", identity: identityWithClaims("tok", map[string]any{"iss": "https://idp.example", "sub": "bad\x00sub"})}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := &metadataObservingSession{mockSession: newMockSession("test-session")} + decorated, err := BindSession(base, tt.identity) + + require.Error(t, err) + assert.Nil(t, decorated) + assert.False(t, base.setMetadataCalled, "SetMetadata must not be called if binding extraction fails") + }) + } +} + +func TestBindSession_RejectsMismatchedCaller(t *testing.T) { + t.Parallel() + + const boundIss = "https://idp.example" + const boundSub = "alice" + + tests := []struct { + name string + caller *auth.Identity + }{ + {name: "different_sub", caller: authedIdentity("tok2", boundIss, "bob")}, + {name: "different_iss", caller: authedIdentity("tok2", "https://idp-b.example", boundSub)}, + {name: "missing_iss_claim", caller: identityWithClaims("tok2", map[string]any{"sub": boundSub})}, + {name: "missing_sub_claim", caller: identityWithClaims("tok2", map[string]any{"iss": boundIss})}, + {name: "both_claims_empty", caller: identityWithClaims("tok2", map[string]any{})}, + {name: "non_string_iss_claim", caller: identityWithClaims("tok2", map[string]any{"iss": []string{boundIss}, "sub": boundSub})}, + {name: "non_string_sub_claim", caller: identityWithClaims("tok2", map[string]any{"iss": boundIss, "sub": 12345})}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + decorated := newDecoratedSession(t, authedIdentity("tok", boundIss, boundSub)) + _, err := decorated.CallTool(context.Background(), tt.caller, "tool", nil, nil) + require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) + }) + } +} + +func TestBindSession_AnonymousSession(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + assertFn func(t *testing.T, decorated sessiontypes.MultiSession) }{ { - name: "anonymous session with nil caller", - allowAnonymous: true, - boundTokenHash: "", - caller: nil, - wantErr: nil, // Should succeed - }, - { - name: "anonymous session rejects caller with token", - allowAnonymous: true, - boundTokenHash: "", - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "token"}, - wantErr: sessiontypes.ErrUnauthorizedCaller, // Prevent session upgrade attack - }, - { - name: "bound session with nil caller", - allowAnonymous: false, - boundTokenHash: hashSubject("alice", testSecret, testTokenSalt), - caller: nil, - wantErr: sessiontypes.ErrNilCaller, - }, - { - name: "bound session with matching subject", - allowAnonymous: false, - boundTokenHash: hashSubject("alice", testSecret, testTokenSalt), - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: "any-token"}, - wantErr: nil, // Should succeed — subject matches regardless of token - }, - { - name: "bound session with wrong subject", - allowAnonymous: false, - boundTokenHash: hashSubject("alice", testSecret, testTokenSalt), - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "eve"}, Token: "any-token"}, - wantErr: sessiontypes.ErrUnauthorizedCaller, - }, - { - name: "bound session survives token refresh (same subject, different token)", - allowAnonymous: false, - boundTokenHash: hashSubject("alice", testSecret, testTokenSalt), - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, Token: "refreshed-token"}, - wantErr: nil, // Subject matches — token refresh must not break session - }, - { - name: "bound session with empty subject in identity", - allowAnonymous: false, - boundTokenHash: hashSubject("alice", testSecret, testTokenSalt), - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: ""}, Token: "some-token"}, - wantErr: sessiontypes.ErrUnauthorizedCaller, + name: "nil_identity_stores_sentinel", + assertFn: func(t *testing.T, decorated sessiontypes.MultiSession) { + t.Helper() + meta := decorated.GetMetadata() + assert.Equal(t, binding.UnauthenticatedSentinel, meta[sessiontypes.MetadataKeyIdentityBinding], + "anonymous session must store UnauthenticatedSentinel in metadata") + }, }, { - name: "anonymous session accepts caller with empty token", - allowAnonymous: true, - boundTokenHash: "", - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: ""}, - wantErr: nil, // Empty token is equivalent to no token + name: "rejects_caller_presenting_token", + assertFn: func(t *testing.T, decorated sessiontypes.MultiSession) { + t.Helper() + caller := &auth.Identity{Token: "some-token"} + _, err := decorated.CallTool(context.Background(), caller, "tool", nil, nil) + require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) + }, }, { - name: "misconfigured bound session with empty hash rejects caller", - allowAnonymous: false, - boundTokenHash: "", // Misconfiguration: bound but no hash - caller: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "token"}, - wantErr: sessiontypes.ErrSessionOwnerUnknown, // Fail closed + name: "accepts_nil_caller", + assertFn: func(t *testing.T, decorated sessiontypes.MultiSession) { + t.Helper() + _, err := decorated.CallTool(context.Background(), nil, "tool", nil, nil) + require.NoError(t, err) + }, }, { - name: "misconfigured bound session with empty hash rejects nil caller", - allowAnonymous: false, - boundTokenHash: "", // Misconfiguration: bound but no hash - caller: nil, - wantErr: sessiontypes.ErrNilCaller, // Nil check happens first + name: "accepts_caller_with_empty_token", + assertFn: func(t *testing.T, decorated sessiontypes.MultiSession) { + t.Helper() + caller := &auth.Identity{Token: ""} + _, err := decorated.CallTool(context.Background(), caller, "tool", nil, nil) + require.NoError(t, err) + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - - // Create a base session - baseSession := newMockSession("test-session") - - // Wrap with decorator that has the test configuration - decorator := &hijackPreventionDecorator{ - MultiSession: baseSession, - allowAnonymous: tt.allowAnonymous, - boundTokenHash: tt.boundTokenHash, - tokenSalt: testTokenSalt, - hmacSecret: testSecret, - } - - ctx := context.Background() - - // Test all three decorated methods to verify validation is integrated correctly - toolResult, errCallTool := decorator.CallTool(ctx, tt.caller, "test-tool", nil, nil) - resourceResult, errReadResource := decorator.ReadResource(ctx, tt.caller, "test://uri") - promptResult, errGetPrompt := decorator.GetPrompt(ctx, tt.caller, "test-prompt", nil) - - if tt.wantErr != nil { - require.ErrorIs(t, errCallTool, tt.wantErr) - require.ErrorIs(t, errReadResource, tt.wantErr) - require.ErrorIs(t, errGetPrompt, tt.wantErr) - assert.Nil(t, toolResult) - assert.Nil(t, resourceResult) - assert.Nil(t, promptResult) - } else { - require.NoError(t, errCallTool) - require.NoError(t, errReadResource) - require.NoError(t, errGetPrompt) - assert.NotNil(t, toolResult) - assert.NotNil(t, resourceResult) - assert.NotNil(t, promptResult) - } + base := newMockSession("test-session") + decorated, err := BindSession(base, nil) + require.NoError(t, err) + require.NotNil(t, decorated) + tt.assertFn(t, decorated) }) } } -// TestPreventSessionHijacking_NilSession tests that a nil session is rejected before any method call. -func TestPreventSessionHijacking_NilSession(t *testing.T) { +func TestBindSession_NilSession(t *testing.T) { t.Parallel() - decorated, err := PreventSessionHijacking(nil, testSecret, &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token"}) + identity := authedIdentity("tok", "https://idp.example", "alice") + decorated, err := BindSession(nil, identity) require.Error(t, err) assert.Nil(t, decorated) } -// TestPreventSessionHijacking_BasicFunctionality tests the main entry point. -func TestPreventSessionHijacking_BasicFunctionality(t *testing.T) { +func TestBindSession_BoundRejectsNilCaller(t *testing.T) { t.Parallel() - t.Run("authenticated session", func(t *testing.T) { - t.Parallel() - - baseSession := newMockSession("test-session") - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token"} - - decorated, err := PreventSessionHijacking(baseSession, testSecret, identity) - require.NoError(t, err) - require.NotNil(t, decorated) - - // Verify metadata was set (no cast needed - returns concrete type) - metadata := decorated.GetMetadata() - assert.NotEmpty(t, metadata[metadataKeyTokenHash]) - assert.NotEmpty(t, metadata[metadataKeyTokenSalt]) - }) + decorated := newDecoratedSession(t, authedIdentity("tok", "https://idp.example", "alice")) - t.Run("anonymous session", func(t *testing.T) { - t.Parallel() - - baseSession := newMockSession("test-session") - - decorated, err := PreventSessionHijacking(baseSession, testSecret, nil) - require.NoError(t, err) - require.NotNil(t, decorated) - - // Verify metadata was set (empty for anonymous, no cast needed) - metadata := decorated.GetMetadata() - assert.Empty(t, metadata[metadataKeyTokenHash]) - assert.Empty(t, metadata[metadataKeyTokenSalt]) - }) + _, err := decorated.CallTool(context.Background(), nil, "tool", nil, nil) + require.ErrorIs(t, err, sessiontypes.ErrNilCaller) } -// TestRestoreHijackPrevention tests restoration of the hijack-prevention decorator. -func TestRestoreHijackPrevention(t *testing.T) { +// TestBindSession_ConcurrentRefreshRace verifies that two goroutines calling +// CallTool concurrently with different bearer tokens but the same (iss, sub) +// both succeed. This tests the core fix for issue #5306. +func TestBindSession_ConcurrentRefreshRace(t *testing.T) { t.Parallel() - t.Run("anonymous session (empty hash and salt)", func(t *testing.T) { - t.Parallel() + const iss = "https://idp.example" + const sub = "alice" - base := newMockSession("s1") - restored, err := RestoreHijackPrevention(base, "", "", testSecret) - require.NoError(t, err) - require.NotNil(t, restored) - }) + decorated := newDecoratedSession(t, authedIdentity("AT1", iss, sub)) - t.Run("hash present but salt absent is rejected", func(t *testing.T) { - t.Parallel() + const goroutines = 20 - base := newMockSession("s2") - _, err := RestoreHijackPrevention(base, "somehash", "", testSecret) - require.Error(t, err) - assert.Contains(t, err.Error(), "salt is missing") - }) + errs := make([]error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := range goroutines { + go func(i int) { + defer wg.Done() + // Each goroutine uses a distinct token string but the same identity. + caller := authedIdentity("refreshed-token-"+string(rune('A'+i%26)), iss, sub) + _, errs[i] = decorated.CallTool(context.Background(), caller, "tool", nil, nil) + }(i) + } - t.Run("salt present but hash absent is rejected", func(t *testing.T) { - t.Parallel() + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent CallTool goroutines") + } - base := newMockSession("s3") - _, err := RestoreHijackPrevention(base, "", "deadbeef", testSecret) - require.Error(t, err) - assert.Contains(t, err.Error(), "hash is missing") - }) + for i, err := range errs { + assert.NoError(t, err, "goroutine %d must succeed with same identity and different token", i) + } +} - t.Run("nil session is rejected", func(t *testing.T) { - t.Parallel() +// metadataObservingSession wraps mockSession and records whether SetMetadata +// was ever called. Used in tests that assert SetMetadata is NOT called before +// a binding is validated. +type metadataObservingSession struct { + *mockSession + setMetadataCalled bool +} - _, err := RestoreHijackPrevention(nil, "", "", testSecret) - require.Error(t, err) - }) +func (m *metadataObservingSession) SetMetadata(key, value string) { + m.setMetadataCalled = true + m.mockSession.SetMetadata(key, value) } diff --git a/pkg/vmcp/session/internal/security/restore_test.go b/pkg/vmcp/session/internal/security/restore_test.go index 76dae42ba9..c9f98854ea 100644 --- a/pkg/vmcp/session/internal/security/restore_test.go +++ b/pkg/vmcp/session/internal/security/restore_test.go @@ -5,130 +5,99 @@ package security import ( "context" - "encoding/hex" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" ) -func TestRestoreHijackPrevention_NilSession(t *testing.T) { +func TestRestoreSessionBinding(t *testing.T) { t.Parallel() - restored, err := RestoreHijackPrevention(nil, "somehash", hex.EncodeToString(testTokenSalt), testSecret) - require.Error(t, err) - assert.Nil(t, restored) + tests := []struct { + name string + storedBinding string + wantErr bool + // checkFn is called with the restored session when wantErr is false. + // Leave nil to skip behavioral assertions. + checkFn func(t *testing.T, restored sessiontypes.MultiSession) + }{ + { + name: "bound_round_trip_accepts_matching_caller", + storedBinding: "https://idp.example\x00alice", + checkFn: func(t *testing.T, restored sessiontypes.MultiSession) { + t.Helper() + matchingCaller := identityWithClaims("some-token", map[string]any{ + "iss": "https://idp.example", + "sub": "alice", + }) + _, err := restored.CallTool(context.Background(), matchingCaller, "tool", nil, nil) + require.NoError(t, err, "matching (iss, sub) caller must be accepted after restore") + + intruder := identityWithClaims("other-token", map[string]any{ + "iss": "https://idp.example", + "sub": "bob", + }) + _, err = restored.CallTool(context.Background(), intruder, "tool", nil, nil) + require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) + }, + }, + { + name: "unauthenticated_sentinel_accepts_nil_rejects_token", + storedBinding: binding.UnauthenticatedSentinel, + checkFn: func(t *testing.T, restored sessiontypes.MultiSession) { + t.Helper() + _, err := restored.CallTool(context.Background(), nil, "tool", nil, nil) + require.NoError(t, err, "nil caller must be accepted for anonymous sessions") + + caller := &auth.Identity{Token: "some-token"} + _, err = restored.CallTool(context.Background(), caller, "tool", nil, nil) + require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller, + "caller presenting a token must be rejected (session-upgrade attack prevention)") + }, + }, + { + name: "corrupted_binding_no_nul", + storedBinding: "no-nul-here", + wantErr: true, + }, + { + name: "empty_string_rejected", + storedBinding: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := newMockSession("sess") + restored, err := RestoreSessionBinding(base, tt.storedBinding) + + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, restored) + return + } + + require.NoError(t, err) + require.NotNil(t, restored) + if tt.checkFn != nil { + tt.checkFn(t, restored) + } + }) + } } -func TestRestoreHijackPrevention_MissingSalt(t *testing.T) { +func TestRestoreSessionBinding_NilSession(t *testing.T) { t.Parallel() - // Non-empty tokenHash with empty tokenSaltHex is malformed state. - base := newMockSession("sess") - restored, err := RestoreHijackPrevention(base, "nonemptyhash", "", testSecret) + restored, err := RestoreSessionBinding(nil, binding.UnauthenticatedSentinel) require.Error(t, err) assert.Nil(t, restored) } - -func TestRestoreHijackPrevention_InvalidSaltHex(t *testing.T) { - t.Parallel() - - base := newMockSession("sess") - restored, err := RestoreHijackPrevention(base, "nonemptyhash", "gg", testSecret) - require.Error(t, err) - assert.Nil(t, restored) -} - -func TestRestoreHijackPrevention_AnonymousSession(t *testing.T) { - t.Parallel() - - base := newMockSession("sess") - // tokenHash="" and tokenSaltHex="" → anonymous. - restored, err := RestoreHijackPrevention(base, "", "", testSecret) - require.NoError(t, err) - require.NotNil(t, restored) - - ctx := context.Background() - - // Nil caller is accepted. - _, err = restored.CallTool(ctx, nil, "tool", nil, nil) - require.NoError(t, err) - - // Caller presenting a token is rejected (session upgrade attack prevention). - caller := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "u"}, Token: "t"} - _, err = restored.CallTool(ctx, caller, "tool", nil, nil) - require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) -} - -func TestRestoreHijackPrevention_AuthenticatedRoundTrip(t *testing.T) { - t.Parallel() - - // --- "Pod A": create a session, persist hash+salt from metadata. --- - base := newMockSession("sess") - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "bearer-token"} - - created, err := PreventSessionHijacking(base, testSecret, identity) - require.NoError(t, err) - - meta := created.GetMetadata() - persistedHash := meta[metadataKeyTokenHash] - persistedSalt := meta[metadataKeyTokenSalt] - require.NotEmpty(t, persistedHash, "tokenHash must be persisted") - require.NotEmpty(t, persistedSalt, "tokenSalt must be persisted") - - // --- "Pod B": restore decorator from persisted values. --- - base2 := newMockSession("sess") - restored, err := RestoreHijackPrevention(base2, persistedHash, persistedSalt, testSecret) - require.NoError(t, err) - require.NotNil(t, restored) - - ctx := context.Background() - - // Original token is accepted. - _, err = restored.CallTool(ctx, identity, "tool", nil, nil) - require.NoError(t, err) - - refreshed := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "refreshed-token"} - _, err = restored.CallTool(ctx, refreshed, "tool", nil, nil) - require.NoError(t, err) - - // A different subject is rejected. - other := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "other-user"}, Token: "bearer-token"} - _, err = restored.CallTool(ctx, other, "tool", nil, nil) - require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller) - - // Nil caller is rejected for a bound session. - _, err = restored.CallTool(ctx, nil, "tool", nil, nil) - require.ErrorIs(t, err, sessiontypes.ErrNilCaller) -} - -func TestRestoreHijackPrevention_CrossReplicaSecretMismatch(t *testing.T) { - t.Parallel() - - // Pod A creates with secretA. - secretA := []byte("secret-A") - secretB := []byte("secret-B") - - base := newMockSession("sess") - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "token"} - - created, err := PreventSessionHijacking(base, secretA, identity) - require.NoError(t, err) - - meta := created.GetMetadata() - persistedHash := meta[metadataKeyTokenHash] - persistedSalt := meta[metadataKeyTokenSalt] - - // Pod B restores with a different secretB — token validation must fail. - base2 := newMockSession("sess") - restored, err := RestoreHijackPrevention(base2, persistedHash, persistedSalt, secretB) - require.NoError(t, err) // Construction succeeds; mismatch only shows at validation time. - - ctx := context.Background() - _, err = restored.CallTool(ctx, identity, "tool", nil, nil) - require.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller, - "cross-replica secret mismatch must reject the original token") -} diff --git a/pkg/vmcp/session/internal/security/security.go b/pkg/vmcp/session/internal/security/security.go index 540f85e551..e0f84f2707 100644 --- a/pkg/vmcp/session/internal/security/security.go +++ b/pkg/vmcp/session/internal/security/security.go @@ -1,122 +1,123 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package security provides cryptographic utilities for session token binding -// and hijacking prevention. It handles HMAC-SHA256 token hashing, salt generation, -// and constant-time comparison to prevent timing attacks. +// Package security provides the session-hijack-prevention decorator for +// vMCP sessions. It binds a session to a stable identity tuple (iss, sub) +// extracted from the OIDC identity that created the session, and validates +// that every subsequent request comes from the same identity. +// +// Session bindings are stored as plaintext at rest in the session metadata +// (see pkg/vmcp/session/binding for the format and trust-boundary statement). +// The binding is NOT a credential; it identifies but does not authenticate. +// Callers must validate the request's token independently before passing the +// resulting *auth.Identity to validateCaller. package security import ( "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/hex" + "crypto/subtle" "fmt" "log/slog" "github.com/stacklok/toolhive/pkg/auth" - pkgsecurity "github.com/stacklok/toolhive/pkg/security" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" ) -const ( - // SHA256HexLen is the length of a hex-encoded SHA256 hash (32 bytes = 64 hex characters) - SHA256HexLen = 64 - - // metadataKeyTokenHash is the session metadata key for the token hash. - // Imported from types package to ensure consistency across all packages. - metadataKeyTokenHash = sessiontypes.MetadataKeyTokenHash - - // metadataKeyTokenSalt is the session metadata key for the token salt. - // Imported from types package to ensure consistency across all packages. - metadataKeyTokenSalt = sessiontypes.MetadataKeyTokenSalt -) - -// generateSalt generates a cryptographically secure random salt for token hashing. -// Returns 16 bytes of random data from crypto/rand. +// sessionBindingDecorator wraps a session and adds identity-binding validation +// to prevent session hijacking attacks. It validates that all requests come from +// the same identity that created the session. // -// Each session should have a unique salt to provide additional entropy and prevent -// attacks that work across multiple sessions. -func generateSalt() ([]byte, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return nil, fmt.Errorf("failed to generate salt: %w", err) - } - return salt, nil +// The decorator is applied by BindSession to ALL sessions (both authenticated +// and anonymous). For authenticated sessions, it validates the caller's (iss, sub) +// identity binding matches the creator's binding. For anonymous sessions +// (allowAnonymous=true), it allows nil callers and prevents session upgrade +// attacks by rejecting any token presentation. +// +// The decorator embeds MultiSession and only overrides the methods that require +// validation (CallTool, ReadResource, GetPrompt). All other methods are +// automatically delegated to the embedded session. +type sessionBindingDecorator struct { + sessiontypes.MultiSession // embedded — automatic delegation for unwrapped methods + + // boundIdentity is the canonical identity binding written at session + // creation. Immutable after construction. + // + // For sessions allowed to be anonymous, boundIdentity is + // binding.UnauthenticatedSentinel. + // For sessions bound to an authenticated identity, boundIdentity is the + // output of binding.Format(iss, sub). + boundIdentity string + + // allowAnonymous tracks whether the session was created without a bound + // identity. Used to reject session-upgrade attacks (caller presents a + // token on an anonymous session). + allowAnonymous bool } -// hashSubject returns the hex-encoded HMAC-SHA256 hash of the caller's subject -// (the OIDC "sub" claim). Uses HMAC with a server-managed secret and per-session -// salt to prevent offline attacks if session storage is compromised. -// -// Binding to the subject instead of the raw bearer token makes session ownership -// survive OAuth token refreshes: the "sub" claim is stable across token renewals -// while the raw JWT string changes on every refresh (different exp/iat/jti). +// extractBindingID derives the canonical identity-binding string from the +// given auth identity's OIDC claims. It reads "iss" and "sub" from +// identity.Claims (not identity.Subject) so that JWT-validation and +// introspection paths canonicalize against the same source. // -// For empty subjects (anonymous sessions) it returns the empty string, which is -// the sentinel value used to identify sessions created without credentials. -// The raw subject is never stored — only the hash. +// Returns ("", error) when: +// - identity is nil. +// - identity.Claims is missing "iss" or "sub". +// - either claim is present but not a string. +// - binding.Format rejects the (iss, sub) pair (empty halves or stray NULs). // -// Parameters: -// - subject: The caller's subject identifier (OIDC "sub" claim) -// - secret: Server-managed HMAC secret (should be 32+ bytes) -// - salt: Per-session random salt (typically 16 bytes) +// Callers MUST treat a non-nil error as "no identifying claims available" +// and fail closed (do not silently fall through to anonymous). // -// Security: Uses HMAC-SHA256 instead of plain SHA256 to prevent rainbow table -// attacks and offline brute force if session state leaks from Redis/Valkey. -func hashSubject(subject string, secret, salt []byte) string { - if subject == "" { - return "" +// TODO(#5306-followup): if/when RFC 7662 introspection becomes a top-level +// incoming-auth type, add a startup probe that verifies the IdP emits iss +// and sub in introspection responses (extractBindingID requires both). +func extractBindingID(identity *auth.Identity) (string, error) { + if identity == nil { + return "", fmt.Errorf("auth identity is nil") } - h := hmac.New(sha256.New, secret) - h.Write(salt) - h.Write([]byte(subject)) - return hex.EncodeToString(h.Sum(nil)) -} -// hijackPreventionDecorator wraps a session and adds token binding validation -// to prevent session hijacking attacks. It validates that all requests come from -// the same identity that created the session. -// -// The decorator is applied by PreventSessionHijacking to ALL sessions (both authenticated -// and anonymous). For authenticated sessions, it validates the caller's token matches -// the creator's token. For anonymous sessions (allowAnonymous=true), it allows nil -// callers and prevents session upgrade attacks by rejecting any token presentation. -// -// The decorator embeds MultiSession and only overrides the methods that require -// validation (CallTool, ReadResource, GetPrompt). All other methods are automatically -// delegated to the embedded session. -type hijackPreventionDecorator struct { - sessiontypes.MultiSession // Embedded interface - provides automatic delegation for most methods - - // Subject binding fields: enforce that subsequent requests come from the same - // identity (OIDC "sub" claim) that created the session. Binding to sub rather - // than the raw JWT makes ownership survive OAuth token refreshes. - // These fields are immutable after decorator creation (no mutex needed). - boundTokenHash string // HMAC-SHA256 hash of creator's subject (empty for anonymous) - tokenSalt []byte // Random salt used for HMAC (empty for anonymous) - hmacSecret []byte // Server-managed secret for HMAC-SHA256 - allowAnonymous bool // Whether to allow nil caller + issRaw, issPresent := identity.Claims["iss"] + if !issPresent { + return "", fmt.Errorf("auth identity is missing iss claim") + } + iss, issIsString := issRaw.(string) + if !issIsString { + return "", fmt.Errorf("auth identity has non-string iss claim") + } + + subRaw, subPresent := identity.Claims["sub"] + if !subPresent { + return "", fmt.Errorf("auth identity is missing sub claim") + } + sub, subIsString := subRaw.(string) + if !subIsString { + return "", fmt.Errorf("auth identity has non-string sub claim") + } + + b, err := binding.Format(iss, sub) + if err != nil { + return "", fmt.Errorf("auth identity (iss, sub) pair is invalid: %w", err) + } + return b, nil } -// validateCaller checks if the provided caller identity matches the session owner. -// Returns nil if validation succeeds, or an error if: -// - The session requires a bound identity but caller is nil (ErrNilCaller) -// - The caller's subject hash doesn't match the session owner (ErrUnauthorizedCaller) -// - An anonymous session receives a caller with a non-empty token (ErrUnauthorizedCaller) +// validateCaller checks the caller against the session's bound identity. // -// For anonymous sessions (allowAnonymous=true, boundTokenHash=""), validation succeeds -// only when the caller is nil or has an empty token (prevents session upgrade attacks). -func (d hijackPreventionDecorator) validateCaller(caller *auth.Identity) error { - // No lock needed - subject binding fields are immutable after decorator creation - - // Anonymous sessions: reject callers that present tokens - if d.allowAnonymous && d.boundTokenHash == "" { - // Prevent session upgrade attack: anonymous sessions cannot accept tokens +// Returns: +// - ErrSessionOwnerUnknown when the decorator was constructed with neither +// an anonymous marker nor a real binding (programming error). +// - ErrNilCaller when a bound session receives nil. +// - ErrUnauthorizedCaller when: +// - an anonymous session receives a caller presenting a token (upgrade attack), +// - the caller's identity binding does not match the session's bound identity. +func (d sessionBindingDecorator) validateCaller(caller *auth.Identity) error { + // Anonymous path: sessions that were created without a bound identity. + if d.allowAnonymous && binding.IsUnauthenticated(d.boundIdentity) { + // Prevent session upgrade attack: anonymous sessions cannot accept tokens. if caller != nil && caller.Token != "" { - slog.Warn("subject validation failed: session upgrade attack prevented", + slog.Warn("identity binding validation failed: session upgrade attack prevented", "reason", "token_presented_to_anonymous_session", ) return sessiontypes.ErrUnauthorizedCaller @@ -124,32 +125,43 @@ func (d hijackPreventionDecorator) validateCaller(caller *auth.Identity) error { return nil } - // Bound sessions require a caller + // Bound sessions require a non-nil caller. if caller == nil { - slog.Warn("subject validation failed: nil caller for bound session", + slog.Warn("identity binding validation failed: nil caller for bound session", "reason", "nil_caller", ) return sessiontypes.ErrNilCaller } - // Defensive check: bound sessions must have a non-empty subject hash. - // This prevents misconfigured sessions from accepting empty subjects. - // Scenario: if boundTokenHash="" and caller.Subject="", both would hash to "", - // and ConstantTimeHashCompare would return true (both empty case). - if d.boundTokenHash == "" { - slog.Error("subject validation failed: bound session has empty subject hash", - "reason", "misconfigured_session", - ) - return sessiontypes.ErrSessionOwnerUnknown + // Defensive check: the stored binding must be parsable. An unparsable value + // means the session was misconfigured at construction time — fail closed + // rather than accepting or rejecting based on garbage state. + if !binding.IsUnauthenticated(d.boundIdentity) { + if _, _, ok := binding.Parse(d.boundIdentity); !ok { + slog.Error("identity binding validation failed: stored binding is not parsable", + "reason", "misconfigured_session", + ) + return sessiontypes.ErrSessionOwnerUnknown + } } - // Compute caller's subject hash using the same HMAC secret and salt - callerHash := hashSubject(caller.Subject, d.hmacSecret, d.tokenSalt) + // Compute the caller's binding from their identity claims. + callerBinding, err := extractBindingID(caller) + if err != nil { + slog.Warn("identity binding validation failed: could not extract caller binding", + "reason", "caller_binding_extraction_failed", + "error", err, + ) + return sessiontypes.ErrUnauthorizedCaller + } - // Constant-time comparison to prevent timing attacks - if !pkgsecurity.ConstantTimeHashCompare(d.boundTokenHash, callerHash, SHA256HexLen) { - slog.Warn("subject validation failed: subject hash mismatch", - "reason", "subject_hash_mismatch", + // ConstantTimeCompare is constant-time over content but short-circuits on + // length mismatch. Leaking binding length is acceptable: iss is the OIDC + // issuer (public, in the discovery document) and sub is an opaque + // identifier whose length is typically per-issuer. Neither is secret. + if subtle.ConstantTimeCompare([]byte(d.boundIdentity), []byte(callerBinding)) != 1 { + slog.Warn("identity binding validation failed: identity binding mismatch", + "reason", "identity_binding_mismatch", ) return sessiontypes.ErrUnauthorizedCaller } @@ -158,7 +170,7 @@ func (d hijackPreventionDecorator) validateCaller(caller *auth.Identity) error { } // CreatorIdentity forwards access to the wrapped session when available. -func (d hijackPreventionDecorator) CreatorIdentity() *auth.Identity { +func (d sessionBindingDecorator) CreatorIdentity() *auth.Identity { provider, ok := d.MultiSession.(sessiontypes.CreatorIdentityProvider) if !ok { return nil @@ -167,14 +179,13 @@ func (d hijackPreventionDecorator) CreatorIdentity() *auth.Identity { } // CallTool validates the caller identity before delegating to the embedded session. -func (d hijackPreventionDecorator) CallTool( +func (d sessionBindingDecorator) CallTool( ctx context.Context, caller *auth.Identity, toolName string, arguments map[string]any, meta map[string]any, ) (*vmcp.ToolCallResult, error) { - // Validate caller identity if err := d.validateCaller(caller); err != nil { return nil, err } @@ -183,12 +194,11 @@ func (d hijackPreventionDecorator) CallTool( } // ReadResource validates the caller identity before delegating to the embedded session. -func (d hijackPreventionDecorator) ReadResource( +func (d sessionBindingDecorator) ReadResource( ctx context.Context, caller *auth.Identity, uri string, ) (*vmcp.ResourceReadResult, error) { - // Validate caller identity if err := d.validateCaller(caller); err != nil { return nil, err } @@ -197,13 +207,12 @@ func (d hijackPreventionDecorator) ReadResource( } // GetPrompt validates the caller identity before delegating to the embedded session. -func (d hijackPreventionDecorator) GetPrompt( +func (d sessionBindingDecorator) GetPrompt( ctx context.Context, caller *auth.Identity, name string, arguments map[string]any, ) (*vmcp.PromptGetResult, error) { - // Validate caller identity if err := d.validateCaller(caller); err != nil { return nil, err } @@ -211,155 +220,101 @@ func (d hijackPreventionDecorator) GetPrompt( return d.MultiSession.GetPrompt(ctx, caller, name, arguments) } -// RestoreHijackPrevention recreates the hijack-prevention decorator from persisted -// metadata, rather than recomputing token binding from an identity. Use this when -// reconstructing a MultiSession after a pod restart or cross-pod failover where the -// original bearer token is no longer available but the stored hash and salt are. +// BindSession wraps a session with identity-binding validation. It writes +// the canonical binding into session metadata under MetadataKeyIdentityBinding +// and returns a decorator that validates the caller on every operation. +// +// Whether the session is anonymous is derived from the identity via +// types.ShouldAllowAnonymous. +// +// For bound sessions, the (iss, sub) tuple is extracted from identity.Claims. +// If the tuple cannot be extracted (missing claims, non-string claims, or +// invalid format), BindSession returns an error BEFORE writing anything to +// the session metadata — preserving the invariant that a session always has +// a valid MetadataKeyIdentityBinding value once written. // -// If tokenHash is empty the session is treated as anonymous (allowAnonymous=true). -// The hmacSecret must be the same server-managed secret used at creation time. -func RestoreHijackPrevention( +// Returns an error if session is nil, or if a bound identity is required +// but no valid (iss, sub) binding can be produced. +func BindSession( session sessiontypes.MultiSession, - tokenHash string, - tokenSaltHex string, - hmacSecret []byte, + identity *auth.Identity, ) (sessiontypes.MultiSession, error) { if session == nil { return nil, fmt.Errorf("session must not be nil") } - // Both fields must be either both present or both absent. Any other - // combination indicates corrupted or incomplete metadata and must be - // rejected to fail closed: - // - hash present, salt absent: HMAC comparison will always fail, - // producing a silently broken (always-rejecting) decorator. - // - hash absent, salt present: session would be treated as anonymous, - // silently downgrading a bound session and bypassing token validation. - if tokenHash != "" && tokenSaltHex == "" { - return nil, fmt.Errorf("RestoreHijackPrevention: stored token hash is present but salt is missing " + - "(incomplete session metadata)") - } - if tokenHash == "" && tokenSaltHex != "" { - return nil, fmt.Errorf("RestoreHijackPrevention: stored token salt is present but hash is missing " + - "(incomplete session metadata)") - } - - allowAnonymous := tokenHash == "" + allowAnonymous := sessiontypes.ShouldAllowAnonymous(identity) - var tokenSalt []byte - if tokenSaltHex != "" { - var decErr error - tokenSalt, decErr = hex.DecodeString(tokenSaltHex) - if decErr != nil { - return nil, fmt.Errorf("failed to decode stored token salt: %w", decErr) + // Determine the binding value to write. For bound sessions, extract the + // (iss, sub) binding before touching session metadata — if extraction fails, + // we must not leave a partial or sentinel value in the session. + bid, err := func() (string, error) { + if allowAnonymous { + return binding.UnauthenticatedSentinel, nil + } + b, err := extractBindingID(identity) + if err != nil { + return "", fmt.Errorf("BindSession: cannot derive identity binding: %w", err) } + return b, nil + }() + if err != nil { + return nil, err } - // Make defensive copies to prevent external mutation after construction. - var hmacSecretCopy, tokenSaltCopy []byte - if len(hmacSecret) > 0 { - hmacSecretCopy = append([]byte(nil), hmacSecret...) - } - if len(tokenSalt) > 0 { - tokenSaltCopy = append([]byte(nil), tokenSalt...) - } + // Write the resolved binding. This is the only metadata key written here; + // backend IDs and per-backend session keys are written by makeBaseSession. + session.SetMetadata(sessiontypes.MetadataKeyIdentityBinding, bid) - return &hijackPreventionDecorator{ + return &sessionBindingDecorator{ MultiSession: session, + boundIdentity: bid, allowAnonymous: allowAnonymous, - hmacSecret: hmacSecretCopy, - boundTokenHash: tokenHash, - tokenSalt: tokenSaltCopy, }, nil } -// PreventSessionHijacking wraps a session with hijack prevention security measures. -// It computes subject binding hashes, stores them in session metadata, and returns -// a decorated session that validates caller identity on every operation. +// RestoreSessionBinding recreates the session-binding decorator from a +// persisted binding string read out of session metadata. Use this when +// reconstructing a MultiSession after a pod restart or cross-pod failover. // -// Session ownership is bound to the caller's OIDC "sub" claim (Identity.Subject) -// rather than the raw bearer token. This makes session ownership survive OAuth -// token refreshes: the "sub" is stable across renewals while the JWT string -// changes on every refresh (different exp/iat/jti). The auth middleware still -// validates the JWT on every request, so only callers with valid tokens reach -// the subject-binding check. +// This function is the symmetric counterpart of BindSession for the restore +// path. It is invoked by the session factory after RestoreSession deserializes +// the binding from session metadata. Unlike BindSession, it does NOT write +// metadata — the factory has already restored the metadata layer separately. // -// Whether the session is anonymous is derived from the identity: nil identity or -// empty token means anonymous, a non-empty token means bound/authenticated. +// storedBinding must be either: +// - binding.UnauthenticatedSentinel (the session was anonymous), or +// - a valid bound binding (binding.Parse returns ok). // -// For authenticated sessions (identity.Subject != ""): -// - Generates a unique random salt -// - Computes HMAC-SHA256 hash of the subject claim -// - Stores hash and salt in session metadata -// - Returns decorator that validates every request against the creator's subject -// -// For anonymous sessions (identity == nil or identity.Token == ""): -// - Stores an empty string sentinel for the token hash metadata key -// - Omits the salt metadata key entirely (no salt is generated for anonymous sessions) -// - Returns decorator that allows nil callers and rejects token presentation -// -// Security: -// - Makes defensive copies of secret and salt to prevent external mutation -// - Uses constant-time comparison to prevent timing attacks -// - Prevents session upgrade attacks (anonymous → authenticated) -// - Raw subjects are never stored, only HMAC-SHA256 hashes -// -// Returns an error if: -// - session is nil -// - salt generation fails -func PreventSessionHijacking( +// Anything else (empty string, malformed value) is rejected as corrupted +// metadata. +func RestoreSessionBinding( session sessiontypes.MultiSession, - hmacSecret []byte, - identity *auth.Identity, + storedBinding string, ) (sessiontypes.MultiSession, error) { if session == nil { return nil, fmt.Errorf("session must not be nil") } - allowAnonymous := sessiontypes.ShouldAllowAnonymous(identity) - - // Note: Pass-through methods (ID, Type, CreatedAt, etc.) are validated by the - // type system when the decorator is used. We don't validate them here to keep - // the constructor simple and allow minimal mocks for testing. - - var boundTokenHash string - var tokenSalt []byte - var err error - // Compute subject binding for authenticated sessions - if !allowAnonymous && identity != nil && identity.Subject != "" { - // Generate unique salt for this session - tokenSalt, err = generateSalt() - if err != nil { - return nil, fmt.Errorf("failed to generate token salt: %w", err) - } - // Compute HMAC-SHA256 hash of subject with server secret and per-session salt - boundTokenHash = hashSubject(identity.Subject, hmacSecret, tokenSalt) - } - - // Store hash and salt in session metadata for persistence, auditing, - // and backward compatibility - session.SetMetadata(metadataKeyTokenHash, boundTokenHash) - if len(tokenSalt) > 0 { - session.SetMetadata(metadataKeyTokenSalt, hex.EncodeToString(tokenSalt)) + if binding.IsUnauthenticated(storedBinding) { + return &sessionBindingDecorator{ + MultiSession: session, + boundIdentity: storedBinding, + allowAnonymous: true, + }, nil } - // Make defensive copies of slices to prevent external mutation - var hmacSecretCopy, tokenSaltCopy []byte - if len(hmacSecret) > 0 { - hmacSecretCopy = append([]byte(nil), hmacSecret...) - } - if len(tokenSalt) > 0 { - tokenSaltCopy = append([]byte(nil), tokenSalt...) + // Validate the stored binding is parsable. We do not use iss/sub here — + // the factory calls binding.Parse separately when it needs them to + // reconstruct identity. This call is purely a validation gate. + if _, _, ok := binding.Parse(storedBinding); !ok { + return nil, fmt.Errorf("RestoreSessionBinding: stored binding is neither the unauthenticated sentinel " + + "nor a valid bound binding (corrupted metadata)") } - // Wrap with hijackPreventionDecorator for runtime validation. - // The decorator embeds the MultiSession interface, so all methods are automatically - // delegated except for the three we override (CallTool, ReadResource, GetPrompt). - return &hijackPreventionDecorator{ + return &sessionBindingDecorator{ MultiSession: session, - allowAnonymous: allowAnonymous, - hmacSecret: hmacSecretCopy, - boundTokenHash: boundTokenHash, - tokenSalt: tokenSaltCopy, + boundIdentity: storedBinding, + allowAnonymous: false, }, nil } diff --git a/pkg/vmcp/session/mocks/mock_factory.go b/pkg/vmcp/session/mocks/mock_factory.go index e548519ea0..3d850a1eb4 100644 --- a/pkg/vmcp/session/mocks/mock_factory.go +++ b/pkg/vmcp/session/mocks/mock_factory.go @@ -44,18 +44,18 @@ func (m *MockMultiSessionFactory) EXPECT() *MockMultiSessionFactoryMockRecorder } // MakeSessionWithID mocks base method. -func (m *MockMultiSessionFactory) MakeSessionWithID(ctx context.Context, id string, identity *auth.Identity, allowAnonymous bool, backends []*vmcp.Backend) (session.MultiSession, error) { +func (m *MockMultiSessionFactory) MakeSessionWithID(ctx context.Context, id string, identity *auth.Identity, backends []*vmcp.Backend) (session.MultiSession, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MakeSessionWithID", ctx, id, identity, allowAnonymous, backends) + ret := m.ctrl.Call(m, "MakeSessionWithID", ctx, id, identity, backends) ret0, _ := ret[0].(session.MultiSession) ret1, _ := ret[1].(error) return ret0, ret1 } // MakeSessionWithID indicates an expected call of MakeSessionWithID. -func (mr *MockMultiSessionFactoryMockRecorder) MakeSessionWithID(ctx, id, identity, allowAnonymous, backends any) *gomock.Call { +func (mr *MockMultiSessionFactoryMockRecorder) MakeSessionWithID(ctx, id, identity, backends any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MakeSessionWithID", reflect.TypeOf((*MockMultiSessionFactory)(nil).MakeSessionWithID), ctx, id, identity, allowAnonymous, backends) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MakeSessionWithID", reflect.TypeOf((*MockMultiSessionFactory)(nil).MakeSessionWithID), ctx, id, identity, backends) } // RestoreSession mocks base method. diff --git a/pkg/vmcp/session/restore_caller_identity_test.go b/pkg/vmcp/session/restore_caller_identity_test.go index df8c92118b..97a3a6a2c0 100644 --- a/pkg/vmcp/session/restore_caller_identity_test.go +++ b/pkg/vmcp/session/restore_caller_identity_test.go @@ -51,7 +51,7 @@ func callerIdentity(subject, token string) *auth.Identity { func storedBoundSession(t *testing.T, factory MultiSessionFactory) map[string]string { t.Helper() original, err := factory.MakeSessionWithID( - t.Context(), uuid.New().String(), callerIdentity("alice", "token-at-create"), false, restoreTestBackends(), + t.Context(), uuid.New().String(), callerIdentity("alice", "token-at-create"), restoreTestBackends(), ) require.NoError(t, err) t.Cleanup(func() { _ = original.Close() }) diff --git a/pkg/vmcp/session/session.go b/pkg/vmcp/session/session.go index c8bc492cac..0b615603b2 100644 --- a/pkg/vmcp/session/session.go +++ b/pkg/vmcp/session/session.go @@ -11,18 +11,13 @@ import ( // backward compatibility and convenience. type MultiSession = sessiontypes.MultiSession +// Re-exports from the types package for convenience. See the types package for +// authoritative documentation. const ( - // MetadataKeyTokenHash is the session metadata key that holds the HMAC-SHA256 - // hash of the bearer token used to create the session. For authenticated sessions - // this is hex(HMAC-SHA256(bearerToken)). For anonymous sessions this is the empty - // string sentinel. The raw token is never stored — only the hash. - // - // Re-exported from types package for convenience. + // Legacy: superseded by MetadataKeyIdentityBinding (#5306); invalidated on read. MetadataKeyTokenHash = sessiontypes.MetadataKeyTokenHash - - // MetadataKeyTokenSalt is the session metadata key that holds the hex-encoded - // random salt used for HMAC-SHA256 token hashing. Omitted for anonymous sessions. - // - // Re-exported from types package for convenience. + // Legacy: superseded by MetadataKeyIdentityBinding (#5306). MetadataKeyTokenSalt = sessiontypes.MetadataKeyTokenSalt + + MetadataKeyIdentityBinding = sessiontypes.MetadataKeyIdentityBinding ) diff --git a/pkg/vmcp/session/token_binding_test.go b/pkg/vmcp/session/token_binding_test.go deleted file mode 100644 index d8c79b1406..0000000000 --- a/pkg/vmcp/session/token_binding_test.go +++ /dev/null @@ -1,342 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package session - -import ( - "context" - "errors" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/vmcp" - internalbk "github.com/stacklok/toolhive/pkg/vmcp/session/internal/backend" - sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" -) - -// --------------------------------------------------------------------------- -// makeSession stores token hash in metadata -// --------------------------------------------------------------------------- - -// nilBackendConnector is a connector that returns (nil, nil, nil), causing the -// backend to be skipped during init. This lets us exercise session-metadata -// logic without real backend connections. -func nilBackendConnector() backendConnector { - return func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { - return nil, nil, nil - } -} - -func TestMakeSession_StoresTokenHash(t *testing.T) { - t.Parallel() - - t.Run("authenticated session stores HMAC-SHA256 hash of subject", func(t *testing.T) { - t.Parallel() - - const subject = "alice" - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: subject}, Token: "test-bearer-token"} - - factory := newSessionFactoryWithConnector(nilBackendConnector()) - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), identity, false, nil) - require.NoError(t, err) - require.NotNil(t, sess) - - // Verify subject hash is stored - storedHash, present := sess.GetMetadata()[MetadataKeyTokenHash] - require.True(t, present, "MetadataKeyTokenHash must be set") - assert.NotEmpty(t, storedHash, "Subject hash must be non-empty for authenticated session") - assert.Len(t, storedHash, 64, "HMAC-SHA256 hex-encoded hash should be 64 characters") - // Raw subject must never appear in metadata. - assert.NotEqual(t, subject, storedHash) - - // Verify salt is stored for authenticated sessions - storedSalt, saltPresent := sess.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - require.True(t, saltPresent, "MetadataKeyTokenSalt must be set for authenticated sessions") - assert.NotEmpty(t, storedSalt, "Salt must be non-empty for authenticated session") - }) - - t.Run("anonymous session stores empty sentinel", func(t *testing.T) { - t.Parallel() - - factory := newSessionFactoryWithConnector(nilBackendConnector()) - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, nil) - require.NoError(t, err) - require.NotNil(t, sess) - - storedHash, present := sess.GetMetadata()[MetadataKeyTokenHash] - require.True(t, present, "MetadataKeyTokenHash must be set even for anonymous sessions") - assert.Empty(t, storedHash, "anonymous session must store empty sentinel") - - // Salt must not be present for anonymous sessions - storedSalt := sess.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - assert.Empty(t, storedSalt, "anonymous session must not store a salt") - }) - - t.Run("identity with empty token stores empty sentinel", func(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: ""} - factory := newSessionFactoryWithConnector(nilBackendConnector()) - sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), identity, true, nil) - require.NoError(t, err) - require.NotNil(t, sess) - - storedHash := sess.GetMetadata()[MetadataKeyTokenHash] - assert.Empty(t, storedHash, "empty-token identity must store empty sentinel") - - // Salt must not be present for empty-token (anonymous) sessions - storedSalt := sess.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - assert.Empty(t, storedSalt, "empty-token identity must not store a salt") - }) - - t.Run("MakeSessionWithID also stores subject hash", func(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "bob"}, Token: "id-specific-token"} - - factory := newSessionFactoryWithConnector(nilBackendConnector()) - sess, err := factory.MakeSessionWithID(t.Context(), "explicit-session-id", identity, false, nil) - require.NoError(t, err) - require.NotNil(t, sess) - - // Verify subject hash - storedHash, present := sess.GetMetadata()[MetadataKeyTokenHash] - require.True(t, present, "MetadataKeyTokenHash must be set") - assert.NotEmpty(t, storedHash, "Subject hash must be non-empty") - assert.Len(t, storedHash, 64, "HMAC-SHA256 hex-encoded hash should be 64 characters") - - // Verify salt is stored for authenticated sessions - storedSalt, saltPresent := sess.GetMetadata()[sessiontypes.MetadataKeyTokenSalt] - require.True(t, saltPresent, "MetadataKeyTokenSalt must be set for authenticated sessions") - assert.NotEmpty(t, storedSalt, "Salt must be non-empty for authenticated session") - }) -} - -// --------------------------------------------------------------------------- -// MakeSessionWithID validation -// --------------------------------------------------------------------------- - -// TestMakeSessionWithID_ValidationOfAllowAnonymous tests that MakeSessionWithID -// validates consistency between identity and allowAnonymous parameters. -func TestMakeSessionWithID_ValidationOfAllowAnonymous(t *testing.T) { - t.Parallel() - - factory := NewSessionFactory(nil) - - t.Run("rejects anonymous session with bearer token", func(t *testing.T) { - t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "bearer-token"} - _, err := factory.MakeSessionWithID( - context.Background(), - "test-session", - identity, - true, // allowAnonymous=true but identity has token - nil, // no backends needed for validation test - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot create anonymous session") - assert.Contains(t, err.Error(), "with bearer token") - }) - - t.Run("rejects bound session without bearer token (nil identity)", func(t *testing.T) { - t.Parallel() - _, err := factory.MakeSessionWithID( - context.Background(), - "test-session", - nil, // no identity - false, // allowAnonymous=false but no identity - nil, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot create bound session") - assert.Contains(t, err.Error(), "without bearer token") - }) - - t.Run("rejects bound session without bearer token (empty token)", func(t *testing.T) { - t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: ""} // empty token - _, err := factory.MakeSessionWithID( - context.Background(), - "test-session", - identity, - false, // allowAnonymous=false but token is empty - nil, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot create bound session") - assert.Contains(t, err.Error(), "without bearer token") - }) - - t.Run("allows anonymous session with nil identity", func(t *testing.T) { - t.Parallel() - _, err := factory.MakeSessionWithID( - context.Background(), - "test-session", - nil, // no identity - true, // allowAnonymous=true - consistent - nil, - ) - require.NoError(t, err) - }) - - t.Run("allows anonymous session with empty token", func(t *testing.T) { - t.Parallel() - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: ""} - _, err := factory.MakeSessionWithID( - context.Background(), - "test-session", - identity, - true, // allowAnonymous=true and token is empty - consistent - nil, - ) - require.NoError(t, err) - }) -} - -// --------------------------------------------------------------------------- -// WithHMACSecret defensive copy -// --------------------------------------------------------------------------- - -// TestWithHMACSecret_DefensiveCopy verifies that WithHMACSecret makes a defensive -// copy of the secret to prevent external modification after assignment. -func TestWithHMACSecret_DefensiveCopy(t *testing.T) { - t.Parallel() - - // Create a mutable secret - secretSlice := []byte("original-secret-value") - - // Create factory with the secret - factory := newSessionFactoryWithConnector(nilBackendConnector(), WithHMACSecret(secretSlice)) - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token"} - - // Create first session before modification - sess1, err := factory.MakeSessionWithID(context.Background(), "session-1", identity, false, nil) - require.NoError(t, err) - - // Verify first session was created successfully - hash1 := sess1.GetMetadata()[MetadataKeyTokenHash] - require.NotEmpty(t, hash1, "first session should have token hash") - - // Maliciously modify the secret slice after passing it to WithHMACSecret - for i := range secretSlice { - secretSlice[i] = 0xFF - } - - // Create second session after modification - should still work correctly - // because WithHMACSecret made a defensive copy - sess2, err := factory.MakeSessionWithID(context.Background(), "session-2", identity, false, nil) - require.NoError(t, err) - - // Verify second session was created successfully - hash2 := sess2.GetMetadata()[MetadataKeyTokenHash] - require.NotEmpty(t, hash2, "second session should have token hash") - - // Both sessions should still be able to validate the original token - // (proving the factory used the original secret, not the modified one). - // We verify this by calling a session method that requires authentication. - ctx := context.Background() - - // First session should accept the original token and fail with ErrToolNotFound, - // not an auth error (which would indicate the secret was corrupted) - _, err = sess1.CallTool(ctx, identity, "nonexistent-tool", nil, nil) - assert.ErrorIs(t, err, ErrToolNotFound, "should fail with tool not found error") - assert.False(t, errors.Is(err, sessiontypes.ErrUnauthorizedCaller), - "should not be an auth error (would indicate corrupted secret)") - - // Second session should also accept the original token and fail with ErrToolNotFound - _, err = sess2.CallTool(ctx, identity, "nonexistent-tool", nil, nil) - assert.ErrorIs(t, err, ErrToolNotFound, "should fail with tool not found error") - assert.False(t, errors.Is(err, sessiontypes.ErrUnauthorizedCaller), - "should not be an auth error (would indicate corrupted secret)") -} - -// --------------------------------------------------------------------------- -// RestoreSession fail-closed behaviour for absent token-hash key -// --------------------------------------------------------------------------- - -// TestRestoreSession_AbsentTokenHashKey verifies that RestoreSession fails closed -// when the stored metadata is missing MetadataKeyTokenHash entirely. -// -// Background: storedMetadata[key] returns "" for both an absent key and a -// legitimately anonymous session (which stores "" as a sentinel). The factory -// uses the two-value map lookup form to distinguish between the two cases and -// rejects absent keys rather than silently downgrading to anonymous. -func TestRestoreSession_AbsentTokenHashKey(t *testing.T) { - t.Parallel() - - factory := newSessionFactoryWithConnector(nilBackendConnector()) - - t.Run("absent token-hash key is rejected (fail closed)", func(t *testing.T) { - t.Parallel() - - // Metadata that deliberately omits MetadataKeyTokenHash (simulates - // corrupted or truncated session metadata). MetadataKeyBackendIDs is - // present (empty = zero backends) so the earlier backend-IDs guard - // passes and we reach the token-hash guard. - storedMetadata := map[string]string{ - MetadataKeyIdentitySubject: "alice", - MetadataKeyBackendIDs: "", // present, empty = zero backends - // MetadataKeyTokenHash intentionally absent - } - - _, err := factory.RestoreSession(t.Context(), uuid.New().String(), storedMetadata, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "token hash metadata key absent") - }) - - t.Run("empty token-hash key (anonymous sentinel) is accepted", func(t *testing.T) { - t.Parallel() - - // Metadata with MetadataKeyTokenHash present but empty — this is what - // PreventSessionHijacking writes for anonymous sessions. - storedMetadata := map[string]string{ - MetadataKeyBackendIDs: "", // present, empty = zero backends - sessiontypes.MetadataKeyTokenHash: "", // present, empty = anonymous - } - - sess, err := factory.RestoreSession(t.Context(), uuid.New().String(), storedMetadata, nil) - require.NoError(t, err) - require.NotNil(t, sess) - }) -} - -// TestWithHMACSecret_RejectsEmptySecret verifies that WithHMACSecret rejects -// nil or empty secrets to prevent silent security downgrades. -func TestWithHMACSecret_RejectsEmptySecret(t *testing.T) { - t.Parallel() - - t.Run("nil secret is rejected", func(t *testing.T) { - t.Parallel() - - // Create factory with nil secret (should fall back to default) - factory := NewSessionFactory(nil, WithHMACSecret(nil)) - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token"} - sess, err := factory.MakeSessionWithID(context.Background(), "test-session", identity, false, nil) - require.NoError(t, err) - - // Should still create a valid session with default secret - hash := sess.GetMetadata()[MetadataKeyTokenHash] - assert.NotEmpty(t, hash, "should use default secret, not nil") - }) - - t.Run("empty secret is rejected", func(t *testing.T) { - t.Parallel() - - // Create factory with empty secret (should fall back to default) - factory := NewSessionFactory(nil, WithHMACSecret([]byte{})) - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "user"}, Token: "test-token"} - sess, err := factory.MakeSessionWithID(context.Background(), "test-session", identity, false, nil) - require.NoError(t, err) - - // Should still create a valid session with default secret - hash := sess.GetMetadata()[MetadataKeyTokenHash] - assert.NotEmpty(t, hash, "should use default secret, not empty slice") - }) -} diff --git a/pkg/vmcp/session/types/session.go b/pkg/vmcp/session/types/session.go index 42a724674e..ff85d72180 100644 --- a/pkg/vmcp/session/types/session.go +++ b/pkg/vmcp/session/types/session.go @@ -12,10 +12,12 @@ package types import ( "context" "errors" + "log/slog" "github.com/stacklok/toolhive/pkg/auth" transportsession "github.com/stacklok/toolhive/pkg/transport/session" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/session/binding" ) // Caller represents the ability to invoke MCP protocol operations against a @@ -152,37 +154,95 @@ const ( // (SSE listen/delete and methodless client replies) back to the owning pod. MetadataKeyOwnerURL = "vmcp.session.owner_url" - // MetadataKeyTokenHash is the session metadata key that holds the HMAC-SHA256 - // hash of the bearer token used to create the session. For authenticated sessions - // this is hex(HMAC-SHA256(bearerToken)). For anonymous sessions this is the empty - // string sentinel. The raw token is never stored — only the hash. + // MetadataKeyTokenHash held hex(HMAC-SHA256(bearerToken)) for authenticated + // sessions and "" for anonymous sessions. // - // This constant is the single source of truth used by the session factory and - // security layer to store and validate token binding metadata. + // Legacy: superseded by MetadataKeyIdentityBinding (#5306); sessions written + // under this key are invalidated on read. Constant removed in the follow-up PR. MetadataKeyTokenHash = "vmcp.token.hash" //nolint:gosec // This is a metadata key name, not a credential. - // MetadataKeyTokenSalt is the session metadata key that holds the hex-encoded - // random salt used for HMAC-SHA256 token hashing. Each authenticated session has a - // unique salt to prevent attacks across multiple sessions. Anonymous sessions do not - // generate a salt and this key is omitted from their metadata. + // MetadataKeyTokenSalt held the hex-encoded per-session salt used for + // HMAC-SHA256 token hashing. // - // This constant is the single source of truth used by the session factory and - // security layer to store and validate token binding metadata. + // Legacy: superseded by MetadataKeyIdentityBinding (#5306); removed in the follow-up PR. MetadataKeyTokenSalt = "vmcp.token.salt" //nolint:gosec // This is a metadata key name, not a credential. + + // MetadataKeyIdentityBinding is the session metadata key that holds the + // identity-binding string in the format defined by + // [pkg/vmcp/session/binding]. Bound sessions store binding.Format(iss, sub); + // unauthenticated sessions store binding.UnauthenticatedSentinel. + // + // Storage is plaintext PII — the Redis/Valkey instance must be access-controlled. + MetadataKeyIdentityBinding = "vmcp.identity.binding" ) -// ShouldAllowAnonymous determines if a session should allow anonymous access -// based on the creator's identity. Sessions without an identity (nil) or with -// an empty token are treated as anonymous. +// HasBoundIdentity reports whether session metadata carries an authenticated +// (iss, sub) identity binding, as opposed to the unauthenticated sentinel or no +// binding at all (a placeholder). DocPlanner fork helper used by the session +// refresh paths. +func HasBoundIdentity(metadata map[string]string) bool { + b := metadata[MetadataKeyIdentityBinding] + return b != "" && !binding.IsUnauthenticated(b) +} + +// ShouldAllowAnonymous reports whether a session has no per-user binding and +// should be treated as anonymous. The identity is bound when Token is set, or +// when Claims yields a (iss, sub) pair accepted by binding.Format. +// +// Fail-closed: a Claims["iss"] or Claims["sub"] that is present but not a +// string (a misbehaving validator) is treated as bound, with a WARN logged. +// +// Contract note: this function answers "should this session be CREATED as +// anonymous?" using Token presence as a fast path. It does NOT guarantee that +// a non-anonymous identity will produce a successful binding: an identity with +// Token != "" but missing iss/sub claims passes this check as bound, then +// fails in BindSession with an extraction error. In practice this is +// pathological — all shipping middlewares (JWT validator, LocalUserMiddleware, +// AnonymousMiddleware) populate Claims. Callers who need "will this identity +// actually produce a binding?" must use extractBindingID directly, which is +// deliberately kept internal to the security decorator. func ShouldAllowAnonymous(identity *auth.Identity) bool { - return identity == nil || identity.Token == "" + if identity == nil { + return true + } + if identity.Token != "" { + return false + } + iss, issOK := claimString(identity.Claims, "iss") + sub, subOK := claimString(identity.Claims, "sub") + if !issOK || !subOK { + slog.Warn("auth identity has present-but-non-string iss/sub claim; treating as bound") + return false + } + if _, err := binding.Format(iss, sub); err == nil { + return false + } + return true +} + +// claimString returns (value, ok) for a string claim. ok is true when the +// claim is missing entirely (absence is benign — the caller proceeds to +// binding.Format which rejects empty halves) or when the claim is present +// and a string (including the empty string). ok is false only when the +// claim is present but not a string — the caller must treat that as a +// misconfigured validator and fail closed. +func claimString(claims map[string]any, key string) (string, bool) { + v, present := claims[key] + if !present { + return "", true + } + s, isString := v.(string) + if !isString { + return "", false + } + return s, true } -// Token binding errors returned by Caller methods when caller identity +// Identity binding errors returned by Caller methods when caller identity // validation fails. var ( // ErrUnauthorizedCaller is returned when the caller identity does not - // match the session owner's identity (token hash mismatch). + // match the session owner's identity (identity binding mismatch). ErrUnauthorizedCaller = errors.New("caller identity does not match session owner") // ErrNilCaller is returned when a bound session receives a nil caller. diff --git a/pkg/vmcp/session/types/session_test.go b/pkg/vmcp/session/types/session_test.go new file mode 100644 index 0000000000..3e6b19ce12 --- /dev/null +++ b/pkg/vmcp/session/types/session_test.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/pkg/auth" +) + +func TestShouldAllowAnonymous(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identity *auth.Identity + want bool + }{ + { + name: "nil identity", + identity: nil, + want: true, + }, + { + name: "token present", + identity: &auth.Identity{ + Token: "x", + PrincipalInfo: auth.PrincipalInfo{ + Claims: map[string]any{"iss": "https://idp.example", "sub": "alice"}, + }, + }, + want: false, + }, + { + name: "empty token with valid iss+sub claims", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Claims: map[string]any{"iss": "toolhive-local", "sub": "alice"}, + }, + }, + want: false, + }, + { + name: "empty token and nil claims", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: nil}, + }, + want: true, + }, + { + name: "empty token and missing iss", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: map[string]any{"sub": "x"}}, + }, + want: true, + }, + { + name: "empty token and missing sub", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: map[string]any{"iss": "x"}}, + }, + want: true, + }, + { + name: "empty token and empty string iss and sub", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: map[string]any{"iss": "", "sub": ""}}, + }, + want: true, + }, + { + // Fail-closed: non-string claim from a misbehaving validator → bound, not anonymous. + name: "non-string iss fails closed", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: map[string]any{"iss": 42, "sub": "x"}}, + }, + want: false, + }, + { + name: "non-string sub fails closed", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Claims: map[string]any{"iss": "x", "sub": []string{"foo"}}}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, ShouldAllowAnonymous(tt.identity)) + }) + } +} diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index be310819d0..c020cd0ace 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -15,6 +15,27 @@ import ( // This file contains shared domain types used across multiple vmcp subpackages. // Following DDD principles, these are core domain concepts that cross bounded contexts. +// HeaderForwardConfig configures HTTP headers injected into outbound requests +// from the vMCP runtime to a static backend. AddPlaintextHeaders carries literal +// values; AddHeadersFromSecret maps header names to secret identifiers resolved +// via secrets.EnvironmentProvider at request time (env var TOOLHIVE_SECRET_). +// +// Secret values MUST NOT appear in this struct — only identifiers. The operator +// injects actual Secret values into the vMCP pod as env vars via +// valueFrom.secretKeyRef at Deployment rendering time. Plaintext values are +// also injected via env vars by the operator (literal values, not secret refs) +// and ingested at vMCP startup; the type is therefore purely a runtime +// representation and is not part of any CRD schema. +type HeaderForwardConfig struct { + // AddPlaintextHeaders is a map of canonical HTTP header name to literal value. + AddPlaintextHeaders map[string]string `json:"addPlaintextHeaders,omitempty" yaml:"addPlaintextHeaders,omitempty"` + + // AddHeadersFromSecret maps canonical HTTP header name to secret identifier. + // The vMCP runtime resolves each identifier via secrets.EnvironmentProvider, + // which reads TOOLHIVE_SECRET_ from the pod environment. + AddHeadersFromSecret map[string]string `json:"addHeadersFromSecret,omitempty" yaml:"addHeadersFromSecret,omitempty"` +} + // BackendTarget identifies a specific backend workload and provides // the information needed to forward requests to it. type BackendTarget struct { @@ -84,6 +105,11 @@ type BackendTarget struct { // HealthStatus indicates the current health of the backend. HealthStatus BackendHealthStatus + // HeaderForward carries per-backend HTTP header injection configuration + // applied by the vMCP client to every outbound request targeting this backend + // (list, call, health-check). Nil when no headers are configured. + HeaderForward *HeaderForwardConfig + // Metadata stores additional backend-specific information. Metadata map[string]string } @@ -149,7 +175,16 @@ const ( // BackendUnknown indicates the backend health status is unknown. BackendUnknown BackendHealthStatus = "unknown" - // BackendUnauthenticated indicates the backend is not authenticated. + // BackendUnauthenticated indicates the backend returned an authentication error + // (HTTP 401/403) while the backend target had no outgoing auth strategy + // configured (AuthConfig nil or StrategyTypeUnauthenticated). This signals + // operator misconfiguration: the backend requires authentication but no + // auth strategy was configured on the backend target. + // + // Note: a 401/403 from a backend with a configured outgoing auth strategy is + // treated as BackendHealthy, because health probes deliberately do not carry + // user credentials and the backend's challenge proves reachability and a + // working auth layer. BackendUnauthenticated BackendHealthStatus = "unauthenticated" ) @@ -158,7 +193,7 @@ const ( // - healthy → ready // - degraded → degraded // - unhealthy → unavailable -// - unauthenticated → unauthenticated (backend is reachable but needs per-request user auth) +// - unauthenticated → unauthenticated (misconfig: backend requires auth but none configured) // - unknown → unknown func (s BackendHealthStatus) ToCRDStatus() string { switch s { @@ -325,6 +360,11 @@ type Backend struct { // +optional AuthConfigRef string + // HeaderForward carries per-backend HTTP header injection configuration. + // Only populated for entry-type backends whose MCPServerEntry declares + // spec.headerForward. Nil when the entry has no header forwarding configured. + HeaderForward *HeaderForwardConfig + // Metadata stores additional backend information. Metadata map[string]string } diff --git a/pkg/vmcp/workloads/k8s.go b/pkg/vmcp/workloads/k8s.go index 7a8ffa557e..94b92eacc1 100644 --- a/pkg/vmcp/workloads/k8s.go +++ b/pkg/vmcp/workloads/k8s.go @@ -22,6 +22,7 @@ import ( transporttypes "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/auth/converters" + "github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt" "github.com/stacklok/toolhive/pkg/workloads/types" ) @@ -582,9 +583,46 @@ func (d *k8sDiscoverer) mcpServerEntryToBackend(ctx context.Context, entry *mcpv return nil } + // Per-backend HTTP header injection. Mirrors the static-mode operator + // path in cmd/thv-operator/controllers/virtualmcpserver_deployment.go::buildHeaderForwardManifestForEntry: + // plaintext values verbatim, secret refs translated to identifiers + // (HEADER_FORWARD__) the round tripper resolves at request + // time via secrets.EnvironmentProvider. + backend.HeaderForward = headerForwardFromEntry(entry) + return backend } +// headerForwardFromEntry projects MCPServerEntry.spec.headerForward onto the +// runtime *vmcp.HeaderForwardConfig the round tripper consumes. Returns nil +// when the entry has no headerForward configured. Secret values never +// appear here — only identifiers, which are dereferenced at request time. +func headerForwardFromEntry(entry *mcpv1beta1.MCPServerEntry) *vmcp.HeaderForwardConfig { + if entry == nil || entry.Spec.HeaderForward == nil { + return nil + } + src := entry.Spec.HeaderForward + + cfg := &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: src.AddPlaintextHeaders, + } + for _, ref := range src.AddHeadersFromSecret { + if ref.ValueSecretRef == nil { + continue + } + _, identifier := wirefmt.SecretEnvVarName(entry.Name, ref.HeaderName) + if cfg.AddHeadersFromSecret == nil { + cfg.AddHeadersFromSecret = make(map[string]string) + } + cfg.AddHeadersFromSecret[ref.HeaderName] = identifier + } + + if len(cfg.AddPlaintextHeaders) == 0 && len(cfg.AddHeadersFromSecret) == 0 { + return nil + } + return cfg +} + // mapMCPServerEntryPhaseToHealth converts a MCPServerEntryPhase to a backend health status. func mapMCPServerEntryPhaseToHealth(phase mcpv1beta1.MCPServerEntryPhase) vmcp.BackendHealthStatus { switch phase { diff --git a/pkg/vmcp/workloads/k8s_test.go b/pkg/vmcp/workloads/k8s_test.go index edae76e868..5c32660044 100644 --- a/pkg/vmcp/workloads/k8s_test.go +++ b/pkg/vmcp/workloads/k8s_test.go @@ -5,6 +5,7 @@ package workloads import ( "context" + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -1829,3 +1830,175 @@ func TestFetchCABundleData(t *testing.T) { }) } } + +// TestHeaderForwardFromEntry covers the dynamic-mode projection of +// MCPServerEntry.spec.headerForward into the runtime *vmcp.HeaderForwardConfig. +// The shape MUST match what the operator emits in the static-mode JSON +// manifest env var so both code paths feed equivalent data into the round +// tripper. +func TestHeaderForwardFromEntry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entry *mcpv1beta1.MCPServerEntry + want *vmcp.HeaderForwardConfig + }{ + { + name: "nil entry yields nil", + entry: nil, + want: nil, + }, + { + name: "entry without headerForward yields nil", + entry: &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{RemoteURL: "https://x"}, + }, + want: nil, + }, + { + name: "plaintext only — header names preserved verbatim", + entry: &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "github-copilot", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + "X-MCP-Toolsets": "projects,issues", + "X-Trace-Id": "kind-test", + }, + }, + }, + }, + want: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + "X-MCP-Toolsets": "projects,issues", + "X-Trace-Id": "kind-test", + }, + }, + }, + { + name: "secret only — values become identifiers", + entry: &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "stripe", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ + { + HeaderName: "X-API-Key", + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "stripe-key", + Key: "token", + }, + }, + }, + }, + }, + }, + want: &vmcp.HeaderForwardConfig{ + AddHeadersFromSecret: map[string]string{ + "X-API-Key": "HEADER_FORWARD_X_API_KEY_STRIPE", + }, + }, + }, + { + name: "mixed plaintext + secret", + entry: &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{"X-Trace": "alpha-trace"}, + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ + { + HeaderName: "X-Token", + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "alpha-secret", + Key: "tok", + }, + }, + }, + }, + }, + }, + want: &vmcp.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{"X-Trace": "alpha-trace"}, + AddHeadersFromSecret: map[string]string{ + "X-Token": "HEADER_FORWARD_X_TOKEN_ALPHA", + }, + }, + }, + { + name: "secret ref with nil ValueSecretRef is skipped — empty config returns nil", + entry: &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "ghost", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ + {HeaderName: "X-Stray", ValueSecretRef: nil}, + }, + }, + }, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := headerForwardFromEntry(tt.entry) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestHeaderForwardParity_StaticVsDynamic pins that the dynamic-mode +// projection produces a runtime-equivalent shape to what the operator's +// static-mode JSON manifest serializes. If either path drifts, the round +// tripper sees a different Backend.HeaderForward depending on mode — +// which is exactly the bug pattern PR #5239 fixes for static mode and +// this finding (F2) extends to dynamic mode. +// +// The test compares the JSON serialization of the dynamic-mode result +// to a hand-written manifest that mirrors what +// virtualmcpserver_deployment.go::buildHeaderForwardManifestForEntry +// would emit for the same entry. The two are required to be byte-equal +// after json.Marshal (which sorts map keys). +func TestHeaderForwardParity_StaticVsDynamic(t *testing.T) { + t.Parallel() + + entry := &mcpv1beta1.MCPServerEntry{ + ObjectMeta: metav1.ObjectMeta{Name: "github-copilot-fake", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + RemoteURL: "https://api.example/mcp", + Transport: "streamable-http", + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "g"}, + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddPlaintextHeaders: map[string]string{ + "X-MCP-Toolsets": "projects,issues,pull_requests", + "X-Trace-Id": "kind-test", + }, + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ + { + HeaderName: "X-Api-Key", + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "test-secret", + Key: "token", + }, + }, + }, + }, + }, + } + + dynamic := headerForwardFromEntry(entry) + require.NotNil(t, dynamic) + + dynamicJSON, err := json.Marshal(dynamic) + require.NoError(t, err) + + const wantStaticManifestJSON = `{"addPlaintextHeaders":{"X-MCP-Toolsets":"projects,issues,pull_requests","X-Trace-Id":"kind-test"},"addHeadersFromSecret":{"X-Api-Key":"HEADER_FORWARD_X_API_KEY_GITHUB_COPILOT_FAKE"}}` + + assert.JSONEq(t, wantStaticManifestJSON, string(dynamicJSON), + "dynamic-mode HeaderForward must marshal to the same shape as the static-mode operator manifest") +} diff --git a/pkg/webhook/client.go b/pkg/webhook/client.go index d6764cf0f0..d79f72185f 100644 --- a/pkg/webhook/client.go +++ b/pkg/webhook/client.go @@ -12,9 +12,11 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "net/http" "os" + "path/filepath" "strconv" "time" @@ -38,6 +40,9 @@ func NewClient(cfg Config, webhookType Type, hmacSecret []byte) (*Client, error) if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid webhook config: %w", err) } + if cfg.HMACSecretRef != "" && len(hmacSecret) == 0 { + return nil, fmt.Errorf("webhook %q has HMAC configured but resolved secret is empty", cfg.Name) + } timeout := cfg.Timeout if timeout == 0 { @@ -109,10 +114,15 @@ func (c *Client) doHTTPCall(ctx context.Context, body []byte) ([]byte, error) { } httpReq.Header.Set("Content-Type", "application/json") + hmacSecret, err := c.hmacSecretForRequest(ctx) + if err != nil { + return nil, NewNetworkError(c.config.Name, fmt.Errorf("failed to resolve HMAC secret: %w", err)) + } + // Apply HMAC signing if configured. - if len(c.hmacSecret) > 0 { + if len(hmacSecret) > 0 { timestamp := time.Now().Unix() - signature := SignPayload(c.hmacSecret, timestamp, body) + signature := SignPayload(hmacSecret, timestamp, body) httpReq.Header.Set(SignatureHeader, signature) httpReq.Header.Set(TimestampHeader, strconv.FormatInt(timestamp, 10)) } @@ -139,22 +149,56 @@ func (c *Client) doHTTPCall(ctx context.Context, body []byte) ([]byte, error) { // 5xx errors indicate webhook operational failures. if resp.StatusCode >= http.StatusInternalServerError { + // Body preview is logged at debug level so operators can troubleshoot, + // but is kept out of the returned error chain to avoid surfacing + // potentially sensitive bytes (e.g. from an internal service reached + // via a misconfigured URL) into higher-level error logs. + slog.Debug("webhook returned server error", + "webhook", c.config.Name, + "url", c.config.URL, + "status_code", resp.StatusCode, + "body_preview", truncateBody(respBody), + ) return nil, NewNetworkError(c.config.Name, - fmt.Errorf("webhook returned HTTP %d: %s", resp.StatusCode, truncateBody(respBody))) + fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)) } // Non-200 responses (excluding 5xx handled above) are treated as invalid. // The StatusCode is surfaced so callers can distinguish HTTP 422 (RFC always-deny) // from other non-2xx codes that may follow the failure policy. if resp.StatusCode != http.StatusOK { + slog.Debug("webhook returned non-2xx response", + "webhook", c.config.Name, + "url", c.config.URL, + "status_code", resp.StatusCode, + "body_preview", truncateBody(respBody), + ) return nil, NewInvalidResponseError(c.config.Name, - fmt.Errorf("webhook returned HTTP %d: %s", resp.StatusCode, truncateBody(respBody)), + fmt.Errorf("webhook returned HTTP %d", resp.StatusCode), resp.StatusCode) } return respBody, nil } +func (c *Client) hmacSecretForRequest(ctx context.Context) ([]byte, error) { + if c.config.HMACSecretRef == "" { + return c.hmacSecret, nil + } + if !filepath.IsAbs(c.config.HMACSecretRef) { + return c.hmacSecret, nil + } + + secret, err := ResolveSecret(ctx, c.config.HMACSecretRef) + if err != nil { + return nil, err + } + if len(secret) == 0 { + return nil, fmt.Errorf("resolved HMAC secret is empty") + } + return secret, nil +} + // buildTransport creates an http.RoundTripper with the specified TLS configuration, // always wrapped in ValidatingTransport for SSRF protection. func buildTransport(tlsCfg *TLSConfig) (http.RoundTripper, error) { diff --git a/pkg/webhook/client_test.go b/pkg/webhook/client_test.go index 17292932ea..7af2b87815 100644 --- a/pkg/webhook/client_test.go +++ b/pkg/webhook/client_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -58,6 +59,16 @@ func TestNewClient(t *testing.T) { }, expectError: true, }, + { + name: "hmac configured with empty resolved secret", + config: Config{ + Name: "test", + URL: "https://example.com/webhook", + FailurePolicy: FailurePolicyFail, + HMACSecretRef: "/etc/toolhive/webhooks/test/hmac", + }, + expectError: true, + }, } for _, tt := range tests { @@ -312,6 +323,73 @@ func TestClientHMACSigningHeaders(t *testing.T) { assert.NotEmpty(t, capturedHeaders.Get(TimestampHeader), "expected %s header", TimestampHeader) } +func TestClientRereadsMountedHMACSecret(t *testing.T) { + t.Parallel() + + type signedRequest struct { + body []byte + signature string + timestamp int64 + } + var requests []signedRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + timestamp, err := strconv.ParseInt(r.Header.Get(TimestampHeader), 10, 64) + require.NoError(t, err) + requests = append(requests, signedRequest{ + body: body, + signature: r.Header.Get(SignatureHeader), + timestamp: timestamp, + }) + + resp := Response{ + Version: APIVersion, + UID: "test-uid", + Allowed: true, + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + defer server.Close() + + secretPath := filepath.Join(t.TempDir(), "hmac") + require.NoError(t, os.WriteFile(secretPath, []byte("initial-secret"), 0o600)) + + cfg := Config{ + Name: "test-hmac-file", + URL: server.URL, + Timeout: 5 * time.Second, + FailurePolicy: FailurePolicyFail, + TLSConfig: &TLSConfig{InsecureSkipVerify: true}, + HMACSecretRef: secretPath, + } + client, err := NewClient(cfg, TypeValidating, []byte("initial-secret")) + require.NoError(t, err) + + req := &Request{ + Version: APIVersion, + UID: "test-uid", + Timestamp: time.Now(), + Principal: &auth.PrincipalInfo{Subject: "user1"}, + Context: &RequestContext{ + ServerName: "test-server", + SourceIP: "127.0.0.1", + Transport: "sse", + }, + } + + _, err = client.Call(t.Context(), req) + require.NoError(t, err) + require.NoError(t, os.WriteFile(secretPath, []byte("rotated-secret"), 0o600)) + _, err = client.Call(t.Context(), req) + require.NoError(t, err) + + require.Len(t, requests, 2) + assert.True(t, VerifySignature([]byte("initial-secret"), requests[0].timestamp, requests[0].body, requests[0].signature)) + assert.True(t, VerifySignature([]byte("rotated-secret"), requests[1].timestamp, requests[1].body, requests[1].signature)) +} + func TestClientNoHMACHeadersWithoutSecret(t *testing.T) { t.Parallel() @@ -705,6 +783,69 @@ func TestDoHTTPCallReadError(t *testing.T) { assert.Contains(t, err.Error(), "forced read error") } +// TestClientErrorDoesNotLeakResponseBody verifies that when the webhook returns +// a non-2xx response, the response body bytes are not embedded in the returned +// error chain. This prevents an SSRF-adjacent information-exfiltration path +// where bytes from an internal service reached via a misconfigured webhook URL +// would otherwise be surfaced into higher-level error logs. +func TestClientErrorDoesNotLeakResponseBody(t *testing.T) { + t.Parallel() + + const sentinel = "INTERNAL_LEAK_TOKEN" + + tests := []struct { + name string + statusCode int + }{ + {name: "500 Internal Server Error", statusCode: http.StatusInternalServerError}, + {name: "503 Service Unavailable", statusCode: http.StatusServiceUnavailable}, + {name: "422 Unprocessable Entity", statusCode: http.StatusUnprocessableEntity}, + {name: "418 I'm a teapot", statusCode: http.StatusTeapot}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte("response containing " + sentinel + " somewhere in the body")) + })) + defer server.Close() + + cfg := Config{ + Name: "leak-test", + URL: server.URL, + Timeout: 5 * time.Second, + FailurePolicy: FailurePolicyFail, + } + + client := newTestClient(cfg, TypeValidating, nil) + + req := &Request{ + Version: APIVersion, + UID: "test-uid", + Timestamp: time.Now(), + Principal: &auth.PrincipalInfo{Subject: "user1"}, + Context: &RequestContext{ + ServerName: "test-server", + SourceIP: "127.0.0.1", + Transport: "sse", + }, + } + + _, err := client.Call(t.Context(), req) + require.Error(t, err) + + errMsg := err.Error() + assert.Contains(t, errMsg, strconv.Itoa(tt.statusCode), + "error should surface the HTTP status code so callers can distinguish failure modes") + assert.NotContains(t, errMsg, sentinel, + "error must not embed response body bytes — they may come from an internal service reached via a misconfigured URL") + }) + } +} + type mockRoundTripper struct { resp *http.Response err error diff --git a/pkg/webhook/mutating/middleware.go b/pkg/webhook/mutating/middleware.go index 23fa6777ac..d484f6af98 100644 --- a/pkg/webhook/mutating/middleware.go +++ b/pkg/webhook/mutating/middleware.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -59,7 +60,12 @@ func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRun // Create clients for each webhook. var executors []clientExecutor for i, whCfg := range params.Webhooks { - client, err := webhook.NewClient(whCfg, webhook.TypeMutating, nil) // HMAC secret not yet plumbed + hmacSecret, err := webhook.ResolveSecret(context.Background(), whCfg.HMACSecretRef) + if err != nil { + return fmt.Errorf("failed to resolve HMAC secret for webhook[%d] (%q): %w", i, whCfg.Name, err) + } + + client, err := webhook.NewClient(whCfg, webhook.TypeMutating, hmacSecret) if err != nil { return fmt.Errorf("failed to create client for webhook[%d] (%q): %w", i, whCfg.Name, err) } @@ -83,9 +89,16 @@ func createMutatingHandler(executors []clientExecutor, serverName, transport str return } - // Read the request body to get the raw MCP request. + // Read the request body to get the raw MCP request, capped at MaxRequestSize + // to prevent unbounded memory consumption from oversized inbound requests. + r.Body = http.MaxBytesReader(w, r.Body, webhook.MaxRequestSize) bodyBytes, err := io.ReadAll(r.Body) if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + sendErrorResponse(w, http.StatusRequestEntityTooLarge, "Request body exceeds maximum size", parsedMCP.ID) + return + } sendErrorResponse(w, http.StatusInternalServerError, "Failed to read request body", parsedMCP.ID) return } diff --git a/pkg/webhook/mutating/middleware_test.go b/pkg/webhook/mutating/middleware_test.go index 1086f06a32..12670dd816 100644 --- a/pkg/webhook/mutating/middleware_test.go +++ b/pkg/webhook/mutating/middleware_test.go @@ -405,6 +405,81 @@ func TestMutatingMiddleware_SkipNonMCPRequests(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) } +// makeJSONBodyOfSize builds a syntactically-valid JSON body of exactly `size` +// bytes by padding the value of a "data" field with ASCII characters. The +// resulting bytes are valid JSON-RPC for use as an MCP request body. +func makeJSONBodyOfSize(tb testing.TB, size int) []byte { + tb.Helper() + const envelope = `{"jsonrpc":"2.0","method":"tools/call","id":1,"data":""}` + if size < len(envelope) { + tb.Fatalf("requested size %d is smaller than minimum envelope size %d", size, len(envelope)) + } + padding := bytes.Repeat([]byte("a"), size-len(envelope)) + body := []byte(`{"jsonrpc":"2.0","method":"tools/call","id":1,"data":"`) + body = append(body, padding...) + body = append(body, []byte(`"}`)...) + if len(body) != size { + tb.Fatalf("constructed body length %d != requested size %d", len(body), size) + } + return body +} + +func TestMutatingMiddleware_RequestBodySizeLimit(t *testing.T) { + t.Parallel() + + t.Run("body exceeding MaxRequestSize returns 413", func(t *testing.T) { + t.Parallel() + + cfg := makeConfig(closedServerURL, webhook.FailurePolicyIgnore) + mw := createMutatingHandler(makeExecutors(t, []webhook.Config{cfg}), "srv", "stdio") + + body := makeJSONBodyOfSize(t, webhook.MaxRequestSize+1) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{Method: "tools/call", ID: 1}) + req = req.WithContext(ctx) + + var nextCalled bool + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { nextCalled = true }) + + rr := httptest.NewRecorder() + mw(nextHandler).ServeHTTP(rr, req) + + assert.False(t, nextCalled, "next must not be called for oversized requests") + assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + + var errResp map[string]interface{} + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp)) + errObj, ok := errResp["Error"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(http.StatusRequestEntityTooLarge), errObj["code"]) + assert.Equal(t, "Request body exceeds maximum size", errObj["message"]) + }) + + t.Run("body at MaxRequestSize boundary is accepted", func(t *testing.T) { + t.Parallel() + + // Use FailurePolicyIgnore against a closed port: the outbound webhook + // call fails and is ignored per fail-open, so next is invoked. This + // isolates the test from depending on a working webhook server. + cfg := makeConfig(closedServerURL, webhook.FailurePolicyIgnore) + mw := createMutatingHandler(makeExecutors(t, []webhook.Config{cfg}), "srv", "stdio") + + body := makeJSONBodyOfSize(t, webhook.MaxRequestSize) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{Method: "tools/call", ID: 1}) + req = req.WithContext(ctx) + + var nextCalled bool + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { nextCalled = true }) + + rr := httptest.NewRecorder() + mw(nextHandler).ServeHTTP(rr, req) + + assert.True(t, nextCalled, "next should be called for boundary-size body (fail-open ignores webhook error)") + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + func TestMiddlewareParams_Validate(t *testing.T) { t.Parallel() tests := []struct { @@ -490,6 +565,57 @@ func TestCreateMiddleware(t *testing.T) { require.NoError(t, mw.Close()) } +func TestCreateMiddleware_ResolvesHMACSecret(t *testing.T) { + t.Setenv("TOOLHIVE_SECRETS_PROVIDER", "environment") + t.Setenv("TOOLHIVE_SECRET_WEBHOOK_MUTATING_TEST_HMAC_SECRET", "top-secret") + + var signatureHeader string + var timestampHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + signatureHeader = r.Header.Get(webhook.SignatureHeader) + timestampHeader = r.Header.Get(webhook.TimestampHeader) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(webhook.MutatingResponse{ + Response: webhook.Response{ + Version: webhook.APIVersion, + UID: "uid", + Allowed: true, + }, + }) + })) + defer server.Close() + + runner := &mockRunner{} + params := FactoryMiddlewareParams{ + MiddlewareParams: MiddlewareParams{ + Webhooks: []webhook.Config{{ + Name: "test", + URL: server.URL, + Timeout: webhook.DefaultTimeout, + FailurePolicy: webhook.FailurePolicyIgnore, + HMACSecretRef: "WEBHOOK_MUTATING_TEST_HMAC_SECRET", + TLSConfig: &webhook.TLSConfig{InsecureSkipVerify: true}, + }}, + }, + ServerName: "test-server", + Transport: "stdio", + } + paramsJSON, err := json.Marshal(params) + require.NoError(t, err) + + err = CreateMiddleware(&types.MiddlewareConfig{Type: MiddlewareType, Parameters: paramsJSON}, runner) + require.NoError(t, err) + + req := makeMCPRequest(t, []byte(`{"jsonrpc":"2.0","method":"tools/call","id":1}`)) + rr := httptest.NewRecorder() + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + runner.middlewares[MiddlewareType].Handler()(next).ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + assert.NotEmpty(t, signatureHeader) + assert.NotEmpty(t, timestampHeader) +} + func TestCreateMiddleware_InvalidParams(t *testing.T) { t.Parallel() runner := &mockRunner{} diff --git a/pkg/webhook/secrets.go b/pkg/webhook/secrets.go new file mode 100644 index 0000000000..520faa2c35 --- /dev/null +++ b/pkg/webhook/secrets.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/secrets" +) + +// ResolveSecret resolves a webhook secret reference using the active secrets provider. +// It accepts either a raw secret name or CLI-style ",target=" format. +func ResolveSecret(ctx context.Context, ref string) ([]byte, error) { + if ref == "" { + return nil, nil + } + + if filepath.IsAbs(ref) { + // #nosec G304 -- absolute refs are generated by the operator for mounted webhook Secret files. + value, err := os.ReadFile(ref) + if err != nil { + return nil, fmt.Errorf("failed to read webhook secret file %q: %w", ref, err) + } + return value, nil + } + + secretName := ref + // Raw secret names are also valid references, so parse errors intentionally fall back to ref as-is. + if secretParam, err := secrets.ParseSecretParameter(ref); err == nil { + secretName = secretParam.Name + } + + provider, err := resolveSecretProvider() + if err != nil { + return nil, fmt.Errorf("failed to initialize webhook secrets provider: %w", err) + } + + value, err := provider.GetSecret(ctx, secretName) + if err != nil { + return nil, fmt.Errorf("failed to resolve webhook secret %q: %w", secretName, err) + } + + return []byte(value), nil +} + +func resolveSecretProvider() (secrets.Provider, error) { + if providerType := os.Getenv(secrets.ProviderEnvVar); providerType != "" { + return secrets.CreateSecretProvider(secrets.ProviderType(providerType)) + } + + cfg := config.NewDefaultProvider().GetConfig() + if !cfg.Secrets.SetupCompleted { + return nil, secrets.ErrSecretsNotSetup + } + + providerType, err := cfg.Secrets.GetProviderType() + if err != nil { + return nil, fmt.Errorf("failed to get configured secrets provider type: %w", err) + } + + return secrets.CreateProvider(providerType, secrets.WithUserFacing()) +} diff --git a/pkg/webhook/secrets_test.go b/pkg/webhook/secrets_test.go new file mode 100644 index 0000000000..4bc13751d1 --- /dev/null +++ b/pkg/webhook/secrets_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/secrets" +) + +func TestResolveSecretFromFile(t *testing.T) { + t.Parallel() + + secretPath := filepath.Join(t.TempDir(), "hmac") + require.NoError(t, os.WriteFile(secretPath, []byte("top-secret"), 0o600)) + + value, err := ResolveSecret(t.Context(), secretPath) + require.NoError(t, err) + assert.Equal(t, []byte("top-secret"), value) +} + +func TestResolveSecret(t *testing.T) { + t.Run("empty ref short-circuits before provider setup", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, "unknown") + + value, err := ResolveSecret(t.Context(), "") + require.NoError(t, err) + assert.Nil(t, value) + }) + + t.Run("env var provider resolves raw secret name", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, string(secrets.EnvironmentType)) + t.Setenv(secrets.EnvVarPrefix+"webhook_hmac", "top-secret") + + value, err := ResolveSecret(t.Context(), "webhook_hmac") + require.NoError(t, err) + assert.Equal(t, []byte("top-secret"), value) + }) + + t.Run("env var provider resolves CLI-style secret reference by name", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, string(secrets.EnvironmentType)) + t.Setenv(secrets.EnvVarPrefix+"webhook_hmac", "top-secret") + + value, err := ResolveSecret(t.Context(), "webhook_hmac,target=ignored") + require.NoError(t, err) + assert.Equal(t, []byte("top-secret"), value) + }) + + t.Run("secrets setup incomplete returns sentinel error", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, "") + config.SetSingletonConfig(&config.Config{}) + t.Cleanup(config.ResetSingleton) + + value, err := ResolveSecret(t.Context(), "webhook_hmac") + require.Error(t, err) + assert.Nil(t, value) + assert.True(t, errors.Is(err, secrets.ErrSecretsNotSetup)) + }) + + t.Run("missing secret returns wrapped error with secret name", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, string(secrets.EnvironmentType)) + + value, err := ResolveSecret(t.Context(), "missing_hmac,target=ignored") + require.Error(t, err) + assert.Nil(t, value) + assert.Contains(t, err.Error(), `failed to resolve webhook secret "missing_hmac"`) + assert.True(t, strings.Contains(err.Error(), "secret not found")) + }) + + t.Run("invalid provider override returns provider initialization error", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, "unknown") + + value, err := ResolveSecret(t.Context(), "webhook_hmac") + require.Error(t, err) + assert.Nil(t, value) + assert.Contains(t, err.Error(), "failed to initialize webhook secrets provider") + }) + + t.Run("invalid configured provider returns provider type error", func(t *testing.T) { + t.Setenv(secrets.ProviderEnvVar, "") + config.SetSingletonConfig(&config.Config{ + Secrets: config.Secrets{ + SetupCompleted: true, + ProviderType: "unknown", + }, + }) + t.Cleanup(config.ResetSingleton) + + value, err := ResolveSecret(t.Context(), "webhook_hmac") + require.Error(t, err) + assert.Nil(t, value) + assert.Contains(t, err.Error(), "failed to initialize webhook secrets provider") + assert.Contains(t, err.Error(), "failed to get configured secrets provider type") + }) +} diff --git a/pkg/webhook/types.go b/pkg/webhook/types.go index 513903e4c5..f9ef729d1f 100644 --- a/pkg/webhook/types.go +++ b/pkg/webhook/types.go @@ -30,6 +30,11 @@ const MinTimeout = 1 * time.Second // MaxResponseSize is the maximum allowed size in bytes for webhook responses (1 MB). const MaxResponseSize = 1 << 20 +// MaxRequestSize is the maximum allowed size in bytes for inbound webhook +// middleware request bodies (1 MB). Requests exceeding this size are +// rejected with HTTP 413 before the body is buffered or forwarded. +const MaxRequestSize = 1 << 20 + // Type indicates whether a webhook is validating or mutating. type Type string @@ -79,8 +84,9 @@ type Config struct { HMACSecretRef string `json:"hmac_secret_ref,omitempty" yaml:"hmac_secret_ref,omitempty"` } -// Validate checks that the WebhookConfig has valid required fields. -func (c *Config) Validate() error { +// ValidateDefinition checks semantic validity of a webhook config without touching the filesystem. +// This is useful when a higher layer is responsible for mounting referenced files later. +func (c *Config) ValidateDefinition() error { if c.Name == "" { return fmt.Errorf("webhook name is required") } @@ -107,7 +113,21 @@ func (c *Config) Validate() error { return fmt.Errorf("webhook timeout %v exceeds maximum %v", c.Timeout, MaxTimeout) } if c.TLSConfig != nil { - if err := validateTLSConfig(c.TLSConfig); err != nil { + if err := validateTLSConfigShape(c.TLSConfig); err != nil { + return fmt.Errorf("webhook TLS config: %w", err) + } + } + return nil +} + +// Validate checks that the WebhookConfig has valid required fields and referenced files exist. +func (c *Config) Validate() error { + if err := c.ValidateDefinition(); err != nil { + return err + } + + if c.TLSConfig != nil { + if err := validateTLSConfigFiles(c.TLSConfig); err != nil { return fmt.Errorf("webhook TLS config: %w", err) } } @@ -221,12 +241,17 @@ type MutatingResponse struct { Patch json.RawMessage `json:"patch,omitempty"` } -// validateTLSConfig validates the TLS configuration for consistency. -func validateTLSConfig(cfg *TLSConfig) error { +// validateTLSConfigShape validates TLS configuration for consistency. +func validateTLSConfigShape(cfg *TLSConfig) error { // If one of client cert/key is provided, both must be present. if (cfg.ClientCertPath == "") != (cfg.ClientKeyPath == "") { return fmt.Errorf("both client_cert_path and client_key_path must be provided for mTLS") } + return nil +} + +// validateTLSConfigFiles validates referenced TLS files exist. +func validateTLSConfigFiles(cfg *TLSConfig) error { if cfg.CABundlePath != "" { if _, err := os.Stat(cfg.CABundlePath); err != nil { return fmt.Errorf("ca_bundle_path %q not found: %w", cfg.CABundlePath, err) diff --git a/pkg/webhook/validating/middleware.go b/pkg/webhook/validating/middleware.go index a86f0146cd..e787bb6540 100644 --- a/pkg/webhook/validating/middleware.go +++ b/pkg/webhook/validating/middleware.go @@ -5,7 +5,9 @@ package validating import ( "bytes" + "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -67,7 +69,12 @@ func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRun // Create clients for each webhook var executors []clientExecutor for i, whCfg := range params.Webhooks { - client, err := webhook.NewClient(whCfg, webhook.TypeValidating, nil) // HMAC secret not yet plumbed + hmacSecret, err := webhook.ResolveSecret(context.Background(), whCfg.HMACSecretRef) + if err != nil { + return fmt.Errorf("failed to resolve HMAC secret for webhook[%d] (%q): %w", i, whCfg.Name, err) + } + + client, err := webhook.NewClient(whCfg, webhook.TypeValidating, hmacSecret) if err != nil { return fmt.Errorf("failed to create client for webhook[%d] (%q): %w", i, whCfg.Name, err) } @@ -91,9 +98,16 @@ func createValidatingHandler(executors []clientExecutor, serverName, transport s return } - // Read the request body to get the raw MCP request + // Read the request body to get the raw MCP request, capped at MaxRequestSize + // to prevent unbounded memory consumption from oversized inbound requests. + r.Body = http.MaxBytesReader(w, r.Body, webhook.MaxRequestSize) bodyBytes, err := io.ReadAll(r.Body) if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + sendErrorResponse(w, http.StatusRequestEntityTooLarge, "Request body exceeds maximum size", parsedMCP.ID) + return + } sendErrorResponse(w, http.StatusInternalServerError, "Failed to read request body", parsedMCP.ID) return } diff --git a/pkg/webhook/validating/middleware_test.go b/pkg/webhook/validating/middleware_test.go index 6bff799a94..5030f5596b 100644 --- a/pkg/webhook/validating/middleware_test.go +++ b/pkg/webhook/validating/middleware_test.go @@ -292,6 +292,102 @@ func TestValidatingMiddleware(t *testing.T) { }) } +// makeJSONBodyOfSize builds a syntactically-valid JSON body of exactly `size` +// bytes by padding the value of a "data" field with ASCII characters. The +// resulting bytes are valid JSON-RPC for use as an MCP request body. +func makeJSONBodyOfSize(tb testing.TB, size int) []byte { + tb.Helper() + // Envelope structure: {"jsonrpc":"2.0","method":"tools/call","id":1,"data":""} + const envelope = `{"jsonrpc":"2.0","method":"tools/call","id":1,"data":""}` + if size < len(envelope) { + tb.Fatalf("requested size %d is smaller than minimum envelope size %d", size, len(envelope)) + } + padding := bytes.Repeat([]byte("a"), size-len(envelope)) + body := []byte(`{"jsonrpc":"2.0","method":"tools/call","id":1,"data":"`) + body = append(body, padding...) + body = append(body, []byte(`"}`)...) + if len(body) != size { + tb.Fatalf("constructed body length %d != requested size %d", len(body), size) + } + return body +} + +func TestValidatingMiddleware_RequestBodySizeLimit(t *testing.T) { + t.Parallel() + + t.Run("body exceeding MaxRequestSize returns 413", func(t *testing.T) { + t.Parallel() + + // Use fail-open so that even if the middleware reached the webhook call + // (it must NOT, since the size check fires first), the test would still + // be unambiguous about the size rejection vs. a downstream error. + cfg := webhook.Config{ + Name: "test-webhook", + URL: closedServerURL, + Timeout: webhook.DefaultTimeout, + FailurePolicy: webhook.FailurePolicyIgnore, + TLSConfig: &webhook.TLSConfig{InsecureSkipVerify: true}, + } + client, err := webhook.NewClient(cfg, webhook.TypeValidating, nil) + require.NoError(t, err) + mw := createValidatingHandler([]clientExecutor{{client: client, config: cfg}}, "srv", "stdio") + + body := makeJSONBodyOfSize(t, webhook.MaxRequestSize+1) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{Method: "tools/call", ID: 1}) + req = req.WithContext(ctx) + + var nextCalled bool + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { nextCalled = true }) + + rr := httptest.NewRecorder() + mw(nextHandler).ServeHTTP(rr, req) + + assert.False(t, nextCalled, "next must not be called for oversized requests") + assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + + // The error response is a JSON-RPC envelope. + var errResp map[string]interface{} + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp)) + errObj, ok := errResp["Error"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(http.StatusRequestEntityTooLarge), errObj["code"]) + assert.Equal(t, "Request body exceeds maximum size", errObj["message"]) + }) + + t.Run("body at MaxRequestSize boundary is accepted", func(t *testing.T) { + t.Parallel() + + // Point at a closed port with FailurePolicyIgnore so that the outbound + // webhook call fails and is ignored per fail-open. This isolates the + // test from depending on a working webhook server. + cfg := webhook.Config{ + Name: "test-webhook", + URL: closedServerURL, + Timeout: webhook.DefaultTimeout, + FailurePolicy: webhook.FailurePolicyIgnore, + TLSConfig: &webhook.TLSConfig{InsecureSkipVerify: true}, + } + client, err := webhook.NewClient(cfg, webhook.TypeValidating, nil) + require.NoError(t, err) + mw := createValidatingHandler([]clientExecutor{{client: client, config: cfg}}, "srv", "stdio") + + body := makeJSONBodyOfSize(t, webhook.MaxRequestSize) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{Method: "tools/call", ID: 1}) + req = req.WithContext(ctx) + + var nextCalled bool + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { nextCalled = true }) + + rr := httptest.NewRecorder() + mw(nextHandler).ServeHTTP(rr, req) + + assert.True(t, nextCalled, "next should be called for boundary-size body (fail-open ignores webhook error)") + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + func TestMiddlewareParams_Validate(t *testing.T) { t.Parallel() tests := []struct { @@ -402,6 +498,62 @@ func TestCreateMiddleware(t *testing.T) { require.NoError(t, mw.Close()) } +func TestCreateMiddleware_ResolvesHMACSecret(t *testing.T) { + t.Setenv("TOOLHIVE_SECRETS_PROVIDER", "environment") + t.Setenv("TOOLHIVE_SECRET_WEBHOOK_VALIDATING_TEST_HMAC_SECRET", "top-secret") + + var signatureHeader string + var timestampHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + signatureHeader = r.Header.Get(webhook.SignatureHeader) + timestampHeader = r.Header.Get(webhook.TimestampHeader) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(webhook.Response{ + Version: webhook.APIVersion, + UID: "uid", + Allowed: true, + }) + })) + defer server.Close() + + runner := &mockRunner{} + params := FactoryMiddlewareParams{ + MiddlewareParams: MiddlewareParams{ + Webhooks: []webhook.Config{{ + Name: "test", + URL: server.URL, + Timeout: webhook.DefaultTimeout, + FailurePolicy: webhook.FailurePolicyIgnore, + HMACSecretRef: "WEBHOOK_VALIDATING_TEST_HMAC_SECRET", + TLSConfig: &webhook.TLSConfig{ + InsecureSkipVerify: true, + }, + }}, + }, + ServerName: "test-server", + Transport: "stdio", + } + paramsJSON, err := json.Marshal(params) + require.NoError(t, err) + + err = CreateMiddleware(&types.MiddlewareConfig{Type: MiddlewareType, Parameters: paramsJSON}, runner) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(`{"jsonrpc":"2.0","method":"tools/call","id":1}`))) + req = req.WithContext(context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{ + Method: "tools/call", + ID: 1, + })) + + rr := httptest.NewRecorder() + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + runner.middlewares[MiddlewareType].Handler()(next).ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + assert.NotEmpty(t, signatureHeader) + assert.NotEmpty(t, timestampHeader) +} + //nolint:paralleltest // Uses httptest server. func TestValidatingMiddleware_HTTP422AlwaysDenies(t *testing.T) { tests := []struct { diff --git a/pkg/workloads/manager.go b/pkg/workloads/manager.go index 7b0187bac7..aa2e0eb442 100644 --- a/pkg/workloads/manager.go +++ b/pkg/workloads/manager.go @@ -45,7 +45,7 @@ type CompletionFunc func() error // NOTE: This interface may be split up in future PRs, in particular, operations // which are only relevant to the CLI/API use case will be split out. // -//go:generate mockgen -destination=mocks/mock_manager.go -package=mocks -source=manager.go Manager +//go:generate mockgen -destination=mocks/mock_manager.go -package=mocks github.com/stacklok/toolhive/pkg/workloads Manager type Manager interface { // GetWorkload retrieves details of the named workload including its status. GetWorkload(ctx context.Context, workloadName string) (core.Workload, error) @@ -97,6 +97,94 @@ type DefaultManager struct { runtime rt.Runtime statuses statuses.StatusManager configProvider config.Provider + + retryConfig *retryConfig + newRunner mcpRunnerFactory + detachedSpawner detachedProcessSpawner +} + +// mcpRunner is the subset of *runner.Runner that RunWorkload's retry loop +// depends on. It exists only to make the retry loop unit-testable; it is +// not part of the workloads package's public API. +type mcpRunner interface { + Run(ctx context.Context) error +} + +// mcpRunnerFactory constructs an mcpRunner for one attempt of the retry loop. +type mcpRunnerFactory func(*runner.RunConfig, statuses.StatusManager) mcpRunner + +// detachedProcessSpawner starts the detached process that backs +// RunWorkloadDetached and returns its PID. In production it re-execs the +// current binary (os.Executable) with `start --foreground` and +// detaches the child via getSysProcAttr (setsid on Unix, a detached process +// group on Windows) — that detachment is what makes orphaned children +// possible under `go test`, which is the bug this seam exists to defuse. +// Tests override this with a no-op so the test binary does not re-exec +// itself. +type detachedProcessSpawner func(ctx context.Context, runConfig *runner.RunConfig) (pid int, err error) + +// retryConfig bundles the RunWorkload retry loop's tunable parameters. +type retryConfig struct { + maxRetries int + initialDelay time.Duration + maxBackoff time.Duration + stableRunThreshold time.Duration +} + +// defaultRetryConfig returns the production retry parameters. +func defaultRetryConfig() retryConfig { + return retryConfig{ + maxRetries: 10, + initialDelay: 5 * time.Second, + maxBackoff: 60 * time.Second, + stableRunThreshold: 30 * time.Second, + } +} + +// managerOption configures a DefaultManager. +type managerOption func(*DefaultManager) + +// withRetryConfig overrides the retry loop's tunable parameters. +func withRetryConfig(cfg retryConfig) managerOption { + return func(d *DefaultManager) { d.retryConfig = &cfg } +} + +// withRunnerFactory overrides the per-attempt runner constructor. +func withRunnerFactory(f mcpRunnerFactory) managerOption { + return func(d *DefaultManager) { d.newRunner = f } +} + +// withDetachedSpawner overrides the function used to start the detached +// workload process. Intended for tests so they do not re-exec the test binary. +func withDetachedSpawner(s detachedProcessSpawner) managerOption { + return func(d *DefaultManager) { d.detachedSpawner = s } +} + +// retryConfigOrDefault returns the manager's retryConfig if set, otherwise defaults. +func (d *DefaultManager) retryConfigOrDefault() retryConfig { + if d.retryConfig == nil { + return defaultRetryConfig() + } + return *d.retryConfig +} + +// newRunnerOrDefault returns the manager's runner factory if set, otherwise wraps runner.NewRunner. +func (d *DefaultManager) newRunnerOrDefault() mcpRunnerFactory { + if d.newRunner != nil { + return d.newRunner + } + return func(rc *runner.RunConfig, sm statuses.StatusManager) mcpRunner { + return runner.NewRunner(rc, sm) + } +} + +// detachedSpawnerOrDefault returns the manager's detached spawner if set, +// otherwise the production default that re-execs the current binary. +func (d *DefaultManager) detachedSpawnerOrDefault() detachedProcessSpawner { + if d.detachedSpawner != nil { + return d.detachedSpawner + } + return d.spawnDetached } // ErrWorkloadNotRunning is returned when a container cannot be found by name. @@ -416,29 +504,35 @@ func (d *DefaultManager) RunWorkload(ctx context.Context, runConfig *runner.RunC } // Retry loop with exponential backoff for container restarts - maxRetries := 10 // Allow many retries for transient issues - retryDelay := 5 * time.Second + cfg := d.retryConfigOrDefault() + maxRetries := cfg.maxRetries + newRunner := d.newRunnerOrDefault() + retryDelay := cfg.initialDelay - for attempt := 1; attempt <= maxRetries; attempt++ { + attempt := 1 + for attempt <= maxRetries { if attempt > 1 { slog.Info("restart attempt", "attempt", attempt, "maxRetries", maxRetries, "workload", runConfig.BaseName, "delay", retryDelay) time.Sleep(retryDelay) // Exponential backoff: 5s, 10s, 20s, 40s, 60s (capped) retryDelay *= 2 - if retryDelay > 60*time.Second { - retryDelay = 60 * time.Second + if retryDelay > cfg.maxBackoff { + retryDelay = cfg.maxBackoff } } - mcpRunner := runner.NewRunner(runConfig, d.statuses) + mcpRunner := newRunner(runConfig, d.statuses) + runStartTime := time.Now() err := mcpRunner.Run(ctx) + runDuration := time.Since(runStartTime) if err != nil { // Check if this is a "container exited, restart needed" error if errors.Is(err, runner.ErrContainerExitedRestartNeeded) { slog.Warn("workload exited unexpectedly, restarting", - "workload", runConfig.BaseName, "attempt", attempt, "maxRetries", maxRetries) + "workload", runConfig.BaseName, "attempt", attempt, "maxRetries", maxRetries, + "runDuration", runDuration) // Remove from client config so clients notice the restart clientManager, clientErr := client.NewManager(ctx) @@ -449,19 +543,42 @@ func (d *DefaultManager) RunWorkload(ctx context.Context, runConfig *runner.RunC } } + // A "stable" run reached at least stableRunThreshold of runtime before + // failing — long enough that we treat the next attempt as a fresh + // failure rather than another tick of the current retry sequence. + stable := runDuration >= cfg.stableRunThreshold + // Set status to starting (since we're restarting) + statusReason := "Container exited, restarting" + if stable { + statusReason = "Container exited after stable run, restarting" + } statusErr := d.statuses.SetWorkloadStatus( ctx, runConfig.BaseName, rt.WorkloadStatusStarting, - "Container exited, restarting", + statusReason, ) if statusErr != nil { slog.Warn("failed to set workload status to starting", "workload", runConfig.BaseName, "error", statusErr) } + // Without this reset of the attempt count, sustained external + // disruption (Docker daemon flap, host sleep/wake) exhausts the + // retry budget through repeated short healthy restarts, and the + // manager gives up on a workload that would have recovered once + // the disruption ended. + if stable { + slog.Debug("resetting retry counter after stable run", + "workload", runConfig.BaseName, "duration", runDuration) + attempt = 1 + retryDelay = cfg.initialDelay + continue + } + // If we haven't exhausted retries, continue the loop if attempt < maxRetries { + attempt++ continue } @@ -526,21 +643,54 @@ func (d *DefaultManager) validateSecretParameters(ctx context.Context, runConfig // RunWorkloadDetached runs a workload in the background. func (d *DefaultManager) RunWorkloadDetached(ctx context.Context, runConfig *runner.RunConfig) error { // before running, validate the parameters for the workload - err := d.validateSecretParameters(ctx, runConfig) - if err != nil { + if err := d.validateSecretParameters(ctx, runConfig); err != nil { return fmt.Errorf("failed to validate workload parameters: %w", err) } + // Start the detached process via the spawner seam. The spawner owns the + // commit point: it sets WorkloadStatus to Starting immediately before + // actually spawning, and rolls it back to Error if the spawn fails. Errors + // returned here mean the spawn was attempted and failed (or could not be + // set up); callers should not observe a status change for pre-spawn + // failures such as os.Executable() returning an error. + pid, err := d.detachedSpawnerOrDefault()(ctx, runConfig) + if err != nil { + return fmt.Errorf("failed to start detached process: %w", err) + } + + // Write the PID to a file so the stop command can kill the process + if err := d.statuses.SetWorkloadPID(ctx, runConfig.BaseName, pid); err != nil { + slog.Warn("failed to set workload PID", "workload", runConfig.BaseName, "error", err) + } + + slog.Debug("mcp server is running in the background", "pid", pid) + + return nil +} + +// spawnDetached is the production implementation of detachedProcessSpawner. +// It re-execs the current binary with `start --foreground` and +// detaches it from the calling process via setsid (Unix) or a detached +// process group (Windows). It also owns the WorkloadStatus transitions: +// Starting is set just before the actual exec, and Error is set if that +// exec fails — failures during pre-exec setup return without any status +// change, matching the original behavior of RunWorkloadDetached. +// +// ctx is intentionally not propagated to exec.Command: the spawned process +// must outlive the parent (that is the point of "detached"), so using +// exec.CommandContext here would kill the child on parent cancellation. +// ctx is still used for status manager calls. +func (d *DefaultManager) spawnDetached(ctx context.Context, runConfig *runner.RunConfig) (int, error) { // Get the current executable path execPath, err := os.Executable() if err != nil { - return fmt.Errorf("failed to get executable path: %w", err) + return 0, fmt.Errorf("failed to get executable path: %w", err) } // Create a log file for the detached process logFilePath, err := xdg.DataFile(fmt.Sprintf("toolhive/logs/%s.log", runConfig.BaseName)) if err != nil { - return fmt.Errorf("failed to create log file path: %w", err) + return 0, fmt.Errorf("failed to create log file path: %w", err) } // #nosec G304 - This is safe as baseName is generated by the application logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) @@ -559,7 +709,6 @@ func (d *DefaultManager) RunWorkloadDetached(ctx context.Context, runConfig *run // Use the start command to start the detached process // The config has already been saved to disk, so start can load it detachedArgs := []string{"start", runConfig.BaseName, "--foreground"} - if runConfig.Debug { detachedArgs = append(detachedArgs, "--debug") } @@ -581,7 +730,7 @@ func (d *DefaultManager) RunWorkloadDetached(ctx context.Context, runConfig *run // wrong passwords before validation. password, _, err := secrets.GetSecretsPassword("") if err != nil { - return fmt.Errorf("failed to get secrets password: %w", err) + return 0, fmt.Errorf("failed to get secrets password: %w", err) } detachedCmd.Env = append(detachedCmd.Env, fmt.Sprintf("%s=%s", secrets.PasswordEnvVar, password)) } @@ -600,29 +749,21 @@ func (d *DefaultManager) RunWorkloadDetached(ctx context.Context, runConfig *run detachedCmd.Stdin = nil detachedCmd.SysProcAttr = getSysProcAttr() - // Ensure that the workload has a status entry before starting the process. - if err = d.statuses.SetWorkloadStatus(ctx, runConfig.BaseName, rt.WorkloadStatusStarting, ""); err != nil { + // Commit point: signal Starting only when we are about to actually spawn. + // Failures above this line return without changing workload status. + if err := d.statuses.SetWorkloadStatus(ctx, runConfig.BaseName, rt.WorkloadStatusStarting, ""); err != nil { // Failure to create the initial state is a fatal error. - return fmt.Errorf("failed to create workload status: %w", err) + return 0, fmt.Errorf("failed to create workload status: %w", err) } - // Start the detached process if err := detachedCmd.Start(); err != nil { - // If the start failed, we need to set the status to error before returning. - if err := d.statuses.SetWorkloadStatus(ctx, runConfig.BaseName, rt.WorkloadStatusError, ""); err != nil { - slog.Warn("Failed to set workload status to error", "workload", runConfig.BaseName, "error", err) + if statusErr := d.statuses.SetWorkloadStatus(ctx, runConfig.BaseName, rt.WorkloadStatusError, ""); statusErr != nil { + slog.Warn("Failed to set workload status to error", "workload", runConfig.BaseName, "error", statusErr) } - return fmt.Errorf("failed to start detached process: %w", err) + return 0, err } - // Write the PID to a file so the stop command can kill the process - if err := d.statuses.SetWorkloadPID(ctx, runConfig.BaseName, detachedCmd.Process.Pid); err != nil { - slog.Warn("failed to set workload PID", "workload", runConfig.BaseName, "error", err) - } - - slog.Debug("mcp server is running in the background", "pid", detachedCmd.Process.Pid) - - return nil + return detachedCmd.Process.Pid, nil } // GetLogs retrieves the logs of a container. diff --git a/pkg/workloads/manager_test.go b/pkg/workloads/manager_test.go index 5fead5aad4..ebbf383d10 100644 --- a/pkg/workloads/manager_test.go +++ b/pkg/workloads/manager_test.go @@ -20,6 +20,7 @@ import ( runtimeMocks "github.com/stacklok/toolhive/pkg/container/runtime/mocks" "github.com/stacklok/toolhive/pkg/core" "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/workloads/statuses" statusMocks "github.com/stacklok/toolhive/pkg/workloads/statuses/mocks" ) @@ -1158,6 +1159,156 @@ func TestDefaultManager_RunWorkload(t *testing.T) { } } +// fakeRunOutcome describes a single attempt's behavior for fakeRunnerFactory. +type fakeRunOutcome struct { + duration time.Duration + err error +} + +// fakeRunner implements mcpRunner for retry-loop tests by sleeping a +// configured duration before returning a configured error. +type fakeRunner struct { + duration time.Duration + err error +} + +func (f *fakeRunner) Run(ctx context.Context) error { + if f.duration > 0 { + select { + case <-time.After(f.duration): + case <-ctx.Done(): + return ctx.Err() + } + } + return f.err +} + +// fakeRunnerFactory replays a sequence of outcomes across successive calls. +// Calls beyond the configured sequence return ErrContainerExitedRestartNeeded +// with no delay so the test fails fast if the loop runs longer than expected. +type fakeRunnerFactory struct { + outcomes []fakeRunOutcome + callCount int +} + +func (f *fakeRunnerFactory) factory() mcpRunnerFactory { + return func(_ *runner.RunConfig, _ statuses.StatusManager) mcpRunner { + idx := f.callCount + f.callCount++ + if idx >= len(f.outcomes) { + return &fakeRunner{err: runner.ErrContainerExitedRestartNeeded} + } + return &fakeRunner{duration: f.outcomes[idx].duration, err: f.outcomes[idx].err} + } +} + +func TestDefaultManager_RunWorkload_RetryCounterReset(t *testing.T) { + t.Parallel() + + // Test thresholds: short enough to keep the test fast, but with a wide + // margin between "short" and "stable" run durations so scheduler jitter + // on a busy CI host does not flip a "short" run to "stable". Production + // thresholds are an order of magnitude larger. + testCfg := retryConfig{ + maxRetries: 3, + initialDelay: 1 * time.Millisecond, + maxBackoff: 5 * time.Millisecond, + stableRunThreshold: 50 * time.Millisecond, + } + + short := fakeRunOutcome{duration: 5 * time.Millisecond, err: runner.ErrContainerExitedRestartNeeded} + stable := fakeRunOutcome{duration: 200 * time.Millisecond, err: runner.ErrContainerExitedRestartNeeded} + atThreshold := fakeRunOutcome{duration: 50 * time.Millisecond, err: runner.ErrContainerExitedRestartNeeded} + + tests := []struct { + name string + outcomes []fakeRunOutcome + expectError bool + errorContains string + expectedAttempts int + }{ + { + name: "all short runs exhaust the retry budget", + outcomes: []fakeRunOutcome{short, short, short}, + expectError: true, + errorContains: "container restart failed after 3 attempts", + expectedAttempts: 3, + }, + { + name: "single stable run at start resets counter, extends budget by one cycle", + outcomes: []fakeRunOutcome{stable, short, short, short}, + expectError: true, + errorContains: "container restart failed after 3 attempts", + expectedAttempts: 4, + }, + { + name: "stable run mid-sequence resets counter, granting a full fresh budget", + outcomes: []fakeRunOutcome{short, stable, short, short, short}, + expectError: true, + errorContains: "container restart failed after 3 attempts", + expectedAttempts: 5, + }, + { + name: "run exactly at threshold counts as stable and resets counter", + outcomes: []fakeRunOutcome{atThreshold, short, short, short}, + expectError: true, + errorContains: "container restart failed after 3 attempts", + expectedAttempts: 4, + }, + { + name: "stable run followed by clean shutdown returns success", + outcomes: []fakeRunOutcome{stable, {duration: 1 * time.Millisecond, err: nil}}, + expectError: false, + expectedAttempts: 2, + }, + { + name: "non-restart error bails immediately without using the retry budget", + outcomes: []fakeRunOutcome{{duration: 1 * time.Millisecond, err: errors.New("fatal config error")}}, + expectError: true, + errorContains: "fatal config error", + expectedAttempts: 1, + }, + { + name: "successful run returns without retrying", + outcomes: []fakeRunOutcome{{duration: 1 * time.Millisecond, err: nil}}, + expectError: false, + expectedAttempts: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusMgr := statusMocks.NewMockStatusManager(ctrl) + mockStatusMgr.EXPECT(). + SetWorkloadStatus(gomock.Any(), "retry-test", gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() + + factory := &fakeRunnerFactory{outcomes: tt.outcomes} + manager := &DefaultManager{statuses: mockStatusMgr} + withRetryConfig(testCfg)(manager) + withRunnerFactory(factory.factory())(manager) + + err := manager.RunWorkload(context.Background(), &runner.RunConfig{BaseName: "retry-test"}) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expectedAttempts, factory.callCount, "unexpected number of run attempts") + }) + } +} + func TestDefaultManager_validateSecretParameters(t *testing.T) { t.Parallel() @@ -1488,6 +1639,70 @@ func TestDefaultManager_RunWorkloadDetached_PIDManagement(t *testing.T) { assert.NotNil(t, runWorkloadDetachedFunc, "RunWorkloadDetached method should exist") } +// TestDefaultManager_RunWorkloadDetached_SpawnerSuccess verifies that the +// injected spawner is invoked exactly once and that the PID it returns is +// forwarded to SetWorkloadPID unchanged. +func TestDefaultManager_RunWorkloadDetached_SpawnerSuccess(t *testing.T) { + t.Parallel() + + const wantPID = 98765 + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusMgr := statusMocks.NewMockStatusManager(ctrl) + mockConfigProvider := configMocks.NewMockProvider(ctrl) + // Forwarded PID is the contract under test. + mockStatusMgr.EXPECT().SetWorkloadPID(gomock.Any(), "test-workload", wantPID).Return(nil) + + var callCount int + spawner := func(_ context.Context, rc *runner.RunConfig) (int, error) { + callCount++ + assert.Equal(t, "test-workload", rc.BaseName) + return wantPID, nil + } + + manager := &DefaultManager{ + statuses: mockStatusMgr, + configProvider: mockConfigProvider, + } + withDetachedSpawner(spawner)(manager) + + err := manager.RunWorkloadDetached(context.Background(), &runner.RunConfig{BaseName: "test-workload"}) + require.NoError(t, err) + assert.Equal(t, 1, callCount, "spawner should be invoked exactly once") +} + +// TestDefaultManager_RunWorkloadDetached_SpawnerError verifies that a spawner +// failure is wrapped as "failed to start detached process" and that no PID +// is recorded. Status rollback is the responsibility of the spawner itself, +// so RunWorkloadDetached should not emit any extra SetWorkloadStatus calls. +func TestDefaultManager_RunWorkloadDetached_SpawnerError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusMgr := statusMocks.NewMockStatusManager(ctrl) + mockConfigProvider := configMocks.NewMockProvider(ctrl) + // No SetWorkloadPID expectation: a failed spawn must not record a PID. + + spawnErr := errors.New("boom") + spawner := func(_ context.Context, _ *runner.RunConfig) (int, error) { + return 0, spawnErr + } + + manager := &DefaultManager{ + statuses: mockStatusMgr, + configProvider: mockConfigProvider, + } + withDetachedSpawner(spawner)(manager) + + err := manager.RunWorkloadDetached(context.Background(), &runner.RunConfig{BaseName: "test-workload"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start detached process") + assert.ErrorIs(t, err, spawnErr) +} + func TestAsyncOperationTimeout(t *testing.T) { t.Parallel() @@ -1695,6 +1910,23 @@ func TestDefaultManager_UpdateWorkload(t *testing.T) { func TestDefaultManager_updateSingleWorkload(t *testing.T) { t.Parallel() + // fakeSpawnerPID is the fake PID returned by the test-injected spawner. + // It must be > 0 so SetWorkloadPID treats it as a real PID. + const fakeSpawnerPID = 12345 + // noopSpawnerFor returns a spawner that replicates the production + // spawner's WorkloadStatus(Starting) side effect against the supplied + // mock, then returns fakeSpawnerPID without re-execing the test binary. + // The Starting transition is part of the spawner contract; mirroring it + // here keeps mock expectations consistent with production behavior. + noopSpawnerFor := func(sm *statusMocks.MockStatusManager) detachedProcessSpawner { + return func(ctx context.Context, rc *runner.RunConfig) (int, error) { + if err := sm.SetWorkloadStatus(ctx, rc.BaseName, runtime.WorkloadStatusStarting, ""); err != nil { + return 0, err + } + return fakeSpawnerPID, nil + } + } + tests := []struct { name string workloadName string @@ -1757,9 +1989,10 @@ func TestDefaultManager_updateSingleWorkload(t *testing.T) { sm.EXPECT().SetWorkloadStatus(gomock.Any(), "test-workload", runtime.WorkloadStatusRemoving, "").Return(nil) sm.EXPECT().DeleteWorkloadStatus(gomock.Any(), "test-workload").Return(nil) - // Mock RunWorkloadDetached calls - expect the ones that will be called + // Mock RunWorkloadDetached calls - expect the ones that will be called. + // The actual spawn is replaced by noopSpawner so SetWorkloadPID receives fakeSpawnerPID. sm.EXPECT().SetWorkloadStatus(gomock.Any(), "test-workload", runtime.WorkloadStatusStarting, "").Return(nil) - sm.EXPECT().SetWorkloadPID(gomock.Any(), "test-workload", gomock.Any()).Return(nil) + sm.EXPECT().SetWorkloadPID(gomock.Any(), "test-workload", fakeSpawnerPID).Return(nil) }, expectError: false, // Test passes - update process completes successfully }, @@ -1821,6 +2054,10 @@ func TestDefaultManager_updateSingleWorkload(t *testing.T) { statuses: mockStatusManager, configProvider: mockConfigProvider, } + // Inject a no-op spawner so RunWorkloadDetached does not re-exec the + // test binary. Without this, the real spawn would orphan a child + // process that recursively reruns the entire test suite. See #5344. + withDetachedSpawner(noopSpawnerFor(mockStatusManager))(manager) err := manager.updateSingleWorkload(ctx, tt.workloadName, tt.runConfig) diff --git a/pkg/workloads/mocks/mock_manager.go b/pkg/workloads/mocks/mock_manager.go index 7480792951..f0e8c005b7 100644 --- a/pkg/workloads/mocks/mock_manager.go +++ b/pkg/workloads/mocks/mock_manager.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: manager.go +// Source: github.com/stacklok/toolhive/pkg/workloads (interfaces: Manager) // // Generated by this command: // -// mockgen -destination=mocks/mock_manager.go -package=mocks -source=manager.go Manager +// mockgen -destination=mocks/mock_manager.go -package=mocks github.com/stacklok/toolhive/pkg/workloads Manager // // Package mocks is a generated GoMock package. diff --git a/pkg/workloads/upgrade/applier.go b/pkg/workloads/upgrade/applier.go new file mode 100644 index 0000000000..1c394d5ca6 --- /dev/null +++ b/pkg/workloads/upgrade/applier.go @@ -0,0 +1,472 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "time" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/container/templates" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/runner/retriever" + "github.com/stacklok/toolhive/pkg/secrets" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// resolveFunc resolves an MCP server definition from the registry and verifies +// its image provenance. It mirrors retriever.ResolveMCPServer so the real +// implementation can be swapped for a stub in tests. +type resolveFunc func( + ctx context.Context, + serverOrImage string, + rawCACertPath string, + verificationType string, + groupName string, + runtimeOverride *templates.RuntimeConfig, +) (string, regtypes.ServerMetadata, error) + +// enforcePullFunc runs the policy gate and performs a verified image pull. It +// mirrors retriever.EnforcePolicyAndPullImage so the real implementation can be +// swapped for a stub in tests. +type enforcePullFunc func( + ctx context.Context, + runConfig *runner.RunConfig, + serverMetadata regtypes.ServerMetadata, + imageURL string, + puller retriever.ImagePuller, + pullTimeout time.Duration, + locallyBuilt bool, +) error + +// loadStateFunc loads a workload's persisted RunConfig. It mirrors +// runner.LoadState so the real implementation can be swapped in tests. +type loadStateFunc func(ctx context.Context, name string) (*runner.RunConfig, error) + +// ErrApplyAfterDestroy marks an Apply failure that occurred AFTER the +// destructive recreate began (the manager has already started to stop/delete +// the workload). Unlike preparation failures — which leave the running workload +// untouched — a failure tagged with this sentinel means the workload may be +// stopped, deleted, or only partially recreated: its state is uncertain and the +// failure is NOT safely retryable against an intact workload. Callers use +// errors.Is(err, ErrApplyAfterDestroy) to distinguish this 5xx-class condition +// from the 4xx-class "candidate could not be prepared" failures. +var ErrApplyAfterDestroy = errors.New("upgrade failed after workload recreate began; workload state is uncertain") + +// Applier is the canonical, security-critical path for applying an available +// registry-sourced upgrade to a running workload. It re-resolves and verifies +// the candidate image, pulls it, and only then asks the workload manager to +// recreate the workload with the new image. +// +// Applier is the single place where an upgrade is materialized; both the CLI +// (Phase D2) and the API (Phase D3) delegate here so that the verify-then-pull +// ordering and TOCTOU guard live in exactly one place. +type Applier struct { + manager workloads.Manager + checker *Checker + appConfig config.Provider + + // resolveFn, enforcePullFn, and loadStateFn wrap the corresponding + // package-level functions in pkg/runner and pkg/runner/retriever. They are + // always populated in production (set in NewApplier) and exist purely so the + // package-level functions can be substituted in unit tests. They are NOT + // optional hooks: NewApplier never leaves them nil. + resolveFn resolveFunc + enforcePullFn enforcePullFunc + loadStateFn loadStateFunc +} + +// NewApplier creates an Applier. +// +// All dependencies are required and validated; the constructor fails loudly +// rather than producing an Applier that would panic or silently no-op at apply +// time. The appConfig provider supplies the registry source URLs recorded on +// the upgraded workload's config. +func NewApplier(manager workloads.Manager, checker *Checker, appConfig config.Provider) (*Applier, error) { + if manager == nil { + return nil, fmt.Errorf("workload manager must not be nil") + } + if checker == nil { + return nil, fmt.Errorf("checker must not be nil") + } + if appConfig == nil { + return nil, fmt.Errorf("config provider must not be nil") + } + return &Applier{ + manager: manager, + checker: checker, + appConfig: appConfig, + resolveFn: retriever.ResolveMCPServer, + enforcePullFn: retriever.EnforcePolicyAndPullImage, + loadStateFn: runner.LoadState, + }, nil +} + +// Apply upgrades the named workload to the candidate image the registry +// currently reports for the workload's registry server. +// +// Sequence (see RFC THV-0068 design §4; step numbers match the inline comments): +// 1. Load the workload's current RunConfig (treated as read-only). +// 2. Re-run the upgrade check against the registry. If the workload is not in +// StatusUpgradeAvailable, the current CheckResult is returned unchanged as a +// no-op (NOT an error) so the caller can decide how to message it. The check +// is ALWAYS re-derived here; a CheckResult computed earlier by the caller is +// never trusted, closing the time-of-check/time-of-use window. +// 3. Re-resolve and VERIFY the candidate image from the registry by server +// name (passing the name, not the image ref, is what triggers provenance +// verification inside ResolveMCPServer). +// 4. Reject non-image (remote) registry entries: there is nothing to pull and +// recreate, so refuse rather than risk destroying the workload. +// 5. Build a merged RunConfig that preserves the workload's user configuration +// while bumping the image to the candidate and re-validating env vars +// against the candidate's fresh metadata. +// 6. Run the policy gate and perform a verified pull of the candidate image. +// 7. Only after all of the above succeed, ask the manager to recreate the +// workload with the new config. +// +// No rollback: steps 3-6 all happen BEFORE the destructive recreate in step 7, +// so any failure while preparing the candidate (resolution, verification, +// policy, pull) leaves the running workload completely untouched. Once step 7 +// begins, the manager deletes and recreates the workload; there is no automatic +// revert to the previous image or configuration if recreation fails. Recovery +// is a forward operation (re-running the previous configuration explicitly). +// +// Runtime boundary: the "verified pull before destruction" guarantee is precise +// only for local container runtimes. On Kubernetes, EnforcePolicyAndPullImage +// runs the verification and policy gate but DELEGATES the byte-level image pull +// to the kubelet, which happens AFTER the workload is recreated. In all cases, +// verification and the policy gate always precede destruction; only the local +// runtime additionally guarantees the bytes are present before destruction. +// (Local runtimes are the scope of this phase; the boundary is documented for +// completeness.) +// +// On success the *CheckResult that drove the upgrade is returned (including any +// detected drift) so the caller can report what changed. +func (a *Applier) Apply(ctx context.Context, name string, opts ApplyOptions) (*CheckResult, error) { + // 1. Load current state. Treat as strictly read-only: never mutate old or + // any of its maps/slices. + old, err := a.loadStateFn(ctx, name) + if err != nil { + return nil, fmt.Errorf("failed to load state for workload %q: %w", name, err) + } + + // 2. Re-check against the registry. Never trust a CheckResult supplied by the + // caller; always re-derive here (TOCTOU guard). + res, err := a.checker.Check(ctx, old) + if err != nil { + return nil, fmt.Errorf("failed to check workload %q for upgrade: %w", name, err) + } + if res.Status != StatusUpgradeAvailable { + // No-op: nothing to apply. Return the result, not an error, so the caller + // decides how to message "already up to date" / "not upgradable". + return res, nil + } + + // 3. Resolve + verify the candidate image from the registry. Passing the + // registry SERVER NAME (not the image ref) is what triggers provenance + // verification inside ResolveMCPServer. A failure here leaves the running + // workload untouched. + imageURL, serverMeta, err := a.resolveFn( + ctx, + old.RegistryServerName, + opts.CACertPath, + opts.defaultVerifySetting(), + "", // registry-based group lookups are not supported + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to resolve candidate image for workload %q: %w", name, err) + } + + // 4. Only container-image servers can be upgraded in place. A remote + // (non-image) entry has nothing to pull/recreate; refuse rather than risk + // destroying the workload. + imgMeta, ok := serverMeta.(*regtypes.ImageMetadata) + if !ok || imgMeta == nil { + return nil, fmt.Errorf( + "registry server %q is not a container image; cannot upgrade workload %q in place", + old.RegistryServerName, name, + ) + } + + // 5. Build the merged config off copies of the caller's/old config's data. + newConfig, err := a.buildUpgradedConfig(ctx, old, imgMeta, opts) + if err != nil { + return nil, fmt.Errorf("failed to build upgraded config for workload %q: %w", name, err) + } + + // 6. Policy gate + verified pull BEFORE any destruction. EnforcePolicyAndPullImage + // runs the policy gate AND the pull, so we deliberately do NOT also call + // EagerCheckCreateServer. This is an intentional deviation from design v2 §4 + // step 5 (which listed a separate EagerCheckCreateServer call): running both + // would double-gate the same config against the same policy. A failure here + // still leaves the running workload untouched. + if err := a.enforcePullFn(ctx, newConfig, serverMeta, imageURL, retriever.PullMCPServerImage, 0, false); err != nil { + return nil, fmt.Errorf("failed to prepare candidate image for workload %q: %w", name, err) + } + + // 7. Destructive apply. The manager stops, deletes, and recreates the + // workload with the new config. We sever request-scoped cancellation with + // context.WithoutCancel so an upgrade in progress is not aborted mid-recreate + // by a client disconnect or handler deadline, while still carrying the OTel + // trace span and request-scoped values (which context.Background would drop). + // + // Errors from here on are tagged with ErrApplyAfterDestroy: the workload may + // already be stopped/deleted, so the caller must treat its state as uncertain + // rather than assuming the running workload was left untouched. + completion, err := a.manager.UpdateWorkload(context.WithoutCancel(ctx), name, newConfig) + if err != nil { + return nil, fmt.Errorf("failed to update workload %q: %w", name, errors.Join(err, ErrApplyAfterDestroy)) + } + + // Block until the stop/delete/save/start cycle finishes. UpdateWorkload runs + // it asynchronously and returns a CompletionFunc; both callers are + // synchronous (the CLI prints success and the API responds 200 only once the + // upgrade has actually taken effect), and the new RunConfig — including the + // upgraded image — is only persisted once this completes. Returning before it + // finishes would surface a stale, pre-upgrade workload to the caller. + if completion != nil { + if err := completion(); err != nil { + return nil, fmt.Errorf("failed to complete upgrade for workload %q: %w", name, errors.Join(err, ErrApplyAfterDestroy)) + } + } + + return res, nil +} + +// buildUpgradedConfig constructs the RunConfig for the upgraded workload. +// +// An upgrade PRESERVES the entire user configuration and changes ONLY: +// - the image (bumped to the candidate), +// - the environment variables and secrets (old merged with the caller's +// ApplyOptions), +// - the registry source URLs (re-resolved from the current app config). +// +// Everything else is byte-identical to the workload's current config: a +// round-trip upgrade of a fully-configured workload must not silently drop +// fields such as OIDC/authz/audit/telemetry config, tool filters, middleware, +// transport/posture, etc. Dropping a field like ToolsFilter (which would +// broaden the exposed tool surface) or the authz/audit config is a security +// regression, not mere behavior drift. +// +// Posture fields (transport, permission profile, network isolation) are +// PRESERVED from the current config and merely SURFACED as drift by the checker +// (preserve-and-warn); they are NOT converged to the candidate's posture here. +// +// The builder is still given the candidate imageMetadata so the env-var +// validator runs against the FRESH metadata: newly-required env vars on the +// candidate are detected/handled (the env-var drift goal of RFC THV-0068). +// +// It never mutates old or any of its maps/slices: caller-visible data is cloned +// before use, and fields copied post-build are either value types or pointers +// that the upgraded config and old config may safely share read-only (neither +// the manager nor the proxy mutates them in place). +func (a *Applier) buildUpgradedConfig( + ctx context.Context, + old *runner.RunConfig, + imgMeta *regtypes.ImageMetadata, + opts ApplyOptions, +) (*runner.RunConfig, error) { + // Copy-before-mutate: never touch the caller's/old config's underlying data. + mergedEnv := maps.Clone(old.EnvVars) + if mergedEnv == nil { + mergedEnv = make(map[string]string, len(opts.EnvVars)) + } + maps.Copy(mergedEnv, opts.EnvVars) + + mergedSecrets := mergeSecrets(old.Secrets, opts.Secrets) + + cfg := a.appConfig.GetConfig() + regAPIURL, regURL := runner.ResolveRegistrySourceURLs(imgMeta, cfg) + + options := []runner.RunConfigBuilderOption{ + // WithImage performs the version bump: NewRunConfigBuilder does NOT derive + // RunConfig.Image from the imageMetadata argument, so it must be set + // explicitly to the candidate image (mirrors workload_service.go). + runner.WithImage(imgMeta.Image), + runner.WithName(old.Name), + runner.WithGroup(old.Group), + // Transport/ports/posture are preserved from old (preserve-and-warn). The + // builder still resolves/normalizes them (and sets transport env vars) + // against the same values old already had. + runner.WithTransportAndPorts(old.Transport.String(), old.Port, old.TargetPort), + runner.WithExistingPort(old.Port), + runner.WithHost(old.Host), + runner.WithVolumes(slices.Clone(old.Volumes)), + runner.WithSecrets(mergedSecrets), + runner.WithNetworkIsolation(old.IsolateNetwork), + runner.WithAllowDockerGateway(old.AllowDockerGateway), + runner.WithTrustProxyHeaders(old.TrustProxyHeaders), + runner.WithProxyMode(old.ProxyMode), + runner.WithCmdArgs(slices.Clone(old.CmdArgs)), + runner.WithStateless(old.Stateless), + runner.WithEndpointPrefix(old.EndpointPrefix), + runner.WithRegistrySourceURLs(regAPIURL, regURL), + runner.WithRegistryServerName(old.RegistryServerName), + } + + // Permission profile: if the workload pinned a named/path profile, preserve + // that name/path (do NOT re-pin to the resolved object or to the candidate's + // profile) so it stays consistent with what the checker reports as drift + // (the checker compares PermissionProfileNameOrPath). Otherwise preserve the + // resolved profile object. + if old.PermissionProfileNameOrPath != "" { + options = append(options, runner.WithPermissionProfileNameOrPath(old.PermissionProfileNameOrPath)) + } else if old.PermissionProfile != nil { + // Clone before handing it over: processVolumeMounts appends to Read/Write, + // which would otherwise mutate old's profile through the shared pointer and + // break this builder's "never mutates old" contract. The clone is shallow + // except for Read/Write: the builder only ever appends to those two slices + // here. The shared Network pointer is safe today because the builder mutates + // it solely via WithNetworkMode, which buildUpgradedConfig never sets; if a + // future change adds WithNetworkMode (or the builder starts touching Network + // unconditionally), Network must be cloned here too. + cloned := *old.PermissionProfile + cloned.Read = slices.Clone(old.PermissionProfile.Read) + cloned.Write = slices.Clone(old.PermissionProfile.Write) + options = append(options, runner.WithPermissionProfile(&cloned)) + } else { + options = append(options, runner.WithPermissionProfile(old.PermissionProfile)) + } + + newConfig, err := runner.NewRunConfigBuilder(ctx, imgMeta, mergedEnv, opts.EnvVarValidator, options...) + if err != nil { + return nil, fmt.Errorf("failed to build run config: %w", err) + } + + // Preserve the remaining user-owned fields that have no clean builder option + // (or whose option carries side effects we don't want). Without this, any + // such field would be silently dropped on upgrade because UpdateWorkload + // persists newConfig verbatim. + preserveUserConfigFields(newConfig, old) + + return newConfig, nil +} + +// preserveUserConfigFields copies user-owned RunConfig fields from old onto the +// freshly-built upgraded config. It covers every persisted, user-configurable +// field that the builder-option list above does NOT already set, so a +// round-trip upgrade changes only the image, env/secrets, and registry URLs. +// +// SchemaVersion, Image, Name, Group, Transport, Host, Port, TargetPort, +// Volumes, Secrets, EnvVars, ProxyMode, CmdArgs, Stateless, EndpointPrefix, +// network/posture flags, permission profile, container name/labels (standard +// labels), and registry fields are intentionally NOT copied here: they are +// produced by the builder above (with image/env/secrets/registry URLs being the +// deliberate deltas). +// +// It does not mutate old. Pointer and slice/map fields are shared read-only; +// neither the workload manager nor the proxy mutates these structures in place. +func preserveUserConfigFields(dst, old *runner.RunConfig) { + // Middleware-driving / security-relevant config. + dst.OIDCConfig = old.OIDCConfig + dst.AuthzConfig = old.AuthzConfig + dst.AuthzConfigPath = old.AuthzConfigPath + dst.AuditConfig = old.AuditConfig + dst.AuditConfigPath = old.AuditConfigPath + dst.TelemetryConfig = old.TelemetryConfig + dst.TokenExchangeConfig = old.TokenExchangeConfig + dst.AWSStsConfig = old.AWSStsConfig + dst.UpstreamSwapConfig = old.UpstreamSwapConfig + dst.EmbeddedAuthServerConfig = old.EmbeddedAuthServerConfig + + // Tool exposure. Dropping these would broaden the exposed tool surface. + // ToolsFilter is replaced wholesale by the manager/proxy (never appended to), + // so sharing the slice is safe; ToolsOverride is cloned so the contract holds + // strictly even if a future caller mutates the map in place. + dst.ToolsFilter = old.ToolsFilter + dst.ToolsOverride = maps.Clone(old.ToolsOverride) + + // The pre-built middleware chain. It is derived from the fields above; carry + // it over verbatim so the upgraded workload keeps the exact same chain rather + // than silently losing all middleware (the builder only rebuilds the chain + // via WithMiddlewareFromFlags, which we do not call). + dst.MiddlewareConfigs = old.MiddlewareConfigs + dst.ValidatingWebhooks = old.ValidatingWebhooks + dst.MutatingWebhooks = old.MutatingWebhooks + + // Rate limiting. + dst.RateLimitConfig = old.RateLimitConfig + dst.RateLimitNamespace = old.RateLimitNamespace + + // Remote server config (preserved for completeness; image upgrades are local, + // but a workload may carry these and they must not be dropped). + dst.RemoteURL = old.RemoteURL + dst.RemoteAuthConfig = old.RemoteAuthConfig + dst.HeaderForward = old.HeaderForward + + // TargetHost and Publish: the builder never sets TargetHost (WithTargetHost + // re-derives it from RemoteURL, which is not what we want), and Publish is + // not passed as a builder option, so both must be copied directly from old. + dst.TargetHost = old.TargetHost + dst.Publish = old.Publish + + // Proxy session / runtime / scheduling knobs. + dst.SessionTTL = old.SessionTTL + dst.RuntimeConfig = old.RuntimeConfig + dst.IgnoreConfig = old.IgnoreConfig + dst.K8sPodTemplatePatch = old.K8sPodTemplatePatch + dst.ScalingConfig = old.ScalingConfig + dst.MCPServerGeneration = old.MCPServerGeneration + + // Debug flag and any user-set container labels not produced by the builder's + // standard-label step. + dst.Debug = old.Debug + for k, v := range old.ContainerLabels { + if _, ok := dst.ContainerLabels[k]; !ok { + dst.ContainerLabels[k] = v + } + } + + // Deprecated/no-longer-used fields, preserved for byte-level fidelity. + dst.EnvFileDir = old.EnvFileDir + dst.ThvCABundle = old.ThvCABundle + dst.JWKSAuthTokenFile = old.JWKSAuthTokenFile +} + +// mergeSecrets returns a new slice containing the old secret parameters plus any +// of the additional parameters whose target is not already covered. It never +// mutates the input slices. Parameters that fail to parse are preserved as-is +// from old and skipped from additions (a malformed addition can't be deduped +// safely and is dropped rather than silently shadowing a valid existing one). +func mergeSecrets(oldSecrets, additional []string) []string { + merged := slices.Clone(oldSecrets) + + existingTargets := make(map[string]struct{}, len(oldSecrets)) + for _, s := range oldSecrets { + parsed, err := secrets.ParseSecretParameter(s) + if err != nil { + continue + } + if parsed.Target != "" { + existingTargets[parsed.Target] = struct{}{} + } + } + + for _, s := range additional { + parsed, err := secrets.ParseSecretParameter(s) + if err != nil { + // A malformed addition can't be deduped; drop it rather than risk + // shadowing an existing valid target. This is explicit user input, so + // warn (logging only the parameter string, never any secret value). + slog.Warn("dropping malformed secret parameter from upgrade", "parameter", s) + continue + } + if parsed.Target != "" { + if _, ok := existingTargets[parsed.Target]; ok { + continue + } + existingTargets[parsed.Target] = struct{}{} + } + merged = append(merged, s) + } + + return merged +} diff --git a/pkg/workloads/upgrade/applier_test.go b/pkg/workloads/upgrade/applier_test.go new file mode 100644 index 0000000000..cc11fc1a05 --- /dev/null +++ b/pkg/workloads/upgrade/applier_test.go @@ -0,0 +1,622 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/permissions" + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/auth" + appconfig "github.com/stacklok/toolhive/pkg/config" + configmocks "github.com/stacklok/toolhive/pkg/config/mocks" + "github.com/stacklok/toolhive/pkg/container/templates" + registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/runner/retriever" + "github.com/stacklok/toolhive/pkg/telemetry" + transporttypes "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/workloads" + workloadmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" +) + +const applyServerName = "example-server" + +// applierHarness wires an Applier with mocked dependencies and swappable +// resolve/enforce/load function fields for deterministic, disk-free tests. +type applierHarness struct { + applier *Applier + manager *workloadmocks.MockManager + provider *registrymocks.MockProvider + configMock *configmocks.MockProvider + + // loadState is what loadStateFn returns. + loadConfig *runner.RunConfig + loadErr error + + // resolve controls resolveFn. + resolveImageURL string + resolveMeta regtypes.ServerMetadata + resolveErr error + + // enforce controls enforcePullFn. + enforceErr error + + // captured records ordering and the config passed to each stage. + calls []string + enforcedConfig *runner.RunConfig + updatedConfig *runner.RunConfig +} + +func newApplierHarness(t *testing.T) *applierHarness { + t.Helper() + ctrl := gomock.NewController(t) + + h := &applierHarness{ + manager: workloadmocks.NewMockManager(ctrl), + provider: registrymocks.NewMockProvider(ctrl), + configMock: configmocks.NewMockProvider(ctrl), + } + + checker, err := NewChecker(h.provider) + require.NoError(t, err) + + applier, err := NewApplier(h.manager, checker, h.configMock) + require.NoError(t, err) + + // Swap the package-level function wrappers for stubs that record ordering. + applier.loadStateFn = func(_ context.Context, _ string) (*runner.RunConfig, error) { + h.calls = append(h.calls, "load") + return h.loadConfig, h.loadErr + } + applier.resolveFn = func( + _ context.Context, _ string, _ string, _ string, _ string, _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + h.calls = append(h.calls, "resolve") + return h.resolveImageURL, h.resolveMeta, h.resolveErr + } + applier.enforcePullFn = func( + _ context.Context, rc *runner.RunConfig, _ regtypes.ServerMetadata, _ string, + _ retriever.ImagePuller, _ time.Duration, _ bool, + ) error { + h.calls = append(h.calls, "enforce") + h.enforcedConfig = rc + return h.enforceErr + } + + h.applier = applier + return h +} + +// expectUpdate sets up the UpdateWorkload mock to record the config and order. +func (h *applierHarness) expectUpdate() { + h.manager.EXPECT(). + UpdateWorkload(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, cfg *runner.RunConfig) (workloads.CompletionFunc, error) { + h.calls = append(h.calls, "update") + h.updatedConfig = cfg + // The completion func records that Apply awaited it. UpdateWorkload + // persists the new config asynchronously, so Apply must block on this + // before returning or callers observe a stale, pre-upgrade workload. + return func() error { h.calls = append(h.calls, "complete"); return nil }, nil + }) +} + +func upgradeableConfig(t *testing.T) *runner.RunConfig { + t.Helper() + // Use a real directory for the volume source: the run config builder + // validates volume source paths against the filesystem. + volSrc := t.TempDir() + return &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: applyServerName, + Group: "default", + Transport: transporttypes.TransportTypeStdio, + ProxyMode: transporttypes.ProxyModeStreamableHTTP, + Port: 8080, + EnvVars: map[string]string{"LOG_LEVEL": "info"}, + Secrets: []string{"mykey,target=API_KEY"}, + Volumes: []string{volSrc + ":/container"}, + } +} + +func TestNewApplier(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr := workloadmocks.NewMockManager(ctrl) + checker, err := NewChecker(registrymocks.NewMockProvider(ctrl)) + require.NoError(t, err) + cfgProvider := configmocks.NewMockProvider(ctrl) + + t.Run("nil manager fails", func(t *testing.T) { + t.Parallel() + a, err := NewApplier(nil, checker, cfgProvider) + require.Error(t, err) + assert.Nil(t, a) + }) + + t.Run("nil checker fails", func(t *testing.T) { + t.Parallel() + a, err := NewApplier(mgr, nil, cfgProvider) + require.Error(t, err) + assert.Nil(t, a) + }) + + t.Run("nil config provider fails", func(t *testing.T) { + t.Parallel() + a, err := NewApplier(mgr, checker, nil) + require.Error(t, err) + assert.Nil(t, a) + }) + + t.Run("valid inputs succeed with real funcs populated", func(t *testing.T) { + t.Parallel() + a, err := NewApplier(mgr, checker, cfgProvider) + require.NoError(t, err) + require.NotNil(t, a) + assert.NotNil(t, a.resolveFn) + assert.NotNil(t, a.enforcePullFn) + assert.NotNil(t, a.loadStateFn) + }) +} + +func TestApplier_Apply_NoOpWhenNotUpgradeAvailable(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + // up-to-date: candidate equals current => StatusUpToDate. + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.0.0"}, nil) + + // UpdateWorkload, resolve, enforce must NEVER be called. + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, StatusUpToDate, res.Status) + assert.NotContains(t, h.calls, "resolve") + assert.NotContains(t, h.calls, "enforce") + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_NoOpWhenNotRegistrySourced(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + cfg := upgradeableConfig(t) + cfg.RegistryServerName = "" // not registry-sourced + h.loadConfig = cfg + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, StatusNotRegistrySourced, res.Status) + assert.NotContains(t, h.calls, "resolve") + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_LoadStateFailure(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + h.loadErr = errors.New("no such workload") + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{}) + require.Error(t, err) + assert.Nil(t, res) + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_ResolveFailureLeavesWorkloadUntouched(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveErr = errors.New("verification failed") + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{}) + require.Error(t, err) + assert.Nil(t, res) + // resolve attempted, but no enforce and no destructive update. + assert.Contains(t, h.calls, "resolve") + assert.NotContains(t, h.calls, "enforce") + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_RemoteMetadataIsRejected(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + // Resolve succeeds but returns a non-image (remote) metadata. + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.RemoteServerMetadata{} + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{}) + require.Error(t, err) + assert.Nil(t, res) + assert.Contains(t, h.calls, "resolve") + assert.NotContains(t, h.calls, "enforce") + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_EnforcePullFailureLeavesWorkloadUntouched(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + h.enforceErr = errors.New("policy denied") + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.Error(t, err) + assert.Nil(t, res) + assert.Contains(t, h.calls, "enforce") + assert.NotContains(t, h.calls, "update") +} + +func TestApplier_Apply_HappyPath(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + old := upgradeableConfig(t) + h.loadConfig = old + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + candidate := ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + candidate.Name = applyServerName + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = candidate + h.configMock.EXPECT(). + GetConfig(). + Return(&appconfig.Config{RegistryApiUrl: "https://api.example", RegistryUrl: "https://reg.example"}). + AnyTimes() + h.expectUpdate() + + res, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVars: map[string]string{"EXTRA": "val"}, + Secrets: []string{"othersecret,target=OTHER"}, + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, StatusUpgradeAvailable, res.Status) + + // The destructive update must have run with the candidate image. + require.NotNil(t, h.updatedConfig) + assert.Equal(t, "ghcr.io/example/server:1.2.0", h.updatedConfig.Image, "image must be the candidate") + assert.Equal(t, applyServerName, h.updatedConfig.RegistryServerName, "registry server name preserved") + assert.Equal(t, "wl", h.updatedConfig.Name, "name preserved") + assert.Equal(t, "default", h.updatedConfig.Group, "group preserved") + + // User env vars preserved + opts merged. + assert.Equal(t, "info", h.updatedConfig.EnvVars["LOG_LEVEL"], "existing env preserved") + assert.Equal(t, "val", h.updatedConfig.EnvVars["EXTRA"], "opts env merged") + + // Secrets preserved + opts merged. + assert.Contains(t, h.updatedConfig.Secrets, "mykey,target=API_KEY", "existing secret preserved") + assert.Contains(t, h.updatedConfig.Secrets, "othersecret,target=OTHER", "opts secret merged") + + // Registry source URLs recorded from app config. + assert.Equal(t, "https://api.example", h.updatedConfig.RegistryAPIURL) + assert.Equal(t, "https://reg.example", h.updatedConfig.RegistryURL) + + // Security guarantee: the exact config that was gated+pulled is the one that + // gets deployed, so the verified image is the one that runs. + assert.Same(t, h.enforcedConfig, h.updatedConfig, "pulled config must be the deployed config") +} + +func TestApplier_Apply_OptsEnvOverridesExisting(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + h.expectUpdate() + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVars: map[string]string{"LOG_LEVEL": "debug"}, // overrides existing "info" + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + require.NotNil(t, h.updatedConfig) + assert.Equal(t, "debug", h.updatedConfig.EnvVars["LOG_LEVEL"], "opts env overrides existing") +} + +func TestApplier_Apply_DoesNotMutateOldConfig(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + old := upgradeableConfig(t) + // A non-named profile object plus a volume is what exposes the mutation: the + // builder's processVolumeMounts appends the volume to PermissionProfile.Read + // or .Write. If the builder is handed old's profile by pointer, those appends + // leak back into old. (upgradeableConfig already sets a Volume.) + old.PermissionProfile = &permissions.Profile{Name: "custom"} + wantVolumes := slices.Clone(old.Volumes) + wantProfileRead := slices.Clone(old.PermissionProfile.Read) + wantProfileWrite := slices.Clone(old.PermissionProfile.Write) + wantOverride := map[string]runner.ToolOverride{"a": {Name: "b"}} + old.ToolsOverride = map[string]runner.ToolOverride{"a": {Name: "b"}} + h.loadConfig = old + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + h.expectUpdate() + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVars: map[string]string{"EXTRA": "val"}, + Secrets: []string{"othersecret,target=OTHER"}, + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + + // old must be untouched: image, env map, secrets slice, volumes slice. + assert.Equal(t, "ghcr.io/example/server:1.0.0", old.Image, "old image unchanged") + assert.Equal(t, map[string]string{"LOG_LEVEL": "info"}, old.EnvVars, "old EnvVars unchanged") + assert.Equal(t, []string{"mykey,target=API_KEY"}, old.Secrets, "old Secrets unchanged") + assert.Equal(t, wantVolumes, old.Volumes, "old Volumes unchanged") + // old's permission profile must not gain mounts from the volume processing. + assert.Equal(t, wantProfileRead, old.PermissionProfile.Read, "old profile Read unchanged") + assert.Equal(t, wantProfileWrite, old.PermissionProfile.Write, "old profile Write unchanged") + assert.Equal(t, wantOverride, old.ToolsOverride, "old ToolsOverride unchanged") +} + +func TestApplier_Apply_VerifyAndPullHappenBeforeUpdate(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + h.expectUpdate() + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + + // Ordering: load -> resolve -> enforce(verify+pull) -> update -> await completion. + assert.Equal(t, []string{"load", "resolve", "enforce", "update", "complete"}, h.calls) +} + +func TestApplier_Apply_CompletionFailureSurfaces(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + + // UpdateWorkload starts the recreate but its completion reports a failure + // (e.g. the new workload failed to start). Apply must await completion and + // surface that error rather than reporting a successful upgrade. + h.manager.EXPECT(). + UpdateWorkload(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, _ *runner.RunConfig) (workloads.CompletionFunc, error) { + h.calls = append(h.calls, "update") + return func() error { return errors.New("new workload failed to start") }, nil + }) + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to complete upgrade") + // The error MUST carry ErrApplyAfterDestroy: the recreate already began, so + // callers (e.g. the API handler) map this to 5xx, not a 422 "nothing changed". + assert.ErrorIs(t, err, ErrApplyAfterDestroy) + assert.Contains(t, h.calls, "update") +} + +func TestApplier_Apply_UpdateFailureIsTaggedAfterDestroy(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + h.loadConfig = upgradeableConfig(t) + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + + // UpdateWorkload itself fails after initiating the stop/delete: the workload + // state is uncertain, so the error must carry ErrApplyAfterDestroy. + h.manager.EXPECT(). + UpdateWorkload(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, _ *runner.RunConfig) (workloads.CompletionFunc, error) { + h.calls = append(h.calls, "update") + return nil, errors.New("stop/delete failed mid-recreate") + }) + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update workload") + assert.ErrorIs(t, err, ErrApplyAfterDestroy) +} + +// fullyConfiguredOld builds a workload config exercising the breadth of +// user-owned RunConfig fields, including HTTP transport with explicit ports and +// a named permission profile, to guard against the config-loss regression. +func fullyConfiguredOld(t *testing.T) *runner.RunConfig { + t.Helper() + volSrc := t.TempDir() + + mw, err := transporttypes.NewMiddlewareConfig(auth.MiddlewareType, auth.MiddlewareParams{}) + require.NoError(t, err) + + return &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: applyServerName, + Group: "prod", + Transport: transporttypes.TransportTypeStreamableHTTP, + ProxyMode: transporttypes.ProxyModeStreamableHTTP, + Host: "127.0.0.1", + Port: 18080, + TargetPort: 19090, + TargetHost: "10.0.0.5", + Publish: []string{"18080:18080"}, + EnvVars: map[string]string{"LOG_LEVEL": "info"}, + Secrets: []string{"mykey,target=API_KEY"}, + Volumes: []string{volSrc + ":/container"}, + CmdArgs: []string{"--flag", "value"}, + PermissionProfileNameOrPath: "none", + IsolateNetwork: true, + TrustProxyHeaders: true, + Stateless: true, + EndpointPrefix: "/mcp", + SessionTTL: "30m", + Debug: true, + ContainerLabels: map[string]string{"team": "platform"}, + OIDCConfig: &auth.TokenValidatorConfig{Issuer: "https://issuer.example", Audience: "aud"}, + AuthzConfigPath: "/etc/thv/authz.yaml", + AuditConfigPath: "/etc/thv/audit.yaml", + TelemetryConfig: &telemetry.Config{Endpoint: "otel.example:4317", ServiceName: "wl"}, + ToolsFilter: []string{"safe_tool"}, + ToolsOverride: map[string]runner.ToolOverride{"a": {Name: "b"}}, + MiddlewareConfigs: []transporttypes.MiddlewareConfig{*mw}, + } +} + +func TestApplier_Apply_PreservesFullUserConfig(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + old := fullyConfiguredOld(t) + h.loadConfig = old + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + // The candidate metadata advertises the same HTTP transport so the builder + // does not need to invent transport/ports; ports come from old. + candidate := ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + candidate.Transport = string(transporttypes.TransportTypeStreamableHTTP) + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = candidate + h.configMock.EXPECT(). + GetConfig(). + Return(&appconfig.Config{RegistryApiUrl: "https://api.example", RegistryUrl: "https://reg.example"}). + AnyTimes() + h.expectUpdate() + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + require.NotNil(t, h.updatedConfig) + got := h.updatedConfig + + // The ONLY changes are image and registry URLs (no env/secret opts here). + assert.Equal(t, "ghcr.io/example/server:1.2.0", got.Image, "image is candidate") + assert.Equal(t, "https://api.example", got.RegistryAPIURL) + assert.Equal(t, "https://reg.example", got.RegistryURL) + + // Everything else preserved from old (the config-loss regression guard). + assert.Equal(t, old.Name, got.Name) + assert.Equal(t, old.Group, got.Group) + assert.Equal(t, old.Transport, got.Transport, "HTTP transport preserved") + assert.Equal(t, old.Host, got.Host) + assert.Equal(t, old.Port, got.Port, "HTTP proxy port preserved") + assert.Equal(t, old.TargetPort, got.TargetPort, "HTTP target port preserved") + assert.Equal(t, "10.0.0.5", got.TargetHost, "target host preserved (not re-derived)") + assert.Equal(t, old.Publish, got.Publish, "published ports preserved") + assert.Equal(t, old.ProxyMode, got.ProxyMode) + assert.Equal(t, old.CmdArgs, got.CmdArgs) + assert.Equal(t, old.PermissionProfileNameOrPath, got.PermissionProfileNameOrPath, "named profile preserved") + assert.Equal(t, old.IsolateNetwork, got.IsolateNetwork) + assert.Equal(t, old.TrustProxyHeaders, got.TrustProxyHeaders) + assert.Equal(t, old.Stateless, got.Stateless) + assert.Equal(t, old.EndpointPrefix, got.EndpointPrefix) + assert.Equal(t, old.SessionTTL, got.SessionTTL) + assert.Equal(t, old.Debug, got.Debug) + assert.Equal(t, "platform", got.ContainerLabels["team"], "user label preserved") + assert.Equal(t, old.OIDCConfig, got.OIDCConfig, "OIDC config preserved") + assert.Equal(t, old.AuthzConfigPath, got.AuthzConfigPath, "authz path preserved") + assert.Equal(t, old.AuditConfigPath, got.AuditConfigPath, "audit path preserved") + assert.Equal(t, old.TelemetryConfig, got.TelemetryConfig, "telemetry preserved") + assert.Equal(t, old.ToolsFilter, got.ToolsFilter, "tools filter preserved (security)") + assert.Equal(t, old.ToolsOverride, got.ToolsOverride) + assert.Equal(t, old.MiddlewareConfigs, got.MiddlewareConfigs, "middleware chain preserved") +} + +func TestApplier_Apply_PreservesHTTPTransportPorts(t *testing.T) { + t.Parallel() + h := newApplierHarness(t) + + old := &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: applyServerName, + Group: "default", + Transport: transporttypes.TransportTypeStreamableHTTP, + ProxyMode: transporttypes.ProxyModeStreamableHTTP, + Port: 21000, + TargetPort: 22000, + EnvVars: map[string]string{}, + } + h.loadConfig = old + h.provider.EXPECT(). + GetServer(applyServerName). + Return(®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"}, nil) + candidate := ®types.ImageMetadata{Image: "ghcr.io/example/server:1.2.0"} + candidate.Transport = string(transporttypes.TransportTypeStreamableHTTP) + // Candidate advertises DIFFERENT registry ports; old's ports must win. + candidate.ProxyPort = 30000 + candidate.TargetPort = 31000 + h.resolveImageURL = "ghcr.io/example/server:1.2.0" + h.resolveMeta = candidate + h.configMock.EXPECT().GetConfig().Return(&appconfig.Config{}).AnyTimes() + h.expectUpdate() + + _, err := h.applier.Apply(context.Background(), "wl", ApplyOptions{ + EnvVarValidator: &runner.DetachedEnvVarValidator{}, + }) + require.NoError(t, err) + require.NotNil(t, h.updatedConfig) + assert.Equal(t, 21000, h.updatedConfig.Port, "existing proxy port preserved over candidate's") + assert.Equal(t, 22000, h.updatedConfig.TargetPort, "existing target port preserved over candidate's") +} diff --git a/pkg/workloads/upgrade/checker.go b/pkg/workloads/upgrade/checker.go new file mode 100644 index 0000000000..ac62436cb4 --- /dev/null +++ b/pkg/workloads/upgrade/checker.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "context" + "errors" + "fmt" + "log/slog" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/secrets" +) + +// Checker determines whether registry-sourced workloads have an available +// upgrade by comparing their current image and configuration against the +// metadata the injected registry provider reports. +type Checker struct { + provider registry.Provider +} + +// NewChecker creates a Checker backed by the given registry provider. +// +// The provider is the source of truth for candidate image metadata; callers +// typically pass the shared singleton from registry.GetDefaultProvider so the +// provider's response cache is reused across checks. It returns an error if the +// provider is nil. +func NewChecker(provider registry.Provider) (*Checker, error) { + if provider == nil { + return nil, fmt.Errorf("registry provider must not be nil") + } + return &Checker{provider: provider}, nil +} + +// Check evaluates a single workload's RunConfig against the registry and +// returns the upgrade status. It never mutates the supplied config. Per-item +// problems (missing server, unparsable tags, non-image entries) are encoded in +// the returned CheckResult's Status/Reason rather than returned as an error; +// an error is returned only for an invalid call (nil config). +func (c *Checker) Check(_ context.Context, cfg *runner.RunConfig) (*CheckResult, error) { + if cfg == nil { + return nil, fmt.Errorf("run config must not be nil") + } + + result := &CheckResult{ + WorkloadName: cfg.Name, + RegistryServer: cfg.RegistryServerName, + CurrentImage: cfg.Image, + } + + if cfg.RegistryServerName == "" { + result.Status = StatusNotRegistrySourced + return result, nil + } + + server, err := c.provider.GetServer(cfg.RegistryServerName) + if err != nil { + if errors.Is(err, registry.ErrServerNotFound) { + result.Status = StatusServerNotFound + return result, nil + } + // Keep the detailed provider error out of the result: Reason is + // serialized into the HTTP response, and for an unreachable or + // misconfigured registry the raw error can carry internal addressing + // (e.g. "dial tcp 10.x.x.x:443: ..."). Log it for operators instead. + slog.Debug("registry lookup failed", "server", cfg.RegistryServerName, "error", err) + result.Status = StatusUnknown + result.Reason = "registry lookup failed" + return result, nil + } + + imgMeta, ok := server.(*regtypes.ImageMetadata) + if !ok { + result.Status = StatusUnknown + result.Reason = fmt.Sprintf("registry entry %q is not a container image (cannot determine upgrade)", cfg.RegistryServerName) + return result, nil + } + + result.CandidateImage = imgMeta.Image + + comparison, reason := compareImageTags(cfg.Image, imgMeta.Image) + switch comparison { + case comparisonNewer: + result.Status = StatusUpgradeAvailable + result.EnvVarDrift = computeEnvDrift(cfg, imgMeta) + result.ConfigDrift = computeConfigDrift(cfg, imgMeta) + case comparisonSameOrOlder: + result.Status = StatusUpToDate + case comparisonUndecidable: + result.Status = StatusUnknown + result.Reason = reason + default: + // Defensive: a future tagComparison value (or an unset zero value) must + // not fall through to the least-safe StatusUpToDate. Treat anything + // unexpected as unknown. + result.Status = StatusUnknown + } + + return result, nil +} + +// CheckAll evaluates a batch of workloads. It never returns an error: each +// workload's outcome (including per-item failures) is encoded in its own +// CheckResult. The returned slice preserves the input order. Nil entries in the +// input are skipped. +func (c *Checker) CheckAll(ctx context.Context, configs []*runner.RunConfig) []*CheckResult { + results := make([]*CheckResult, 0, len(configs)) + for _, cfg := range configs { + if cfg == nil { + continue + } + // Check only errors on a nil config, which we already guarded against, + // so the error here is unreachable; encode defensively rather than drop. + res, err := c.Check(ctx, cfg) + if err != nil { + slog.Debug("upgrade check failed", "workload", cfg.Name, "error", err) + res = &CheckResult{ + WorkloadName: cfg.Name, + Status: StatusUnknown, + Reason: "check failed", + } + } + results = append(results, res) + } + return results +} + +// computeEnvDrift reports the candidate environment variables the workload does +// not currently satisfy. A variable is considered satisfied if it appears as a +// plain env var key in the config, or as the target of one of the config's +// secret parameters. Removed is left unpopulated (best-effort, forward-compat). +// +// It treats the config as read-only. Returns nil when there is no drift. +func computeEnvDrift(cfg *runner.RunConfig, imgMeta *regtypes.ImageMetadata) *EnvVarDrift { + satisfied := make(map[string]struct{}, len(cfg.EnvVars)+len(cfg.Secrets)) + for k := range cfg.EnvVars { + satisfied[k] = struct{}{} + } + for _, s := range cfg.Secrets { + parsed, err := secrets.ParseSecretParameter(s) + if err != nil { + // Malformed secret parameters can't satisfy a variable; skip them. + continue + } + if parsed.Target != "" { + satisfied[parsed.Target] = struct{}{} + } + } + + var added []EnvVarInfo + for _, ev := range imgMeta.EnvVars { + if ev == nil { + continue + } + if _, ok := satisfied[ev.Name]; ok { + continue + } + added = append(added, toEnvVarInfo(ev)) + } + + if len(added) == 0 { + return nil + } + return &EnvVarDrift{Added: added} +} + +// computeConfigDrift reports posture differences between the workload's current +// configuration and the candidate registry entry. Each field is nil when that +// aspect did not drift or could not be compared. +// +// The permission profile is compared against imgMeta.Permissions.Name (a +// *permissions.Profile, not a string). Comparison degrades gracefully: when the +// candidate has no profile, or the workload's profile is a custom name/path +// that has no registry analogue, that dimension is not reported as drift unless +// both sides are known and differ. It treats the config as read-only. +func computeConfigDrift(cfg *runner.RunConfig, imgMeta *regtypes.ImageMetadata) *ConfigDrift { + drift := &ConfigDrift{} + + // Transport: compare the workload's transport string against the registry + // entry's transport. GetTransport() may return an empty string when the + // registry entry does not declare one; only report drift when both are set. + currentTransport := cfg.Transport.String() + candidateTransport := imgMeta.GetTransport() + if candidateTransport != "" && currentTransport != "" && currentTransport != candidateTransport { + drift.Transport = &StringChange{From: currentTransport, To: candidateTransport} + } + + // Permission profile: compare names. The candidate name is only known when + // the registry entry carries a profile with a non-empty Name. + candidateProfile := "" + if imgMeta.Permissions != nil { + candidateProfile = imgMeta.Permissions.Name + } + currentProfile := cfg.PermissionProfileNameOrPath + if candidateProfile != "" && currentProfile != "" && currentProfile != candidateProfile { + drift.PermissionProfile = &StringChange{From: currentProfile, To: candidateProfile} + } + + if drift.Transport == nil && drift.PermissionProfile == nil { + return nil + } + return drift +} + +// toEnvVarInfo converts a registry EnvVar into the drift-report shape, clearing +// the Default value when the variable is a secret to avoid leaking sensitive +// data into reports that may be logged or returned over the API. +func toEnvVarInfo(ev *regtypes.EnvVar) EnvVarInfo { + info := EnvVarInfo{ + Name: ev.Name, + Description: ev.Description, + Required: ev.Required, + Secret: ev.Secret, + Default: ev.Default, + } + if info.Secret { + info.Default = "" + } + return info +} diff --git a/pkg/workloads/upgrade/checker_test.go b/pkg/workloads/upgrade/checker_test.go new file mode 100644 index 0000000000..797b0a5053 --- /dev/null +++ b/pkg/workloads/upgrade/checker_test.go @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/permissions" + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry" + registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/runner" + transporttypes "github.com/stacklok/toolhive/pkg/transport/types" +) + +const testServerName = "example-server" + +func TestNewChecker(t *testing.T) { + t.Parallel() + + t.Run("nil provider fails", func(t *testing.T) { + t.Parallel() + c, err := NewChecker(nil) + require.Error(t, err) + assert.Nil(t, c) + }) + + t.Run("valid provider succeeds", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + c, err := NewChecker(registrymocks.NewMockProvider(ctrl)) + require.NoError(t, err) + assert.NotNil(t, c) + }) +} + +func TestChecker_Check(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *runner.RunConfig + setupMock func(m *registrymocks.MockProvider) + wantStatus UpgradeStatus + wantReason bool // true if Reason must be non-empty + assertMore func(t *testing.T, res *CheckResult) + }{ + { + name: "not registry sourced", + cfg: &runner.RunConfig{ + Name: "raw", + Image: "ghcr.io/example/server:1.0.0", + }, + setupMock: func(_ *registrymocks.MockProvider) {}, + wantStatus: StatusNotRegistrySourced, + }, + { + name: "server not found", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(nil, registry.ErrServerNotFound) + }, + wantStatus: StatusServerNotFound, + }, + { + name: "registry lookup error is unknown", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(nil, fmt.Errorf("network down")) + }, + wantStatus: StatusUnknown, + wantReason: true, + }, + { + name: "non-image entry is unknown", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(®types.RemoteServerMetadata{}, nil) + }, + wantStatus: StatusUnknown, + wantReason: true, + }, + { + name: "up to date", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.2.0", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(imageMeta("ghcr.io/example/server:1.2.0"), nil) + }, + wantStatus: StatusUpToDate, + }, + { + name: "undecidable tags is unknown", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:latest", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(imageMeta("ghcr.io/example/server:1.2.0"), nil) + }, + wantStatus: StatusUnknown, + wantReason: true, + }, + { + name: "upgrade available with no drift", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: testServerName, + }, + setupMock: func(m *registrymocks.MockProvider) { + m.EXPECT().GetServer(testServerName).Return(imageMeta("ghcr.io/example/server:1.2.0"), nil) + }, + wantStatus: StatusUpgradeAvailable, + assertMore: func(t *testing.T, res *CheckResult) { + t.Helper() + assert.Equal(t, "ghcr.io/example/server:1.2.0", res.CandidateImage) + assert.Nil(t, res.EnvVarDrift) + assert.Nil(t, res.ConfigDrift) + }, + }, + { + name: "upgrade available surfaces env and config drift", + cfg: &runner.RunConfig{ + Name: "wl", + Image: "ghcr.io/example/server:1.0.0", + RegistryServerName: testServerName, + Transport: transporttypes.TransportTypeStdio, + PermissionProfileNameOrPath: "none", + EnvVars: map[string]string{"LOG_LEVEL": "info"}, + Secrets: []string{"mykey,target=API_KEY"}, + }, + setupMock: func(m *registrymocks.MockProvider) { + meta := imageMeta("ghcr.io/example/server:1.2.0") + meta.Transport = string(transporttypes.TransportTypeStreamableHTTP) + meta.Permissions = &permissions.Profile{Name: "network"} + meta.EnvVars = []*regtypes.EnvVar{ + {Name: "LOG_LEVEL"}, // satisfied via EnvVars + {Name: "API_KEY", Secret: true}, // satisfied via secret target + {Name: "NEW_VAR", Required: true}, // drift: added + {Name: "NEW_SECRET", Secret: true, Default: "leak-me"}, // drift: added, default cleared + } + m.EXPECT().GetServer(testServerName).Return(meta, nil) + }, + wantStatus: StatusUpgradeAvailable, + assertMore: func(t *testing.T, res *CheckResult) { + t.Helper() + require.NotNil(t, res.EnvVarDrift) + names := make(map[string]EnvVarInfo, len(res.EnvVarDrift.Added)) + for _, e := range res.EnvVarDrift.Added { + names[e.Name] = e + } + assert.Len(t, names, 2) + assert.Contains(t, names, "NEW_VAR") + require.Contains(t, names, "NEW_SECRET") + assert.Empty(t, names["NEW_SECRET"].Default, "secret default must be cleared in drift") + + require.NotNil(t, res.ConfigDrift) + require.NotNil(t, res.ConfigDrift.Transport) + assert.Equal(t, "stdio", res.ConfigDrift.Transport.From) + assert.Equal(t, "streamable-http", res.ConfigDrift.Transport.To) + require.NotNil(t, res.ConfigDrift.PermissionProfile) + assert.Equal(t, "none", res.ConfigDrift.PermissionProfile.From) + assert.Equal(t, "network", res.ConfigDrift.PermissionProfile.To) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockProvider := registrymocks.NewMockProvider(ctrl) + tt.setupMock(mockProvider) + + checker, err := NewChecker(mockProvider) + require.NoError(t, err) + + res, err := checker.Check(t.Context(), tt.cfg) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, tt.wantStatus, res.Status) + if tt.wantReason { + assert.NotEmpty(t, res.Reason) + } + if tt.assertMore != nil { + tt.assertMore(t, res) + } + }) + } +} + +func TestChecker_Check_NilConfig(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker, err := NewChecker(registrymocks.NewMockProvider(ctrl)) + require.NoError(t, err) + + res, err := checker.Check(t.Context(), nil) + require.Error(t, err) + assert.Nil(t, res) +} + +func TestChecker_CheckAll(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockProvider := registrymocks.NewMockProvider(ctrl) + mockProvider.EXPECT().GetServer(testServerName).Return(imageMeta("ghcr.io/example/server:1.2.0"), nil) + + checker, err := NewChecker(mockProvider) + require.NoError(t, err) + + configs := []*runner.RunConfig{ + nil, // skipped + {Name: "raw", Image: "ghcr.io/example/server:1.0.0"}, // not-registry-sourced + {Name: "wl", Image: "ghcr.io/example/server:1.0.0", RegistryServerName: testServerName}, // upgrade-available + } + + results := checker.CheckAll(t.Context(), configs) + require.Len(t, results, 2, "nil config must be skipped") + assert.Equal(t, StatusNotRegistrySourced, results[0].Status) + assert.Equal(t, StatusUpgradeAvailable, results[1].Status) +} + +func TestComputeEnvDrift_NoDrift(t *testing.T) { + t.Parallel() + cfg := &runner.RunConfig{ + EnvVars: map[string]string{"FOO": "bar"}, + Secrets: []string{"sk,target=TOKEN"}, + } + meta := imageMeta("ghcr.io/example/server:1.0.0") + meta.EnvVars = []*regtypes.EnvVar{ + {Name: "FOO"}, + {Name: "TOKEN", Secret: true}, + nil, // tolerated + } + assert.Nil(t, computeEnvDrift(cfg, meta)) +} + +func TestComputeConfigDrift_GracefulDegradation(t *testing.T) { + t.Parallel() + + t.Run("nil permissions and empty transport do not drift", func(t *testing.T) { + t.Parallel() + cfg := &runner.RunConfig{ + Transport: transporttypes.TransportTypeStdio, + PermissionProfileNameOrPath: "custom-profile", + } + meta := imageMeta("ghcr.io/example/server:1.0.0") + meta.Transport = "" // registry declares no transport + meta.Permissions = nil + assert.Nil(t, computeConfigDrift(cfg, meta)) + }) + + t.Run("network isolation alone does not drift", func(t *testing.T) { + t.Parallel() + // The registry has no network-isolation field, so a workload's own + // isolation choice is not registry-driven drift and must not be + // reported (it would otherwise fire for every isolated workload). + cfg := &runner.RunConfig{IsolateNetwork: true} + meta := imageMeta("ghcr.io/example/server:1.0.0") + meta.Transport = "" + assert.Nil(t, computeConfigDrift(cfg, meta)) + }) +} + +// imageMeta builds a minimal ImageMetadata for the given image reference. +func imageMeta(image string) *regtypes.ImageMetadata { + return ®types.ImageMetadata{Image: image} +} diff --git a/pkg/workloads/upgrade/doc.go b/pkg/workloads/upgrade/doc.go new file mode 100644 index 0000000000..be7d699c35 --- /dev/null +++ b/pkg/workloads/upgrade/doc.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package upgrade implements registry-sourced workload upgrade checks for +// ToolHive (RFC THV-0068). It compares the image and configuration of a +// running workload against the latest metadata served by the registry and +// reports whether an upgrade is available, along with any environment-variable +// or posture (transport/network/permission) drift. +// +// # Dependency direction (cycle guard) +// +// This package depends on pkg/workloads (for the apply path in later phases) +// and pkg/runner. To avoid an import cycle, pkg/workloads MUST NEVER import +// this package. Higher-level entry points (CLI, API handlers) wire the two +// together; the manager itself stays unaware of upgrade logic. +// +// # No rollback +// +// The apply path (Phase D) intentionally provides no rollback. It resolves, +// verifies, and pulls the candidate image BEFORE destroying the existing +// workload so that a failure during preparation leaves the running workload +// untouched. Once the workload is deleted and recreated, there is no automatic +// revert to the previous image or configuration; recovery is a forward +// operation (re-running the previous configuration explicitly). +package upgrade diff --git a/pkg/workloads/upgrade/types.go b/pkg/workloads/upgrade/types.go new file mode 100644 index 0000000000..9cc1ae44d8 --- /dev/null +++ b/pkg/workloads/upgrade/types.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/runner/retriever" +) + +// UpgradeStatus is the outcome of an upgrade check for a single workload. +// +//revive:disable-next-line:exported // "UpgradeStatus" reads better than "Status" at call sites. +type UpgradeStatus string + +const ( + // StatusUpToDate indicates the workload is already running the candidate + // image reported by the registry (or a newer one). + StatusUpToDate UpgradeStatus = "up-to-date" + + // StatusUpgradeAvailable indicates the registry reports a newer image than + // the one the workload is currently running. + StatusUpgradeAvailable UpgradeStatus = "upgrade-available" + + // StatusNotRegistrySourced indicates the workload was not created from a + // registry entry (no RegistryServerName), so no upgrade can be determined. + StatusNotRegistrySourced UpgradeStatus = "not-registry-sourced" + + // StatusServerNotFound indicates the workload references a registry server + // name that no longer exists in the configured registry. + StatusServerNotFound UpgradeStatus = "server-not-found" + + // StatusUnknown indicates the upgrade status could not be determined, e.g. + // the registry lookup failed, the entry is a remote (non-image) server, or + // the image tags are not comparable. The Reason field explains why. + StatusUnknown UpgradeStatus = "unknown" +) + +// CheckResult is the outcome of checking a single workload for an available +// upgrade. EnvVarDrift and ConfigDrift are only populated when an upgrade is +// available and the relevant drift was detected. +type CheckResult struct { + // WorkloadName is the name of the workload that was checked. + WorkloadName string `json:"workload_name"` + + // RegistryServer is the registry entry name the workload was sourced from. + // Empty when the workload is not registry-sourced. + RegistryServer string `json:"registry_server,omitempty"` + + // Status is the upgrade status for the workload. + Status UpgradeStatus `json:"status"` + + // CurrentImage is the image reference the workload is currently running. + CurrentImage string `json:"current_image,omitempty"` + + // CandidateImage is the image reference the registry currently reports. + CandidateImage string `json:"candidate_image,omitempty"` + + // Reason provides additional context, primarily for StatusUnknown. + Reason string `json:"reason,omitempty"` + + // EnvVarDrift describes environment variables the candidate registry entry + // declares that differ from the workload's current configuration. + EnvVarDrift *EnvVarDrift `json:"env_var_drift,omitempty"` + + // ConfigDrift describes posture differences (transport, permission profile) + // between the workload and the candidate registry entry. + ConfigDrift *ConfigDrift `json:"config_drift,omitempty"` +} + +// EnvVarDrift describes how the candidate registry entry's declared +// environment variables differ from those the workload currently satisfies. +type EnvVarDrift struct { + // Added lists environment variables the candidate declares that the + // workload does not currently supply (via plain env vars or secrets). + Added []EnvVarInfo `json:"added,omitempty"` + + // Removed lists environment variables the workload supplies that the + // candidate no longer declares. Populated on a best-effort basis; may be + // empty even when removals exist (forward-compatible field). + Removed []EnvVarInfo `json:"removed,omitempty"` +} + +// EnvVarInfo is a registry-declared environment variable surfaced in drift. +type EnvVarInfo struct { + // Name is the environment variable name. + Name string `json:"name"` + + // Description is the human-readable purpose of the variable. + Description string `json:"description,omitempty"` + + // Required indicates whether the candidate marks the variable as required. + Required bool `json:"required"` + + // Secret indicates whether the variable holds sensitive data. + Secret bool `json:"secret,omitempty"` + + // Default is the candidate's default value. It is cleared (left empty) + // whenever Secret is true: a secret env var's default could carry sensitive + // data, and surfacing it in a drift report (which may be logged or returned + // over the API) would leak it. Non-secret defaults are safe to display. + Default string `json:"default,omitempty"` +} + +// ConfigDrift describes posture differences between a workload's current +// configuration and the candidate registry entry. A nil field means that +// aspect did not drift (or could not be compared). +type ConfigDrift struct { + // Transport is set when the candidate's transport differs from the + // workload's current transport. + Transport *StringChange `json:"transport,omitempty"` + + // PermissionProfile is set when the candidate's permission profile differs + // from the workload's current profile. + PermissionProfile *StringChange `json:"permission_profile,omitempty"` +} + +// StringChange records a string-valued configuration change from the +// workload's current value to the candidate registry value. +type StringChange struct { + From string `json:"from"` + To string `json:"to"` +} + +// ApplyOptions controls how an upgrade is applied to a workload. +// +// It is defined here for use by the Applier (Phase D). No apply logic is +// implemented in this phase. +type ApplyOptions struct { + // EnvVars are additional or overriding environment variables to merge into + // the upgraded workload's configuration. + EnvVars map[string]string + + // Secrets are additional secret parameters (`,target=`) to merge + // into the upgraded workload's configuration. + Secrets []string + + // EnvVarValidator validates that required environment variables and secrets + // are supplied for the candidate registry entry. + EnvVarValidator runner.EnvVarValidator + + // VerifySetting controls image signature verification. Empty defaults to + // retriever.VerifyImageWarn. + VerifySetting string + + // CACertPath is an optional path to a CA certificate bundle used when + // resolving the candidate image from a registry over TLS. + CACertPath string +} + +// defaultVerifySetting returns the configured verification setting, falling +// back to retriever.VerifyImageWarn when unset. +func (o ApplyOptions) defaultVerifySetting() string { + if o.VerifySetting == "" { + return retriever.VerifyImageWarn + } + return o.VerifySetting +} diff --git a/pkg/workloads/upgrade/types_test.go b/pkg/workloads/upgrade/types_test.go new file mode 100644 index 0000000000..2acc01a514 --- /dev/null +++ b/pkg/workloads/upgrade/types_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/runner/retriever" +) + +// TestEnvVarInfoMirrorsRegistryEnvVar is a drift guard: EnvVarInfo carries a +// curated, display-safe subset of regtypes.EnvVar. If a new field is added to +// regtypes.EnvVar, this test fails so a maintainer consciously decides whether +// the new field should be surfaced (and whether it is safe to display). +func TestEnvVarInfoMirrorsRegistryEnvVar(t *testing.T) { + t.Parallel() + + regFields := structFieldNames(reflect.TypeOf(regtypes.EnvVar{})) + infoFields := structFieldNames(reflect.TypeOf(EnvVarInfo{})) + + // Every field of regtypes.EnvVar that we expect to mirror must be present in + // EnvVarInfo. If regtypes.EnvVar grows, update this expectation deliberately. + expectedRegFields := []string{"Name", "Description", "Required", "Default", "Secret"} + assert.ElementsMatch(t, expectedRegFields, regFields, + "regtypes.EnvVar fields changed; review toEnvVarInfo and the EnvVarInfo drift surface") + + for _, f := range expectedRegFields { + assert.Contains(t, infoFields, f, "EnvVarInfo must mirror regtypes.EnvVar field %q", f) + } +} + +func TestToEnvVarInfo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in *regtypes.EnvVar + want EnvVarInfo + }{ + { + name: "non-secret keeps default", + in: ®types.EnvVar{ + Name: "LOG_LEVEL", + Description: "logging verbosity", + Required: false, + Default: "info", + Secret: false, + }, + want: EnvVarInfo{ + Name: "LOG_LEVEL", + Description: "logging verbosity", + Required: false, + Default: "info", + Secret: false, + }, + }, + { + name: "secret clears default", + in: ®types.EnvVar{ + Name: "API_KEY", + Description: "service api key", + Required: true, + Default: "super-secret-default", + Secret: true, + }, + want: EnvVarInfo{ + Name: "API_KEY", + Description: "service api key", + Required: true, + Default: "", // cleared because Secret == true + Secret: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := toEnvVarInfo(tt.in) + assert.Equal(t, tt.want, got) + if tt.in.Secret { + assert.Empty(t, got.Default, "secret env var default must be cleared") + } + }) + } +} + +func TestApplyOptionsDefaultVerifySetting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts ApplyOptions + want string + }{ + {name: "empty defaults to warn", opts: ApplyOptions{}, want: retriever.VerifyImageWarn}, + {name: "explicit setting preserved", opts: ApplyOptions{VerifySetting: retriever.VerifyImageEnabled}, want: retriever.VerifyImageEnabled}, + {name: "disabled preserved", opts: ApplyOptions{VerifySetting: retriever.VerifyImageDisabled}, want: retriever.VerifyImageDisabled}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.opts.defaultVerifySetting()) + }) + } +} + +// structFieldNames returns the exported field names of a struct type. +func structFieldNames(t reflect.Type) []string { + names := make([]string, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.IsExported() { + names = append(names, f.Name) + } + } + return names +} diff --git a/pkg/workloads/upgrade/version.go b/pkg/workloads/upgrade/version.go new file mode 100644 index 0000000000..280f62f78d --- /dev/null +++ b/pkg/workloads/upgrade/version.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "golang.org/x/mod/semver" +) + +// tagComparison is the result of comparing a workload's current image tag +// against a candidate registry image tag. +type tagComparison int + +const ( + // comparisonSameOrOlder means the candidate is the same as, or older than, + // the current image (no upgrade available). + comparisonSameOrOlder tagComparison = iota + + // comparisonNewer means the candidate is strictly newer than the current + // image (an upgrade is available). + comparisonNewer + + // comparisonUndecidable means the two references cannot be meaningfully + // compared (different repositories, a "latest" tag, or non-semver tags that + // are not equal). The accompanying reason explains why. + comparisonUndecidable +) + +// latestTag is the floating tag that defers to the registry and therefore +// cannot be compared for ordering. +const latestTag = "latest" + +// compareImageTags compares a workload's current image against a candidate +// registry image and reports whether the candidate represents an upgrade. +// +// Both references are parsed and their repositories are compared using the +// host-normalized fully-qualified name. The comparison is intentionally +// conservative: any situation it cannot reason about confidently (parse +// failure, differing repositories, a "latest" tag on either side, or +// non-equal non-semver tags) yields comparisonUndecidable with a reason rather +// than a guess. Equal semver or equal literal tags yield comparisonSameOrOlder. +func compareImageTags(current, candidate string) (tagComparison, string) { + curRepo, curTag, err := splitRepoTag(current) + if err != nil { + return comparisonUndecidable, fmt.Sprintf("cannot parse current image %q: %v", current, err) + } + candRepo, candTag, err := splitRepoTag(candidate) + if err != nil { + return comparisonUndecidable, fmt.Sprintf("cannot parse candidate image %q: %v", candidate, err) + } + + if curRepo != candRepo { + return comparisonUndecidable, + fmt.Sprintf("repository differs: current %q vs candidate %q", curRepo, candRepo) + } + + if curTag == latestTag || candTag == latestTag { + return comparisonUndecidable, "image uses the \"latest\" tag, which defers to the registry and cannot be ordered" + } + + curSemver := normalizeSemver(curTag) + candSemver := normalizeSemver(candTag) + if semver.IsValid(curSemver) && semver.IsValid(candSemver) { + switch semver.Compare(semver.Canonical(curSemver), semver.Canonical(candSemver)) { + case -1: + return comparisonNewer, "" + default: + // Equal or current is newer: nothing to upgrade to. + return comparisonSameOrOlder, "" + } + } + + // Fallback for non-semver tags: only an exact match is decidable. + if curTag == candTag { + return comparisonSameOrOlder, "" + } + return comparisonUndecidable, + fmt.Sprintf("tags are not comparable: current %q vs candidate %q", curTag, candTag) +} + +// splitRepoTag parses an image reference and returns its host-normalized +// fully-qualified repository name and tag string. +// +// It uses name.ParseReference and asserts the result to name.Tag, then reads +// the repository via Context().Name() and the tag via TagStr() (not +// Identifier(), which returns a digest for digest references). A reference that +// is not a tag (e.g. a bare digest) is rejected so the caller can treat it as +// undecidable rather than mis-compare a digest against a tag. +func splitRepoTag(ref string) (repo, tag string, err error) { + parsed, err := name.ParseReference(ref) + if err != nil { + return "", "", err + } + tagged, ok := parsed.(name.Tag) + if !ok { + return "", "", fmt.Errorf("reference %q is not a tag", ref) + } + return tagged.Context().Name(), tagged.TagStr(), nil +} + +// normalizeSemver prepends a "v" to a tag that looks like a bare semantic +// version (e.g. "1.2.3" -> "v1.2.3") so it can be validated and compared by +// golang.org/x/mod/semver, which requires a lowercase "v" prefix. Tags that +// already start with a lowercase "v" are returned unchanged; an uppercase "V" +// prefix is lowercased (semver.IsValid rejects "V1.2.3"). Tags that are not +// semver-shaped are returned unchanged. +func normalizeSemver(tag string) string { + if tag == "" { + return tag + } + if tag[0] == 'v' { + return tag + } + if tag[0] == 'V' { + // semver.IsValid only accepts a lowercase "v"; without this an + // uppercase-tagged pair (e.g. "V1.2.0" vs "V1.3.0") falls through to + // the non-semver path and reports an undecidable comparison, hiding a + // real upgrade. + return "v" + tag[1:] + } + if tag[0] >= '0' && tag[0] <= '9' { + return "v" + tag + } + return tag +} diff --git a/pkg/workloads/upgrade/version_test.go b/pkg/workloads/upgrade/version_test.go new file mode 100644 index 0000000000..c22c645a1c --- /dev/null +++ b/pkg/workloads/upgrade/version_test.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareImageTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + current string + candidate string + want tagComparison + }{ + { + name: "candidate newer semver", + current: "ghcr.io/example/server:1.0.0", + candidate: "ghcr.io/example/server:1.1.0", + want: comparisonNewer, + }, + { + name: "candidate newer semver with v prefix", + current: "ghcr.io/example/server:v1.0.0", + candidate: "ghcr.io/example/server:v2.0.0", + want: comparisonNewer, + }, + { + name: "candidate newer semver with uppercase V prefix", + current: "ghcr.io/example/server:V1.2.0", + candidate: "ghcr.io/example/server:V1.3.0", + want: comparisonNewer, + }, + { + name: "double-digit minor orders numerically not lexically", + current: "ghcr.io/example/server:1.9.0", + candidate: "ghcr.io/example/server:1.10.0", + want: comparisonNewer, + }, + { + name: "prerelease is older than its release", + current: "ghcr.io/example/server:1.2.3-rc1", + candidate: "ghcr.io/example/server:1.2.3", + want: comparisonNewer, + }, + { + name: "candidate equal semver", + current: "ghcr.io/example/server:1.2.3", + candidate: "ghcr.io/example/server:1.2.3", + want: comparisonSameOrOlder, + }, + { + name: "candidate older semver", + current: "ghcr.io/example/server:2.0.0", + candidate: "ghcr.io/example/server:1.0.0", + want: comparisonSameOrOlder, + }, + { + name: "mixed v-prefix normalization", + current: "ghcr.io/example/server:1.0.0", + candidate: "ghcr.io/example/server:v1.5.0", + want: comparisonNewer, + }, + { + name: "equal non-semver literal tag", + current: "ghcr.io/example/server:stable", + candidate: "ghcr.io/example/server:stable", + want: comparisonSameOrOlder, + }, + { + name: "different non-semver tags undecidable", + current: "ghcr.io/example/server:stable", + candidate: "ghcr.io/example/server:edge", + want: comparisonUndecidable, + }, + { + name: "different repositories undecidable", + current: "ghcr.io/example/server:1.0.0", + candidate: "ghcr.io/other/server:1.1.0", + want: comparisonUndecidable, + }, + { + name: "latest on candidate undecidable", + current: "ghcr.io/example/server:1.0.0", + candidate: "ghcr.io/example/server:latest", + want: comparisonUndecidable, + }, + { + name: "latest on current undecidable", + current: "ghcr.io/example/server:latest", + candidate: "ghcr.io/example/server:1.0.0", + want: comparisonUndecidable, + }, + { + name: "implicit latest (no tag) undecidable", + current: "ghcr.io/example/server", + candidate: "ghcr.io/example/server:1.0.0", + want: comparisonUndecidable, + }, + { + name: "docker hub short name normalized to same repo", + current: "library/nginx:1.0.0", + candidate: "library/nginx:1.1.0", + want: comparisonNewer, + }, + { + name: "digest reference is undecidable", + current: "ghcr.io/example/server@sha256:" + "0000000000000000000000000000000000000000000000000000000000000000", + candidate: "ghcr.io/example/server:1.1.0", + want: comparisonUndecidable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, reason := compareImageTags(tt.current, tt.candidate) + assert.Equal(t, tt.want, got) + if got == comparisonUndecidable { + assert.NotEmpty(t, reason, "undecidable result must carry a reason") + } else { + assert.Empty(t, reason, "decidable result must not carry a reason") + } + }) + } +} + +func TestSplitRepoTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + wantRepo string + wantTag string + wantErr bool + }{ + { + name: "fully qualified ref", + ref: "ghcr.io/example/server:1.0.0", + wantRepo: "ghcr.io/example/server", + wantTag: "1.0.0", + }, + { + name: "docker hub short name normalized", + ref: "library/nginx:1.0.0", + wantRepo: "index.docker.io/library/nginx", + wantTag: "1.0.0", + }, + { + name: "no tag defaults to latest", + ref: "ghcr.io/example/server", + wantRepo: "ghcr.io/example/server", + wantTag: "latest", + }, + { + name: "digest reference rejected", + ref: "ghcr.io/example/server@sha256:" + "0000000000000000000000000000000000000000000000000000000000000000", + wantErr: true, + }, + { + name: "invalid reference", + ref: "::::not a ref::::", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + repo, tag, err := splitRepoTag(tt.ref) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRepo, repo) + assert.Equal(t, tt.wantTag, tag) + }) + } +} + +func TestNormalizeSemver(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "bare version gets v prefix", in: "1.2.3", want: "v1.2.3"}, + {name: "already prefixed unchanged", in: "v1.2.3", want: "v1.2.3"}, + {name: "uppercase V lowercased for semver validation", in: "V1.2.3", want: "v1.2.3"}, + {name: "non-semver tag unchanged", in: "stable", want: "stable"}, + {name: "empty unchanged", in: "", want: ""}, + {name: "numeric prefix gets v", in: "2024.01", want: "v2024.01"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, normalizeSemver(tt.in)) + }) + } +} diff --git a/renovate.json b/renovate.json index aaaae7fe6a..5adbc029de 100644 --- a/renovate.json +++ b/renovate.json @@ -7,6 +7,10 @@ ], "labels": ["dependencies"], "schedule": ["every weekend"], + "vulnerabilityAlerts": { + "enabled": true, + "schedule": ["at any time"] + }, "prConcurrentLimit": 5, "prHourlyLimit": 2, "packageRules": [ @@ -15,6 +19,11 @@ "matchPackageNames": ["github.com/stacklok/toolhive-catalog"], "schedule": ["at any time"] }, + { + "description": "Update toolhive-core dependency daily", + "matchPackageNames": ["github.com/stacklok/toolhive-core"], + "schedule": ["at any time"] + }, { "groupName": "toolhive images", "matchDatasources": ["docker"], diff --git a/skills/toolhive-cli-user/SKILL.md b/skills/toolhive-cli-user/SKILL.md index a641373472..a5044a0419 100644 --- a/skills/toolhive-cli-user/SKILL.md +++ b/skills/toolhive-cli-user/SKILL.md @@ -220,9 +220,12 @@ thv inspector filesystem # MCP Inspector UI thv mcp list tools --server filesystem thv mcp list resources --server filesystem thv mcp list prompts --server filesystem +thv mcp call read_file --server filesystem --args '{"path":"/etc/hosts"}' # Invoke a tool thv runtime check # Verify container runtime ``` +For tool invocation patterns (file args, stdin, JSON output, error handling), see [EXAMPLES.md](references/EXAMPLES.md#invoke-a-tool). + ## Guardrails - NEVER use `docker rm` or `podman rm` on ToolHive-managed containers -- always use `thv rm` for proper cleanup. diff --git a/skills/toolhive-cli-user/references/COMMANDS.md b/skills/toolhive-cli-user/references/COMMANDS.md index 82c2782e85..4f78759d34 100644 --- a/skills/toolhive-cli-user/references/COMMANDS.md +++ b/skills/toolhive-cli-user/references/COMMANDS.md @@ -560,6 +560,30 @@ thv mcp list prompts --server SERVER | `--timeout` | Connection timeout | | | `--transport` | Transport (auto, sse, streamable-http) | auto | +### thv mcp call + +Invoke a tool on an MCP server. Opens a fresh MCP session, calls the tool, prints +the result, and closes the session. + +``` +thv mcp call TOOL_NAME --server SERVER [--args JSON | --args-file PATH] +``` + +**Flags:** +| Flag | Description | Default | +|------|-------------|---------| +| `--server` | Server URL or name | Required | +| `--args` | Tool arguments as a JSON object literal. Omit both `--args` and `--args-file` to call the tool with no arguments. | | +| `--args-file` | Path to JSON args file (`-` reads stdin); mutually exclusive with `--args` | | +| `--ignore-tool-error` | Exit zero even when the tool reports an error | false | +| `--format` | Output format (text, json) | text | +| `--timeout` | Connection timeout | 30s | +| `--transport` | Transport (auto, sse, streamable-http) | auto | + +Exits non-zero when the tool reports an error (`isError=true` in the result) +unless `--ignore-tool-error` is set. Transport and protocol failures always +exit non-zero. + ### thv runtime check Check container runtime. diff --git a/skills/toolhive-cli-user/references/EXAMPLES.md b/skills/toolhive-cli-user/references/EXAMPLES.md index 4cb8e4bfed..50b603e993 100644 --- a/skills/toolhive-cli-user/references/EXAMPLES.md +++ b/skills/toolhive-cli-user/references/EXAMPLES.md @@ -348,6 +348,25 @@ thv mcp list prompts --server filesystem thv mcp list tools --server filesystem --format json ``` +### Invoke a Tool + +```bash +# Inline JSON args +thv mcp call fetch --server fetch --args '{"url":"https://example.com"}' + +# Args from a file +thv mcp call read_file --server filesystem --args-file ./args.json + +# Args from stdin +echo '{"url":"https://example.com"}' | thv mcp call fetch --server fetch --args-file - + +# JSON output (full CallToolResult, includes content + structuredContent + isError) +thv mcp call fetch --server fetch --args '{"url":"https://example.com"}' --format json + +# Tool-reported errors normally exit non-zero; flip with --ignore-tool-error +thv mcp call fetch --server fetch --args '{"url":"not-a-url"}' --ignore-tool-error +``` + ### Launch Inspector UI ```bash diff --git a/test/e2e/api_helpers.go b/test/e2e/api_helpers.go index ad8c4cc5f6..7b509a2032 100644 --- a/test/e2e/api_helpers.go +++ b/test/e2e/api_helpers.go @@ -7,7 +7,6 @@ package e2e import ( "context" "fmt" - "io" "net/http" "os" "os/exec" @@ -196,20 +195,6 @@ func (s *Server) Get(path string) (*http.Response, error) { return s.httpClient.Do(req) // #nosec G704 -- baseURL is the local test server URL } -// GetWithHeaders performs a GET request with custom headers. -func (s *Server) GetWithHeaders(path string, headers map[string]string) (*http.Response, error) { - req, err := http.NewRequestWithContext(s.ctx, http.MethodGet, s.baseURL+path, nil) - if err != nil { - return nil, err - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - return s.httpClient.Do(req) // #nosec G704 -- baseURL is the local test server URL -} - // BaseURL returns the base URL of the API server. func (s *Server) BaseURL() string { return s.baseURL @@ -242,14 +227,3 @@ func StartServer(config *ServerConfig) *Server { return server } - -// ExpectStatus reads the response body and asserts the status code, -// including the response body in the failure message for debugging. -// The response body is consumed and closed; callers must not read it again. -func ExpectStatus(resp *http.Response, expected int) { - body, _ := io.ReadAll(resp.Body) - //nolint:errcheck,gosec // This is just a test - resp.Body.Close() - ExpectWithOffset(1, resp.StatusCode).To(Equal(expected), - fmt.Sprintf("Response body: %s", string(body))) -} diff --git a/test/e2e/api_registry_test.go b/test/e2e/api_registry_test.go index a2d2de257b..94f7a4c14e 100644 --- a/test/e2e/api_registry_test.go +++ b/test/e2e/api_registry_test.go @@ -127,14 +127,10 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") Context("valid updates", func() { It("should update with valid local file path", func() { By("Creating a valid test registry file") - testFile := createTestRegistryFile(map[string]interface{}{ - "test-server": map[string]interface{}{ - "image": "test/image:latest", - "description": "Test server", - "tier": "Community", - "status": "Active", - "transport": "stdio", - }, + testFile := createTestRegistryFile(testServerSpec{ + Name: "test-server", + Image: "test/image:latest", + Description: "Test server", }) By("Updating registry with local file path") @@ -157,14 +153,10 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") It("should reset to default with empty request", func() { By("First setting a custom registry") - testFile := createTestRegistryFile(map[string]interface{}{ - "test-server": map[string]interface{}{ - "image": "test/image:latest", - "description": "Test server", - "tier": "Community", - "status": "Active", - "transport": "stdio", - }, + testFile := createTestRegistryFile(testServerSpec{ + Name: "test-server", + Image: "test/image:latest", + Description: "Test server", }) setResp := updateRegistry(apiServer, "default", map[string]interface{}{ "local_path": testFile, @@ -201,14 +193,10 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") It("should return 400 when specifying multiple sources", func() { By("Sending request with multiple sources") - testFile := createTestRegistryFile(map[string]interface{}{ - "test-server": map[string]interface{}{ - "image": "test/image:latest", - "description": "Test server", - "tier": "Community", - "status": "Active", - "transport": "stdio", - }, + testFile := createTestRegistryFile(testServerSpec{ + Name: "test-server", + Image: "test/image:latest", + Description: "Test server", }) updateReq := map[string]interface{}{ "url": "https://example.com/registry.json", @@ -393,14 +381,10 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") BeforeEach(func() { By("Creating a test registry file with a unique server") - testFile = createTestRegistryFile(map[string]interface{}{ - testServerName: map[string]interface{}{ - "image": "test/e2e-image:latest", - "description": "E2E Test server for state consistency", - "tier": "Community", - "status": "Active", - "transport": "stdio", - }, + testFile = createTestRegistryFile(testServerSpec{ + Name: testServerName, + Image: "test/e2e-image:latest", + Description: "E2E Test server for state consistency", }) By("Updating registry with the test file") @@ -506,14 +490,10 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") Context("after resetting to default", func() { BeforeEach(func() { By("First setting a custom registry") - testFile := createTestRegistryFile(map[string]interface{}{ - "custom-server": map[string]interface{}{ - "image": "test/custom:latest", - "description": "Custom server", - "tier": "Community", - "status": "Active", - "transport": "stdio", - }, + testFile := createTestRegistryFile(testServerSpec{ + Name: "custom-server", + Image: "test/custom:latest", + Description: "Custom server", }) setResp := updateRegistry(apiServer, "default", map[string]interface{}{ "local_path": testFile, @@ -567,23 +547,11 @@ var _ = Describe("Registry API", Label("api", "api-registry", "registry", "e2e") BeforeEach(func() { By("Creating a test registry file with remote servers") - registryData := map[string]interface{}{ - "version": "1.0.0", - "last_updated": "2025-01-01T00:00:00Z", - "servers": map[string]interface{}{}, - "remote_servers": map[string]interface{}{ - remoteServerName: map[string]interface{}{ - "url": "https://example.com/mcp", - "description": "E2E Test remote server", - "tier": "Community", - "status": "Active", - "transport": "sse", - }, - }, - } - data, err := json.Marshal(registryData) - Expect(err).ToNot(HaveOccurred()) - testFile = createTestRegistryFileWithContent(data) + testFile = createTestRegistryFile(testServerSpec{ + Name: remoteServerName, + URL: "https://example.com/mcp", + Description: "E2E Test remote server", + }) By("Updating registry with the test file") updateReq := map[string]interface{}{ @@ -879,13 +847,48 @@ func getRegistryServer(server *e2e.Server, registryName, serverName string) *htt return resp } -func createTestRegistryFile(servers map[string]interface{}) string { +// testServerSpec describes a single MCP server entry for an upstream-format +// registry fixture. Either Image (container) or URL (remote) is required. +type testServerSpec struct { + Name string + Description string + Image string + URL string +} + +// createTestRegistryFile writes an upstream MCP registry JSON file containing +// the given server specs and returns the file path. Container specs (Image +// set) become packages with stdio transport; remote specs (URL set) become +// streamable-http remotes. +func createTestRegistryFile(specs ...testServerSpec) string { + servers := make([]map[string]interface{}, 0, len(specs)) + for _, s := range specs { + entry := map[string]interface{}{ + "name": s.Name, + "description": s.Description, + } + switch { + case s.Image != "": + entry["packages"] = []map[string]interface{}{ + { + "registryType": "oci", + "identifier": s.Image, + "transport": map[string]interface{}{"type": "stdio"}, + }, + } + case s.URL != "": + entry["remotes"] = []map[string]interface{}{ + {"type": "streamable-http", "url": s.URL}, + } + } + servers = append(servers, entry) + } registryData := map[string]interface{}{ - "version": "1.0.0", - "last_updated": "2025-01-01T00:00:00Z", - "servers": servers, + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": map[string]interface{}{"last_updated": "2025-01-01T00:00:00Z"}, + "data": map[string]interface{}{"servers": servers}, } - data, err := json.Marshal(registryData) ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Should be able to marshal test registry") diff --git a/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml b/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml index b15d13b8bb..a19b342b1e 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml @@ -20,13 +20,6 @@ rules: - patch - update - watch -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch - apiGroups: - "" resources: @@ -49,6 +42,21 @@ rules: - pods/log verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions/status + verbs: + - patch + - update - apiGroups: - apps resources: @@ -74,6 +82,13 @@ rules: - patch - update - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch - apiGroups: - gateway.networking.k8s.io resources: @@ -96,6 +111,14 @@ rules: - patch - update - watch +- apiGroups: + - toolhive.stacklok.dev + resources: + - '*' + verbs: + - get + - list + - update - apiGroups: - toolhive.stacklok.dev resources: @@ -107,6 +130,7 @@ rules: - mcpremoteproxies - mcpservers - mcptoolconfigs + - mcpwebhookconfigs - virtualmcpservers verbs: - create @@ -127,6 +151,7 @@ rules: - mcpservers/finalizers - mcptelemetryconfigs/finalizers - mcptoolconfigs/finalizers + - mcpwebhookconfigs/finalizers verbs: - update - apiGroups: @@ -142,6 +167,7 @@ rules: - mcpservers/status - mcptelemetryconfigs/status - mcptoolconfigs/status + - mcpwebhookconfigs/status - virtualmcpservers/status verbs: - get diff --git a/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml b/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml index b15d13b8bb..a19b342b1e 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml @@ -20,13 +20,6 @@ rules: - patch - update - watch -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch - apiGroups: - "" resources: @@ -49,6 +42,21 @@ rules: - pods/log verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions/status + verbs: + - patch + - update - apiGroups: - apps resources: @@ -74,6 +82,13 @@ rules: - patch - update - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch - apiGroups: - gateway.networking.k8s.io resources: @@ -96,6 +111,14 @@ rules: - patch - update - watch +- apiGroups: + - toolhive.stacklok.dev + resources: + - '*' + verbs: + - get + - list + - update - apiGroups: - toolhive.stacklok.dev resources: @@ -107,6 +130,7 @@ rules: - mcpremoteproxies - mcpservers - mcptoolconfigs + - mcpwebhookconfigs - virtualmcpservers verbs: - create @@ -127,6 +151,7 @@ rules: - mcpservers/finalizers - mcptelemetryconfigs/finalizers - mcptoolconfigs/finalizers + - mcpwebhookconfigs/finalizers verbs: - update - apiGroups: @@ -142,6 +167,7 @@ rules: - mcpservers/status - mcptelemetryconfigs/status - mcptoolconfigs/status + - mcpwebhookconfigs/status - virtualmcpservers/status verbs: - get diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/mcpwebhookconfig/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/mcpwebhookconfig/chainsaw-test.yaml new file mode 100644 index 0000000000..c26e217f50 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/mcpwebhookconfig/chainsaw-test.yaml @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: mcpwebhookconfig-reconciliation +spec: + description: | + Tests that MCPServers properly wait for and reconcile their referenced MCPWebhookConfig + timeouts: + assert: 90s + cleanup: 90s + delete: 90s + exec: 180s + catch: + - script: + content: | + kubectl delete mcpserver missing-webhook-server -n toolhive-system --ignore-not-found --timeout=60s + kubectl wait --for=delete mcpserver/missing-webhook-server -n toolhive-system --timeout=60s 2>/dev/null || true + kubectl delete mcpwebhookconfig non-existent-webhook-config -n toolhive-system --ignore-not-found --timeout=60s + kubectl wait --for=delete mcpwebhookconfig/non-existent-webhook-config -n toolhive-system --timeout=60s 2>/dev/null || true + steps: + # Set up + - name: create-mcpserver-with-missing-webhook-config + # MCPWebhookConfig has a protective finalizer while MCPServers reference it. + # Use explicit ordered cleanup so Chainsaw does not auto-delete the config + # before the referencing server when an earlier assertion fails. + skipDelete: true + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1beta1 + kind: MCPServer + metadata: + name: missing-webhook-server + namespace: toolhive-system + spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 + transport: streamable-http + env: + - name: TRANSPORT + value: streamable-http + proxyPort: 8080 + mcpPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" + webhookConfigRef: + name: non-existent-webhook-config + + # Wait for the system to process the MCPServer + - name: wait-for-failure-condition + try: + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1beta1 + kind: MCPServer + metadata: + name: missing-webhook-server + namespace: toolhive-system + status: + phase: Failed + + # Create the webhook config and wait for reconciliation + - name: create-webhook-config + skipDelete: true + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: non-existent-webhook-config + namespace: toolhive-system + spec: + validating: + - name: validate-1 + url: https://validate.example.com + mutating: + - name: mutate-1 + url: https://mutate.example.com + + # The MCPServer should now enter Ready phase since the WebhookConfig was created + - name: wait-for-ready-condition + try: + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1beta1 + kind: MCPServer + metadata: + name: missing-webhook-server + namespace: toolhive-system + status: + phase: Ready + - script: + content: | + timeout=60 + while [ $timeout -gt 0 ]; do + HASH=$(kubectl get mcpserver missing-webhook-server -n toolhive-system -o jsonpath='{.status.webhookConfigHash}' 2>/dev/null || echo "") + if [ -n "$HASH" ]; then + echo "MCPServer has webhookConfigHash: $HASH" + exit 0 + fi + sleep 2 + timeout=$((timeout - 2)) + done + + echo "MCPServer did not get a webhookConfigHash" + kubectl get mcpserver missing-webhook-server -n toolhive-system -o yaml + exit 1 + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: non-existent-webhook-config + namespace: toolhive-system + status: + referencingWorkloads: + - kind: MCPServer + name: missing-webhook-server + + # Delete in dependency order so the MCPWebhookConfig finalizer can clear before Chainsaw cleanup. + - name: cleanup-webhook-config-dependencies + try: + - script: + content: | + kubectl delete mcpserver missing-webhook-server -n toolhive-system --ignore-not-found --timeout=60s + kubectl wait --for=delete mcpserver/missing-webhook-server -n toolhive-system --timeout=60s 2>/dev/null || true + kubectl delete mcpwebhookconfig non-existent-webhook-config -n toolhive-system --ignore-not-found --timeout=60s + kubectl wait --for=delete mcpwebhookconfig/non-existent-webhook-config -n toolhive-system --timeout=60s 2>/dev/null || true diff --git a/test/e2e/chainsaw/operator/validation/mcpwebhookconfig/chainsaw-test.yaml b/test/e2e/chainsaw/operator/validation/mcpwebhookconfig/chainsaw-test.yaml new file mode 100644 index 0000000000..d5ba6fe735 --- /dev/null +++ b/test/e2e/chainsaw/operator/validation/mcpwebhookconfig/chainsaw-test.yaml @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Test CEL validation for MCPWebhookConfig CRD +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: mcpwebhookconfig-cel-validation +spec: + description: | + Test CEL validation rules for MCPWebhookConfig. + These validations happen at the API server level and reject invalid specs immediately. + steps: + # Test 1: Empty config is rejected + - name: reject-empty-webhook + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-valid-empty + namespace: default + spec: {} + expect: + - check: + ($error != null): true + ($error.message): "?* at least one validating or mutating webhook must be defined *" + + # Test 2: Valid validating and mutating config + - name: accept-valid-webhooks + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-valid-webhooks + namespace: default + spec: + validating: + - name: authz + url: https://authz.example.com + failurePolicy: fail + timeout: 5s + mutating: + - name: custom-mutator + url: https://mutate.example.com + failurePolicy: ignore + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-valid-webhooks + + # Test 3: Mutating-only config should also be valid + - name: accept-mutating-only-webhook + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-mutating-only-webhook + namespace: default + spec: + mutating: + - name: custom-mutator + url: https://mutate.example.com + failurePolicy: ignore + - assert: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-mutating-only-webhook + + # Test 4: Invalid URL + - name: reject-invalid-url + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPWebhookConfig + metadata: + name: test-invalid-url + namespace: default + spec: + validating: + - name: "test" + url: "not-a-valid-url" + expect: + - check: + ($error != null): true + ($error.message): "?* Invalid value: \"uri\" *" diff --git a/test/e2e/cimd_auth_helpers_test.go b/test/e2e/cimd_auth_helpers_test.go new file mode 100644 index 0000000000..cc66cd47ad --- /dev/null +++ b/test/e2e/cimd_auth_helpers_test.go @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "time" +) + +// testHelper is a minimal subset of testing.TB and ginkgo.GinkgoTInterface that +// the CIMD mock server helpers require. Both *testing.T and GinkgoT() satisfy +// this interface, so helpers can be called from plain Go tests and Ginkgo specs. +type testHelper interface { + Helper() + Cleanup(func()) +} + +// cimdAuthRequest captures parameters from an OAuth authorization request. +type cimdAuthRequest struct { + ClientID string + RedirectURI string + State string + CodeChallenge string +} + +// cimdMockAuthServer is a minimal httptest-based mock authorization server +// for CIMD testing. Unlike OIDCMockServer (Fosite-backed), this server accepts +// any HTTPS URL as a client_id, which is required to verify CIMD behaviour. +type cimdMockAuthServer struct { + server *httptest.Server + authRequestChan chan cimdAuthRequest + + mu sync.Mutex + lastClientID string + dcrCalled bool + cimdSupported bool + rejectCIMD bool + cimdRejectedOnce bool +} + +// newCIMDMockAuthServer creates and starts a mock authorization server that +// advertises client_id_metadata_document_supported. It registers t.Cleanup to +// close the server automatically. Pass rejectCIMD=true to make the server +// reject the first authorization request that uses a CIMD client_id (an HTTPS +// URL), simulating an AS that advertises CIMD support but rejects it at +// runtime, triggering the DCR fallback path in ToolHive. +func newCIMDMockAuthServer(tb testHelper, cimdSupported bool, rejectCIMD bool) *cimdMockAuthServer { + tb.Helper() + + s := &cimdMockAuthServer{ + authRequestChan: make(chan cimdAuthRequest, 4), + cimdSupported: cimdSupported, + rejectCIMD: rejectCIMD, + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery) + mux.HandleFunc("/oauth/authorize", s.handleAuthorize) + mux.HandleFunc("/oauth/token", s.handleToken) + mux.HandleFunc("/oauth/register", s.handleRegister) + mux.HandleFunc("/.well-known/jwks.json", s.handleJWKS) + mux.HandleFunc("/.well-known/mcp-resource", s.handleResourceMetadata) + + s.server = httptest.NewServer(mux) + tb.Cleanup(s.server.Close) + + return s +} + +// URL returns the base URL of the mock authorization server. +func (s *cimdMockAuthServer) URL() string { + return s.server.URL +} + +// IssuerURL returns the issuer URL (same as URL for this mock). +func (s *cimdMockAuthServer) IssuerURL() string { + return s.server.URL +} + +// WaitForAuthRequest blocks until an authorization request arrives or the timeout +// elapses. +func (s *cimdMockAuthServer) WaitForAuthRequest(timeout time.Duration) (cimdAuthRequest, error) { + select { + case req := <-s.authRequestChan: + return req, nil + case <-time.After(timeout): + return cimdAuthRequest{}, fmt.Errorf("timeout waiting for auth request after %s", timeout) + } +} + +// DcrWasCalled returns true if the DCR /oauth/register endpoint was ever called. +func (s *cimdMockAuthServer) DcrWasCalled() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.dcrCalled +} + +// handleDiscovery serves the OIDC discovery document. It sets +// client_id_metadata_document_supported based on the server's configuration. +func (s *cimdMockAuthServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) { + doc := map[string]interface{}{ + "issuer": s.server.URL, + "authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", s.server.URL), + "token_endpoint": fmt.Sprintf("%s/oauth/token", s.server.URL), + "registration_endpoint": fmt.Sprintf("%s/oauth/register", s.server.URL), + "jwks_uri": fmt.Sprintf("%s/.well-known/jwks.json", s.server.URL), + "code_challenge_methods_supported": []string{"S256"}, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "client_id_metadata_document_supported": s.cimdSupported, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) +} + +// RejectCIMDWasCalled returns true if the server rejected a CIMD client_id at +// least once. Callers use this to assert that the CIMD path was attempted +// before the DCR fallback fired. +func (s *cimdMockAuthServer) RejectCIMDWasCalled() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.cimdRejectedOnce +} + +// handleAuthorize captures the authorization request and either immediately +// redirects (when auto_complete=true) or places the request into the channel +// for the test to inspect. +// +// When rejectCIMD is true, the first request whose client_id is an HTTPS URL +// (i.e. a CIMD metadata document URL) is rejected by redirecting to the +// callback with error=invalid_client. This simulates an AS that advertises +// CIMD support but rejects it at the authorization endpoint, triggering the +// DCR fallback path in ToolHive. +func (s *cimdMockAuthServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + req := cimdAuthRequest{ + ClientID: q.Get("client_id"), + RedirectURI: q.Get("redirect_uri"), + State: q.Get("state"), + CodeChallenge: q.Get("code_challenge"), + } + + s.mu.Lock() + s.lastClientID = req.ClientID + + // If rejectCIMD is armed and this is the first CIMD request, reject it. + // A CIMD client_id is any HTTPS URL (see oauthproto.IsClientIDMetadataDocumentURL). + if s.rejectCIMD && !s.cimdRejectedOnce && isCIMDClientID(req.ClientID) { + s.cimdRejectedOnce = true + s.mu.Unlock() + + redirectURI := req.RedirectURI + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + separator := "?" + for _, ch := range redirectURI { + if ch == '?' { + separator = "&" + break + } + } + http.Redirect(w, r, + fmt.Sprintf("%s%serror=invalid_client&state=%s&error_description=cimd+not+supported", + redirectURI, separator, req.State), + http.StatusFound, + ) + return + } + s.mu.Unlock() + + // Always send into the channel so WaitForAuthRequest can inspect it. + select { + case s.authRequestChan <- req: + default: + // Channel buffer full; drop the duplicate. + } + + if q.Get("auto_complete") == "true" { + redirectURI := req.RedirectURI + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + separator := "&" + if len(q.Get("redirect_uri")) > 0 { + // redirect_uri itself may or may not have a query string already; + // we append to it by adding a '?' if needed. + separator = "?" + for _, ch := range redirectURI { + if ch == '?' { + separator = "&" + break + } + } + } + http.Redirect(w, r, + fmt.Sprintf("%s%scode=test-auth-code&state=%s", redirectURI, separator, req.State), + http.StatusFound, + ) + return + } + + // Without auto_complete the test must drive the flow externally. + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("authorization pending")) +} + +// handleToken accepts any code=test-auth-code and returns a minimal access token. +func (*cimdMockAuthServer) handleToken(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + tokenResp := map[string]interface{}{ + "access_token": "test-access-token-cimd", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test-refresh-token-cimd", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenResp) +} + +// handleRegister is the DCR endpoint. Calling it records that DCR was used. +func (s *cimdMockAuthServer) handleRegister(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + s.dcrCalled = true + s.mu.Unlock() + + resp := map[string]interface{}{ + "client_id": "dcr-issued-client-id", + "client_secret": "dcr-issued-secret", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) +} + +// handleJWKS returns an empty JWKS set. +func (*cimdMockAuthServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) +} + +// handleResourceMetadata returns RFC 9728 protected resource metadata pointing +// at this authorization server. +func (s *cimdMockAuthServer) handleResourceMetadata(w http.ResponseWriter, _ *http.Request) { + meta := map[string]interface{}{ + "resource": s.server.URL, + "authorization_servers": []string{s.server.URL}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meta) +} + +// isCIMDClientID returns true if clientID looks like a CIMD metadata document +// URL (i.e. any HTTPS URL). This mirrors oauthproto.IsClientIDMetadataDocumentURL +// without importing the production package from a test helper. +func isCIMDClientID(clientID string) bool { + return len(clientID) >= 8 && clientID[:8] == "https://" +} + +// newCIMDMockMCPServer creates a minimal httptest MCP server that: +// - Returns 401 with WWW-Authenticate header when there is no Authorization header. +// - Returns a minimal JSON-RPC success response when an Authorization header is present. +// +// asURL is the base URL of the authorization server; it is embedded in the +// WWW-Authenticate header's realm and resource_metadata attributes. +func newCIMDMockMCPServer(tb testHelper, asURL string) *httptest.Server { + tb.Helper() + + resourceMetaURL := fmt.Sprintf("%s/.well-known/mcp-resource", asURL) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + w.Header().Set( + "WWW-Authenticate", + fmt.Sprintf(`Bearer realm="%s",resource_metadata="%s"`, asURL, resourceMetaURL), + ) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Minimal JSON-RPC success response so the proxy can verify connectivity. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"cimd-mock-mcp","version":"0.0.1"}}}`)) + })) + + tb.Cleanup(srv.Close) + return srv +} diff --git a/test/e2e/cimd_auth_test.go b/test/e2e/cimd_auth_test.go new file mode 100644 index 0000000000..b8d775eec3 --- /dev/null +++ b/test/e2e/cimd_auth_test.go @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "bytes" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/pkg/oauthproto" + "github.com/stacklok/toolhive/test/e2e" +) + +// startCIMDRunCommand starts `thv run --name --remote-auth …` +// and returns the exec.Cmd together with a buffer that captures combined stdout +// and stderr. The buffer is safe to read concurrently from the test goroutine. +func startCIMDRunCommand( + config *e2e.TestConfig, + serverName string, + mcpURL string, + asIssuerURL string, +) (*exec.Cmd, *bytes.Buffer) { + args := []string{ + "run", + mcpURL, + "--name", serverName, + "--remote-auth", + "--remote-auth-skip-browser", + "--remote-auth-issuer", asIssuerURL, + "--remote-auth-timeout", "30s", + } + + GinkgoWriter.Printf("Starting thv run with args: %v\n", args) + + cmd := exec.Command(config.THVBinary, args...) //nolint:gosec // Intentional for e2e testing + cmd.Env = os.Environ() + + var outputBuffer bytes.Buffer + multiWriter := io.MultiWriter(&outputBuffer, GinkgoWriter) + cmd.Stdout = multiWriter + cmd.Stderr = multiWriter + + err := cmd.Start() + Expect(err).ToNot(HaveOccurred(), "thv run should start without error") + + return cmd, &outputBuffer +} + +// extractAuthURL scans the captured output buffer for the OAuth browser URL +// that ToolHive prints when --remote-auth-skip-browser is set. +func extractAuthURL(output string) string { + urlPattern := regexp.MustCompile(`Please open this URL in your browser: (https?://[^\s"]+)`) + matches := urlPattern.FindStringSubmatch(output) + if len(matches) >= 2 { + return matches[1] + } + return "" +} + +// appendAutoComplete appends or sets auto_complete=true on an authorize URL so +// that the cimdMockAuthServer will immediately redirect to the callback. +func appendAutoComplete(authURL string) string { + if authURL == "" { + return authURL + } + separator := "&" + if !strings.Contains(authURL, "?") { + separator = "?" + } + return authURL + separator + "auto_complete=true" +} + +var _ = Describe("CIMD Authentication", Label("remote", "auth", "cimd"), Serial, func() { + var config *e2e.TestConfig + + BeforeEach(func() { + config = e2e.NewTestConfig() + + err := e2e.CheckTHVBinaryAvailable(config) + Expect(err).ToNot(HaveOccurred(), "thv binary should be available for testing") + }) + + Context("when the authorization server advertises CIMD support", func() { + It("uses the CIMD client_id and skips DCR", func() { + By("Starting mock authorization server with CIMD support enabled") + mockAS := newCIMDMockAuthServer(GinkgoT(), true, false) + + By("Starting mock MCP server that requires authentication") + mockMCP := newCIMDMockMCPServer(GinkgoT(), mockAS.URL()) + + serverName := e2e.GenerateUniqueServerName("cimd-cimd-supported") + + By("Starting thv run pointing at the mock MCP server") + cmd, outputBuffer := startCIMDRunCommand(config, serverName, mockMCP.URL, mockAS.IssuerURL()) + + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + if config.CleanupAfter { + _ = e2e.StopAndRemoveMCPServer(config, serverName) + } + }() + + By("Waiting for the OAuth URL to appear in the output") + var authURL string + Eventually(func() string { + authURL = extractAuthURL(outputBuffer.String()) + return authURL + }, 30*time.Second, 500*time.Millisecond).ShouldNot(BeEmpty(), + "thv run should print 'Please open this URL in your browser'") + + By("Completing the OAuth flow via auto_complete") + autoURL := appendAutoComplete(authURL) + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return nil // follow redirects + }, + } + resp, err := client.Get(autoURL) //nolint:gosec // URL is test-controlled + Expect(err).ToNot(HaveOccurred(), "GET to auto-complete URL should succeed") + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + Expect(resp.StatusCode).To(BeNumerically("<", 400), + "auto-complete redirect chain should succeed") + + By("Waiting for the authorization request to be captured by the mock AS") + authReq, err := mockAS.WaitForAuthRequest(15 * time.Second) + Expect(err).ToNot(HaveOccurred(), "mock AS should receive an authorization request") + + By("Asserting client_id equals the CIMD metadata URL") + Expect(authReq.ClientID).To(Equal(oauthproto.ToolHiveClientMetadataDocumentURL), + "thv run should use the CIMD metadata URL as client_id when AS advertises support") + + By("Asserting PKCE code_challenge was included") + Expect(authReq.CodeChallenge).ToNot(BeEmpty(), + "PKCE code_challenge must be present in the authorization request") + + By("Asserting DCR was NOT called") + Expect(mockAS.DcrWasCalled()).To(BeFalse(), + "DCR registration endpoint must not be called when CIMD is used") + + By("Waiting for thv to report the server as running") + err = e2e.WaitForMCPServer(config, serverName, 30*time.Second) + Expect(err).ToNot(HaveOccurred(), "server should appear as running in thv list") + }) + }) + + Context("when the authorization server does NOT advertise CIMD support", func() { + It("falls back to DCR and does not use the CIMD client_id", func() { + By("Starting mock authorization server with CIMD support disabled") + mockAS := newCIMDMockAuthServer(GinkgoT(), false, false) + + By("Starting mock MCP server that requires authentication") + mockMCP := newCIMDMockMCPServer(GinkgoT(), mockAS.URL()) + + serverName := e2e.GenerateUniqueServerName("cimd-dcr-fallback") + + By("Starting thv run pointing at the mock MCP server") + cmd, outputBuffer := startCIMDRunCommand(config, serverName, mockMCP.URL, mockAS.IssuerURL()) + + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + if config.CleanupAfter { + _ = e2e.StopAndRemoveMCPServer(config, serverName) + } + }() + + By("Waiting for the OAuth URL to appear in the output") + var authURL string + Eventually(func() string { + authURL = extractAuthURL(outputBuffer.String()) + return authURL + }, 30*time.Second, 500*time.Millisecond).ShouldNot(BeEmpty(), + "thv run should print 'Please open this URL in your browser'") + + By("Completing the OAuth flow via auto_complete") + autoURL := appendAutoComplete(authURL) + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return nil + }, + } + resp, err := client.Get(autoURL) //nolint:gosec // URL is test-controlled + Expect(err).ToNot(HaveOccurred(), "GET to auto-complete URL should succeed") + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + Expect(resp.StatusCode).To(BeNumerically("<", 400)) + + By("Waiting for the authorization request to be captured by the mock AS") + authReq, err := mockAS.WaitForAuthRequest(15 * time.Second) + Expect(err).ToNot(HaveOccurred(), "mock AS should receive an authorization request") + + By("Asserting client_id is NOT the CIMD metadata URL") + Expect(authReq.ClientID).ToNot(Equal(oauthproto.ToolHiveClientMetadataDocumentURL), + "thv run must not use the CIMD metadata URL when the AS does not advertise support") + + By("Asserting DCR WAS called") + // Give thv a moment to hit the DCR endpoint before asserting. + Eventually(mockAS.DcrWasCalled, 10*time.Second, 500*time.Millisecond).Should(BeTrue(), + "DCR registration endpoint must be called when CIMD is not advertised") + }) + }) + + Context("CIMD fallback and warm-start behaviour", func() { + It("falls back to DCR when AS rejects the CIMD client_id", func() { + By("Starting mock authorization server: CIMD advertised but first CIMD request rejected") + mockAS := newCIMDMockAuthServer(GinkgoT(), true, true) + + By("Starting mock MCP server that requires authentication") + mockMCP := newCIMDMockMCPServer(GinkgoT(), mockAS.URL()) + + serverName := e2e.GenerateUniqueServerName("cimd-reject-fallback") + + By("Starting thv run pointing at the mock MCP server") + cmd, outputBuffer := startCIMDRunCommand(config, serverName, mockMCP.URL, mockAS.IssuerURL()) + + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + if config.CleanupAfter { + _ = e2e.StopAndRemoveMCPServer(config, serverName) + } + }() + + By("Waiting for the first OAuth URL (CIMD attempt) to appear in the output") + var firstAuthURL string + Eventually(func() string { + firstAuthURL = extractAuthURL(outputBuffer.String()) + return firstAuthURL + }, 30*time.Second, 500*time.Millisecond).ShouldNot(BeEmpty(), + "thv run should print 'Please open this URL in your browser' for the CIMD attempt") + + By("Visiting the first URL — the AS will redirect back with error=invalid_client") + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return nil // follow redirects + }, + } + autoFirstURL := appendAutoComplete(firstAuthURL) + resp, err := client.Get(autoFirstURL) //nolint:gosec // URL is test-controlled + Expect(err).ToNot(HaveOccurred(), "GET to first auto-complete URL should not error") + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + // The redirect chain ends at the ToolHive callback; any 2xx/3xx is fine. + Expect(resp.StatusCode).To(BeNumerically("<", 500), + "redirect chain for CIMD rejection should not produce a server error") + + By("Asserting the mock AS registered the CIMD rejection") + Eventually(mockAS.RejectCIMDWasCalled, 10*time.Second, 500*time.Millisecond).Should(BeTrue(), + "mock AS must have rejected the CIMD client_id before DCR retry") + + By("Waiting for the second OAuth URL (DCR retry) to appear in the output") + var secondAuthURL string + Eventually(func() string { + out := outputBuffer.String() + // The second URL appears after the first; find the last occurrence. + allURLs := regexp.MustCompile(`Please open this URL in your browser: (https?://[^\s"]+)`). + FindAllStringSubmatch(out, -1) + if len(allURLs) >= 2 { + secondAuthURL = allURLs[len(allURLs)-1][1] + } + return secondAuthURL + }, 45*time.Second, 500*time.Millisecond).ShouldNot(BeEmpty(), + "thv run should print a second OAuth URL after the CIMD rejection triggers a DCR retry") + + By("Completing the DCR OAuth flow via auto_complete") + autoSecondURL := appendAutoComplete(secondAuthURL) + resp2, err := client.Get(autoSecondURL) //nolint:gosec // URL is test-controlled + Expect(err).ToNot(HaveOccurred(), "GET to second auto-complete URL should succeed") + _, _ = io.Copy(io.Discard, resp2.Body) + _ = resp2.Body.Close() + Expect(resp2.StatusCode).To(BeNumerically("<", 400), + "DCR auto-complete redirect chain should succeed") + + By("Asserting DCR was called during the retry") + Eventually(mockAS.DcrWasCalled, 10*time.Second, 500*time.Millisecond).Should(BeTrue(), + "DCR registration endpoint must be called after CIMD rejection") + + By("Waiting for thv to report the server as running") + err = e2e.WaitForMCPServer(config, serverName, 30*time.Second) + Expect(err).ToNot(HaveOccurred(), "server should appear as running in thv list after CIMD→DCR fallback") + }) + }) +}) diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go new file mode 100644 index 0000000000..102a141270 --- /dev/null +++ b/test/e2e/cli_llm_all_clients_test.go @@ -0,0 +1,1067 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/pkg/auth/tokensource" + "github.com/stacklok/toolhive/pkg/llm" + "github.com/stacklok/toolhive/pkg/llmgateway" + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/test/e2e" +) + +const ( + osDarwin = "darwin" + clientThvProxy = "thv-proxy" +) + +// llmClientTestCase defines everything needed to test a single LLM gateway +// client: directories to create for detection, an optional binary stub, the +// path to the settings file after setup, and the expected JSON keys. +type llmClientTestCase struct { + // name is the thv client name (e.g. "claude-code") + name string + + // detectionDir returns the directory path (relative to tempDir) that must + // exist for thv to consider the client "installed". + detectionDir func(tempDir string) string + + // binaryName is the stub executable to place on PATH (empty = no binary check). + binaryName string + + // settingsPath returns the absolute path to the settings file that thv will + // patch during setup. + settingsPath func(tempDir string) string + + // mode is "direct" or "proxy". + mode string + + // expectedKeys maps JSON pointer paths to a function that validates the value. + // The function receives (gatewayURL, proxyBaseURL) for flexibility. + expectedKeys map[string]func(gatewayURL, proxyURL string) string + + // envFilePath returns the absolute path to the .env file that thv will write, + // or nil when the client has no .env file to manage. + envFilePath func(tempDir string) string + + // expectedEnvKeys maps env var names to resolver functions, checked in the + // .env file written by setup. Only used when envFilePath is non-nil. + expectedEnvKeys map[string]func(gatewayURL, proxyURL string) string + + // skipOnOS is a GOOS value ("linux", "darwin") on which the test is skipped. + // Empty means the test runs everywhere. + skipOnOS string +} + +// allClientTestCases returns the full test matrix for all supported LLM +// gateway clients. This mirrors the production detection logic in +// pkg/client/llm_gateway.go. +func allClientTestCases() []llmClientTestCase { + return []llmClientTestCase{ + { + name: "claude-code", + detectionDir: func(tempDir string) string { + return filepath.Join(tempDir, ".claude") + }, + binaryName: "claude", + settingsPath: func(tempDir string) string { + return filepath.Join(tempDir, ".claude", "settings.json") + }, + mode: "direct", + expectedKeys: map[string]func(string, string) string{ + "/apiKeyHelper": func(_, _ string) string { return "llm token" }, + "/env/ANTHROPIC_BASE_URL": func(gatewayURL, _ string) string { + return gatewayURL + }, + }, + }, + { + name: "gemini-cli", + detectionDir: func(tempDir string) string { + return filepath.Join(tempDir, ".gemini") + }, + binaryName: "gemini", + settingsPath: func(tempDir string) string { + return filepath.Join(tempDir, ".gemini", "settings.json") + }, + mode: "proxy", + expectedKeys: map[string]func(string, string) string{ + // Force API-key auth so GOOGLE_GEMINI_BASE_URL is respected. + "/security/auth/selectedType": func(_, _ string) string { return "gemini-api-key" }, + }, + // Gemini CLI reads these from process.env, so thv writes them to .env. + envFilePath: func(tempDir string) string { + return filepath.Join(tempDir, ".gemini", ".env") + }, + expectedEnvKeys: map[string]func(string, string) string{ + "GEMINI_API_KEY": func(_, _ string) string { return clientThvProxy }, + // ProxyOrigin strips the path so Gemini CLI can append /v1beta/... + "GOOGLE_GEMINI_BASE_URL": func(_, proxyURL string) string { + return llmgateway.ProxyOriginOf(proxyURL) + }, + }, + }, + { + name: "cursor", + detectionDir: func(tempDir string) string { + return llmSettingsDirFor("cursor", tempDir) + }, + binaryName: "cursor", + settingsPath: func(tempDir string) string { + return filepath.Join(llmSettingsDirFor("cursor", tempDir), "settings.json") + }, + mode: "proxy", + expectedKeys: map[string]func(string, string) string{ + "/cursor.general.openAIBaseURL": func(_, proxyURL string) string { + return proxyURL + }, + "/cursor.general.openAIAPIKey": func(_, _ string) string { + return clientThvProxy + }, + }, + }, + { + name: "vscode", + detectionDir: func(tempDir string) string { + return llmSettingsDirFor("vscode", tempDir) + }, + binaryName: "code", + settingsPath: func(tempDir string) string { + return filepath.Join(llmSettingsDirFor("vscode", tempDir), "settings.json") + }, + mode: "proxy", + expectedKeys: map[string]func(string, string) string{ + "/github.copilot.advanced.serverUrl": func(_, proxyURL string) string { + return proxyURL + }, + "/github.copilot.advanced.apiKey": func(_, _ string) string { + return clientThvProxy + }, + }, + }, + { + name: "vscode-insider", + detectionDir: func(tempDir string) string { + return llmSettingsDirFor("vscode-insider", tempDir) + }, + binaryName: "code-insiders", + settingsPath: func(tempDir string) string { + return filepath.Join(llmSettingsDirFor("vscode-insider", tempDir), "settings.json") + }, + mode: "proxy", + expectedKeys: map[string]func(string, string) string{ + "/github.copilot.advanced.serverUrl": func(_, proxyURL string) string { + return proxyURL + }, + "/github.copilot.advanced.apiKey": func(_, _ string) string { + return clientThvProxy + }, + }, + }, + { + name: "xcode", + detectionDir: func(tempDir string) string { + return llmSettingsDirFor("xcode", tempDir) + }, + binaryName: "", // no binary check for xcode + settingsPath: func(tempDir string) string { + return filepath.Join(llmSettingsDirFor("xcode", tempDir), "editorSettings.json") + }, + mode: "proxy", + skipOnOS: "linux", // xcode path is macOS-only + expectedKeys: map[string]func(string, string) string{ + "/openAIBaseURL": func(_, proxyURL string) string { + return proxyURL + }, + "/apiKey": func(_, _ string) string { + return clientThvProxy + }, + }, + }, + } +} + +// llmSettingsDirFor returns the directory (under tempDir) that thv uses for +// the LLM gateway settings file of the named client. The path mirrors the +// production buildLLMSettingsPath logic from pkg/client/config.go. +func llmSettingsDirFor(client, tempDir string) string { + switch client { + case "cursor": + if runtime.GOOS == osDarwin { + return filepath.Join(tempDir, "Library", "Application Support", "Cursor", "User") + } + return filepath.Join(tempDir, ".config", "Cursor", "User") + case "vscode": + if runtime.GOOS == osDarwin { + return filepath.Join(tempDir, "Library", "Application Support", "Code", "User") + } + return filepath.Join(tempDir, ".config", "Code", "User") + case "vscode-insider": + if runtime.GOOS == osDarwin { + return filepath.Join(tempDir, "Library", "Application Support", "Code - Insiders", "User") + } + return filepath.Join(tempDir, ".config", "Code - Insiders", "User") + case "xcode": + // macOS only + return filepath.Join(tempDir, "Library", "Application Support", "GitHub Copilot for Xcode") + default: + panic(fmt.Sprintf("unknown client: %s", client)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Suite +// ───────────────────────────────────────────────────────────────────────────── + +var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", "e2e"), func() { + if runtime.GOOS == "windows" { + Skip("fake-browser stub is POSIX-only; skipping on Windows") + } + + var ( + thvConfig *e2e.TestConfig + tempDir string + oidcPort int + oidcServer *e2e.OIDCMockServer + binDir string + thvCmd func(args ...string) *e2e.THVCommand + gatewayURL = "https://llm.example.com" + clientID = "test-client" + clientSecret = "test-secret" + ) + + BeforeEach(func() { + thvConfig = e2e.NewTestConfig() + tempDir = GinkgoT().TempDir() + + // Create a fake browser dir so OIDC can complete headlessly. + var err error + binDir, err = e2e.CreateFakeBrowserDir(tempDir) + Expect(err).ToNot(HaveOccurred()) + + // Allocate a free port for the OIDC mock server. + oidcPort, err = networking.FindOrUsePort(0) + Expect(err).ToNot(HaveOccurred()) + + oidcServer, err = e2e.NewOIDCMockServer(oidcPort, clientID, clientSecret) + Expect(err).ToNot(HaveOccurred()) + oidcServer.EnableAutoComplete() + Expect(oidcServer.Start()).To(Succeed()) + + Eventually(func() error { + return checkServerHealth(fmt.Sprintf("http://localhost:%d/.well-known/openid-configuration", oidcPort)) + }, 10*time.Second, 200*time.Millisecond).Should(Succeed()) + + thvCmd = func(args ...string) *e2e.THVCommand { + return e2e.NewTHVCommand(thvConfig, args...). + WithEnv( + "XDG_CONFIG_HOME="+tempDir, + "HOME="+tempDir, + "PATH="+binDir+":"+os.Getenv("PATH"), + ) + } + + By("Configuring environment secrets provider") + thvCmd("secret", "provider", "environment").ExpectSuccess() + }) + + AfterEach(func() { + if oidcServer != nil { + _ = oidcServer.Stop() + } + }) + + // ── Per-client setup + teardown ──────────────────────────────────────────── + + Describe("per-client setup patches settings and teardown reverts them", func() { + for _, clientTC := range allClientTestCases() { + clientTC := clientTC // capture loop variable + It(clientTC.name, func() { + if clientTC.skipOnOS != "" && runtime.GOOS == clientTC.skipOnOS { + Skip(fmt.Sprintf("client %q not supported on %s", clientTC.name, runtime.GOOS)) + } + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Create the detection directory so thv considers this client installed. + By(fmt.Sprintf("[%s] creating detection directory", clientTC.name)) + detectionDir := clientTC.detectionDir(tempDir) + Expect(os.MkdirAll(detectionDir, 0750)).To(Succeed()) + + // Stub the required binary (if any) in binDir. + if clientTC.binaryName != "" { + By(fmt.Sprintf("[%s] stubbing binary %q", clientTC.name, clientTC.binaryName)) + Expect(createFakeBinary(binDir, clientTC.binaryName)).To(Succeed()) + } + + // ── setup ──────────────────────────────────────────────────────── + By(fmt.Sprintf("[%s] running thv llm setup", clientTC.name)) + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + // Verify the settings file was created and patched. + settingsFile := clientTC.settingsPath(tempDir) + By(fmt.Sprintf("[%s] verifying settings file was patched: %s", clientTC.name, settingsFile)) + data, err := os.ReadFile(settingsFile) + Expect(err).ToNot(HaveOccurred(), "settings file should exist after setup") + + var settings map[string]any + Expect(json.Unmarshal(data, &settings)).To(Succeed()) + + proxyBaseURL := fmt.Sprintf("http://localhost:%d/v1", llm.DefaultProxyListenPort) + for pointer, valueFn := range clientTC.expectedKeys { + expectedSubstr := valueFn(gatewayURL, proxyBaseURL) + actualValue, found := jsonPointerGet(settings, pointer) + Expect(found).To(BeTrue(), + "JSON pointer %s should be present in %s", pointer, settingsFile) + Expect(actualValue).To(ContainSubstring(expectedSubstr), + "JSON pointer %s should contain %q in %s", pointer, expectedSubstr, settingsFile) + } + + // Verify the .env file was written (if applicable). + if clientTC.envFilePath != nil { + envFile := clientTC.envFilePath(tempDir) + By(fmt.Sprintf("[%s] verifying .env file was written: %s", clientTC.name, envFile)) + envData, err := os.ReadFile(envFile) + Expect(err).ToNot(HaveOccurred(), ".env file should exist after setup") + envContent := string(envData) + for key, valueFn := range clientTC.expectedEnvKeys { + expectedVal := valueFn(gatewayURL, proxyBaseURL) + Expect(envContent).To(ContainSubstring(key+"="+expectedVal), + ".env file should contain %s=%s", key, expectedVal) + } + } + + // Verify config show reflects this client. + By(fmt.Sprintf("[%s] verifying config show contains this client", clientTC.name)) + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + + found := false + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == clientTC.name { + found = true + Expect(toolCfg.Mode).To(Equal(clientTC.mode), + "client %s should be in %s mode", clientTC.name, clientTC.mode) + break + } + } + Expect(found).To(BeTrue(), "client %q should appear in ConfiguredTools", clientTC.name) + + // ── teardown ───────────────────────────────────────────────────── + By(fmt.Sprintf("[%s] running thv llm teardown %s", clientTC.name, clientTC.name)) + thvCmd("llm", "teardown", clientTC.name).ExpectSuccess() + + By(fmt.Sprintf("[%s] verifying settings file was reverted", clientTC.name)) + data, err = os.ReadFile(settingsFile) + Expect(err).ToNot(HaveOccurred()) + var after map[string]any + Expect(json.Unmarshal(data, &after)).To(Succeed()) + + for pointer := range clientTC.expectedKeys { + _, found := jsonPointerGet(after, pointer) + Expect(found).To(BeFalse(), + "JSON pointer %s should be absent after teardown in %s", pointer, settingsFile) + } + + // Verify the .env file had thv-managed entries removed (if applicable). + if clientTC.envFilePath != nil { + envFile := clientTC.envFilePath(tempDir) + By(fmt.Sprintf("[%s] verifying .env file entries were removed: %s", clientTC.name, envFile)) + envData, err := os.ReadFile(envFile) + Expect(err).ToNot(HaveOccurred(), ".env file should still exist after teardown") + envContent := string(envData) + for key := range clientTC.expectedEnvKeys { + Expect(envContent).ToNot(ContainSubstring(key+"="), + ".env file should not contain %s after teardown", key) + } + } + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown of %s", clientTC.name) + }) + } + }) + + // ── Multi-client setup ───────────────────────────────────────────────────── + + Describe("multi-client setup", func() { + It("configures all detected clients in a single setup call", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Install all clients (skip xcode on Linux). + installedClients := installAllDetectedClients(tempDir, binDir) + + By(fmt.Sprintf("running setup with %d clients installed", len(installedClients))) + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying all installed clients appear in ConfiguredTools") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + + configuredNames := make(map[string]bool) + for _, tc := range cfg.ConfiguredTools { + configuredNames[string(tc.Tool)] = true + } + for _, clientName := range installedClients { + Expect(configuredNames).To(HaveKey(clientName), + "client %q should appear in ConfiguredTools after multi-client setup", clientName) + } + Expect(cfg.ConfiguredTools).To(HaveLen(len(installedClients)), + "number of configured tools should match installed clients") + }) + }) + + // ── Targeted teardown ────────────────────────────────────────────────────── + + Describe("targeted teardown preserves other clients", func() { + It("tears down only the named client while leaving others configured", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Install claude-code and gemini-cli (both are cross-platform). + claudeDir := filepath.Join(tempDir, ".claude") + geminiDir := filepath.Join(tempDir, ".gemini") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(os.MkdirAll(geminiDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + Expect(createFakeBinary(binDir, "gemini")).To(Succeed()) + + By("running setup for both clients") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying both clients are configured") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(HaveLen(2)) + + By("tearing down only claude-code") + thvCmd("llm", "teardown", "claude-code").ExpectSuccess() + + By("verifying claude-code settings are reverted") + claudeSettings := filepath.Join(tempDir, ".claude", "settings.json") + data, err := os.ReadFile(claudeSettings) + Expect(err).ToNot(HaveOccurred()) + var claudeAfter map[string]any + Expect(json.Unmarshal(data, &claudeAfter)).To(Succeed()) + Expect(claudeAfter).ToNot(HaveKey("apiKeyHelper")) + + By("verifying gemini-cli settings.json still has auth type patched") + geminiSettings := filepath.Join(tempDir, ".gemini", "settings.json") + data, err = os.ReadFile(geminiSettings) + Expect(err).ToNot(HaveOccurred()) + var geminiAfter map[string]any + Expect(json.Unmarshal(data, &geminiAfter)).To(Succeed()) + authType, found := jsonPointerGet(geminiAfter, "/security/auth/selectedType") + Expect(found).To(BeTrue(), + "gemini-cli /security/auth/selectedType should still be present after claude-code teardown") + Expect(authType).To(Equal("gemini-api-key"), + "gemini-cli auth type should still be gemini-api-key after claude-code teardown") + + By("verifying gemini-cli .env file still has proxy env vars") + geminiEnv := filepath.Join(tempDir, ".gemini", ".env") + envData, envErr := os.ReadFile(geminiEnv) + Expect(envErr).ToNot(HaveOccurred(), ".env file should still exist after claude-code teardown") + Expect(string(envData)).To(ContainSubstring("GOOGLE_GEMINI_BASE_URL="), + "gemini-cli GOOGLE_GEMINI_BASE_URL should still be in .env after claude-code teardown") + + By("verifying ConfiguredTools has only gemini-cli") + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(HaveLen(1)) + Expect(string(cfg.ConfiguredTools[0].Tool)).To(Equal("gemini-cli")) + }) + }) + + // ── Proxy start ─────────────────────────────────────────────────────────── + + Describe("thv llm proxy start", func() { + It("starts and listens on the configured proxy port", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Install claude-code so setup has at least one client to configure. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + By("running setup") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + // Allocate a free port for the proxy so we don't clash with port 14000 + // if another test or service already uses it. + proxyPort, portErr := networking.FindOrUsePort(0) + Expect(portErr).ToNot(HaveOccurred()) + + By(fmt.Sprintf("setting proxy port to %d", proxyPort)) + thvCmd("llm", "config", "set", "--proxy-port", fmt.Sprintf("%d", proxyPort)).ExpectSuccess() + + By("starting the proxy in a goroutine") + type proxyResult struct { + stdout, stderr string + err error + } + done := make(chan proxyResult, 1) + proxyCmd := thvCmd("llm", "proxy", "start") + go func() { + out, serr, rerr := proxyCmd.RunWithTimeout(15 * time.Second) + done <- proxyResult{out, serr, rerr} + }() + DeferCleanup(func() { + _ = proxyCmd.Interrupt() + select { + case <-done: + case <-time.After(5 * time.Second): + } + }) + + By(fmt.Sprintf("waiting for proxy to listen on port %d", proxyPort)) + proxyAddr := fmt.Sprintf("127.0.0.1:%d", proxyPort) + Eventually(func() error { + conn, err := net.DialTimeout("tcp", proxyAddr, 200*time.Millisecond) + if err != nil { + return err + } + _ = conn.Close() + return nil + }, 10*time.Second, 300*time.Millisecond).Should(Succeed(), + "proxy should be listening on %s", proxyAddr) + }) + }) + + // ── Token command ───────────────────────────────────────────────────────── + + Describe("thv llm token", func() { + It("returns a token when a cached access token is present", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Install claude-code so setup configures at least one client. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + By("running setup to persist config") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + // Inject a fake but structurally valid cached access token via the + // environment secrets provider. The env provider reads from env vars + // with prefix TOOLHIVE_SECRET_. The scoped key for LLM access-token + // cache is: __thv_llm__AT + // Value format: | + By("injecting cached access token into environment") + envKey := tokensource.LLMAccessTokenEnvVar(gatewayURL, issuerURL) + fakeToken := "test-access-token" + tokenValue := fakeToken + "|" + time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + thvCmdWithToken := func(args ...string) *e2e.THVCommand { + return thvCmd(args...).WithEnv(envKey + "=" + tokenValue) + } + + By("running thv llm token") + tokenOut, _, err := thvCmdWithToken("llm", "token").Run() + Expect(err).ToNot(HaveOccurred(), "thv llm token should succeed with a cached token") + Expect(strings.TrimSpace(tokenOut)).To(Equal(fakeToken), + "thv llm token should print the cached access token") + }) + }) + + // ── Proxy: DNS rebinding protection ─────────────────────────────────────────── + + Describe("thv llm proxy start — DNS rebinding protection", func() { + It("returns 403 for a non-loopback Host header and allows loopback hosts", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + // Start a local HTTPS mock gateway so the proxy can forward requests + // quickly rather than timing out on DNS resolution for a fake domain. + gwPort, portErr := networking.FindOrUsePort(0) + Expect(portErr).ToNot(HaveOccurred()) + gw, gwErr := e2e.NewLLMGatewayMock(gwPort) + Expect(gwErr).ToNot(HaveOccurred()) + Expect(gw.Start()).To(Succeed()) + defer func() { _ = gw.Stop() }() + + gwCertFile := filepath.Join(tempDir, "rebind-gw-cert.pem") + Expect(os.WriteFile(gwCertFile, gw.CertPEM(), 0600)).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gw.URL(), + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + // Inject a cached access token so the proxy returns a response + // immediately rather than hanging on the 10-second token-fetch timeout. + // The loopback-Host check fires before token fetch, but a valid-looking + // token lets the proxy attempt forwarding and return 502 (not 403) fast. + rebindEnvKey := tokensource.LLMAccessTokenEnvVar(gw.URL(), issuerURL) + rebindToken := "rebind-test-token|" + time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + proxyPort, portErr2 := networking.FindOrUsePort(0) + Expect(portErr2).ToNot(HaveOccurred()) + + By(fmt.Sprintf("setting proxy port to %d and starting proxy", proxyPort)) + thvCmd("llm", "config", "set", "--proxy-port", fmt.Sprintf("%d", proxyPort)).ExpectSuccess() + + done := make(chan struct{}) + proxyCmd := thvCmd("llm", "proxy", "start").WithEnv( + "SSL_CERT_FILE="+gwCertFile, + rebindEnvKey+"="+rebindToken, + ) + go func() { + defer close(done) + _, _, _ = proxyCmd.RunWithTimeout(15 * time.Second) + }() + DeferCleanup(func() { + _ = proxyCmd.Interrupt() + select { + case <-done: + case <-time.After(5 * time.Second): + } + }) + + proxyAddr := fmt.Sprintf("127.0.0.1:%d", proxyPort) + Eventually(func() error { + conn, dialErr := net.DialTimeout("tcp", proxyAddr, 200*time.Millisecond) + if dialErr != nil { + return dialErr + } + _ = conn.Close() + return nil + }, 10*time.Second, 300*time.Millisecond).Should(Succeed(), + "proxy should be listening on %s", proxyAddr) + + rebindClient := &http.Client{Timeout: 10 * time.Second} + + By("verifying a non-loopback Host header is rejected with 403") + req, reqErr := http.NewRequest("GET", fmt.Sprintf("http://%s/v1/models", proxyAddr), nil) + Expect(reqErr).ToNot(HaveOccurred()) + req.Host = "attacker.example.com" + + resp, doErr := rebindClient.Do(req) //nolint:noctx + Expect(doErr).ToNot(HaveOccurred()) + _ = resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusForbidden), + "non-loopback Host should be rejected with 403") + + By("verifying a loopback Host header is not rejected with 403") + req2, reqErr2 := http.NewRequest("GET", fmt.Sprintf("http://%s/v1/models", proxyAddr), nil) + Expect(reqErr2).ToNot(HaveOccurred()) + // Use the default Host (127.0.0.1:port) — no override needed. + + resp2, doErr2 := rebindClient.Do(req2) //nolint:noctx + Expect(doErr2).ToNot(HaveOccurred()) + _ = resp2.Body.Close() + Expect(resp2.StatusCode).ToNot(Equal(http.StatusForbidden), + "loopback Host should pass the DNS-rebinding guard (got %d instead)", resp2.StatusCode) + }) + }) + + // ── Proxy: port conflict ─────────────────────────────────────────────────────── + + Describe("thv llm proxy start — port conflict", func() { + It("exits with an error when the configured port is already in use", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("pre-binding a port to simulate a conflict") + listener, listenErr := net.Listen("tcp", "127.0.0.1:0") + Expect(listenErr).ToNot(HaveOccurred()) + defer listener.Close() //nolint:errcheck + occupiedPort := listener.Addr().(*net.TCPAddr).Port + + thvCmd("llm", "config", "set", "--proxy-port", fmt.Sprintf("%d", occupiedPort)).ExpectSuccess() + + By(fmt.Sprintf("starting proxy on occupied port %d — expecting failure", occupiedPort)) + _, _, err = thvCmd("llm", "proxy", "start").RunWithTimeout(10 * time.Second) + Expect(err).To(HaveOccurred(), + "proxy start should fail when the configured port is already in use") + }) + }) + + // ── Proxy end-to-end token forwarding ──────────────────────────────────────── + // + // These tests start a real mock LLM gateway and verify that the proxy + // correctly forwards the Bearer token to the upstream on every request. + // We iterate over all clients so that each client's setup + proxy combo is + // exercised against a real HTTP server rather than a fake URL. + + Describe("thv llm proxy — end-to-end token forwarding", func() { + for _, clientTC := range allClientTestCases() { + clientTC := clientTC + It(fmt.Sprintf("forwards Bearer token to gateway for %s", clientTC.name), func() { + if clientTC.skipOnOS != "" && runtime.GOOS == clientTC.skipOnOS { + Skip(fmt.Sprintf("client %q not supported on %s", clientTC.name, runtime.GOOS)) + } + + // Allocate ports for the mock gateway and the proxy. + gatewayPort, portErr := networking.FindOrUsePort(0) + Expect(portErr).ToNot(HaveOccurred()) + proxyPort, portErr := networking.FindOrUsePort(0) + Expect(portErr).ToNot(HaveOccurred()) + + // Start the mock LLM gateway (HTTPS with self-signed cert). + By(fmt.Sprintf("[%s] starting mock LLM gateway on port %d", clientTC.name, gatewayPort)) + gateway, gwErr := e2e.NewLLMGatewayMock(gatewayPort) + Expect(gwErr).ToNot(HaveOccurred()) + Expect(gateway.Start()).To(Succeed()) + defer func() { _ = gateway.Stop() }() + + // Write the self-signed cert to a temp file so the thv subprocess + // can trust it via SSL_CERT_FILE (respected by Go on Linux). + certFile := filepath.Join(tempDir, fmt.Sprintf("gw-cert-%d.pem", gatewayPort)) + Expect(os.WriteFile(certFile, gateway.CertPEM(), 0600)).To(Succeed()) + + mockGatewayURL := gateway.URL() + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + // Create the detection directory and binary stub for this client. + By(fmt.Sprintf("[%s] installing client", clientTC.name)) + Expect(os.MkdirAll(clientTC.detectionDir(tempDir), 0750)).To(Succeed()) + if clientTC.binaryName != "" { + Expect(createFakeBinary(binDir, clientTC.binaryName)).To(Succeed()) + } + + // Run setup pointing at the mock gateway so the proxy knows where to + // forward requests. + By(fmt.Sprintf("[%s] running thv llm setup against mock gateway", clientTC.name)) + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", mockGatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + // Inject a valid cached access token so the proxy can present it to + // the gateway without triggering a real OIDC browser flow. + // The environment secrets provider reads TOOLHIVE_SECRET_. + // Value format: | + By(fmt.Sprintf("[%s] injecting cached access token", clientTC.name)) + envKey := tokensource.LLMAccessTokenEnvVar(mockGatewayURL, issuerURL) + fakeToken := "e2e-bearer-token-" + clientTC.name + tokenValue := fakeToken + "|" + time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + thvCmdWithToken := func(args ...string) *e2e.THVCommand { + return thvCmd(args...).WithEnv( + envKey+"="+tokenValue, + "SSL_CERT_FILE="+certFile, + ) + } + + // Configure the proxy port and start it. + By(fmt.Sprintf("[%s] setting proxy port to %d", clientTC.name, proxyPort)) + thvCmd("llm", "config", "set", "--proxy-port", fmt.Sprintf("%d", proxyPort)).ExpectSuccess() + + By(fmt.Sprintf("[%s] starting the proxy", clientTC.name)) + done := make(chan struct{}) + proxyCmd := thvCmdWithToken("llm", "proxy", "start") + go func() { + defer close(done) + _, _, _ = proxyCmd.RunWithTimeout(20 * time.Second) + }() + DeferCleanup(func() { + _ = proxyCmd.Interrupt() + select { + case <-done: + case <-time.After(5 * time.Second): + } + }) + + proxyAddr := fmt.Sprintf("127.0.0.1:%d", proxyPort) + Eventually(func() error { + conn, dialErr := net.DialTimeout("tcp", proxyAddr, 200*time.Millisecond) + if dialErr != nil { + return dialErr + } + _ = conn.Close() + return nil + }, 10*time.Second, 300*time.Millisecond).Should(Succeed(), + "proxy should be listening on %s", proxyAddr) + + // Send requests through the proxy and verify the gateway received them + // with the correct Bearer token. Use a client with an explicit timeout + // so a stalled proxy fails fast rather than hanging the suite. + proxyClient := &http.Client{Timeout: 10 * time.Second} + + By(fmt.Sprintf("[%s] sending GET /v1/models through the proxy", clientTC.name)) + resp, doErr := proxyClient.Get(fmt.Sprintf("http://%s/v1/models", proxyAddr)) //nolint:noctx + Expect(doErr).ToNot(HaveOccurred(), "GET /v1/models through proxy should not error") + _ = resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK), + "proxy should forward the request and return 200 OK") + + By(fmt.Sprintf("[%s] verifying Bearer token was forwarded to gateway", clientTC.name)) + Expect(gateway.LastBearerToken()).To(Equal(fakeToken), + "proxy should forward the cached access token as the Bearer token") + + // Also verify the response payload is the expected mock JSON. + By(fmt.Sprintf("[%s] sending POST /v1/chat/completions through the proxy", clientTC.name)) + chatResp, chatErr := proxyClient.Post( //nolint:noctx + fmt.Sprintf("http://%s/v1/chat/completions", proxyAddr), + "application/json", + strings.NewReader(`{"model":"mock-gpt-4","messages":[{"role":"user","content":"hi"}]}`), + ) + Expect(chatErr).ToNot(HaveOccurred()) + Expect(chatResp.StatusCode).To(Equal(http.StatusOK)) + var chatBody map[string]any + Expect(json.NewDecoder(chatResp.Body).Decode(&chatBody)).To(Succeed()) + _ = chatResp.Body.Close() + Expect(chatBody).To(HaveKey("choices"), + "chat completions response should contain 'choices'") + + }) + } + }) + + // ── Edge cases ───────────────────────────────────────────────────────────────── + + Describe("edge cases", func() { + Describe("setup with no clients detected", func() { + It("exits cleanly and prints an informative message", func() { + // No detection dirs or binary stubs → no clients found. + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("running setup with no installed clients") + stdout, _ := thvCmd( + "llm", "setup", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ).ExpectSuccess() + + Expect(stdout).To(ContainSubstring("No supported AI tools detected"), + "setup should explain that no tools were found") + }) + }) + + Describe("setup with a corrupted settings file", func() { + It("fails gracefully without modifying the corrupted file", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + By("writing invalid JSON to the settings file") + settingsPath := filepath.Join(claudeDir, "settings.json") + corruptContent := []byte(`{not valid json!!!`) + Expect(os.WriteFile(settingsPath, corruptContent, 0600)).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + _, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).To(HaveOccurred(), + "setup should fail when the settings file is corrupted; stderr=%q", stderr) + + By("verifying the corrupted file was not modified") + data, readErr := os.ReadFile(settingsPath) + Expect(readErr).ToNot(HaveOccurred()) + Expect(data).To(Equal(corruptContent), + "corrupted settings file should be left untouched on parse failure") + }) + }) + + Describe("setup with an unreachable OIDC issuer", func() { + It("fails with an OIDC error and leaves settings files unmodified", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + By("running setup with an unreachable issuer (port 1)") + _, stderr, err := thvCmd( + "llm", "setup", + "--gateway-url", gatewayURL, + "--issuer", "http://localhost:1", + "--client-id", "bad-client", + ).RunWithTimeout(15 * time.Second) + + Expect(err).To(HaveOccurred(), + "setup with unreachable issuer should fail") + Expect(stderr).To(ContainSubstring("OIDC"), + "error should mention OIDC; got stderr=%q", stderr) + + By("verifying settings.json was not created") + settingsPath := filepath.Join(claudeDir, "settings.json") + _, statErr := os.Stat(settingsPath) + Expect(os.IsNotExist(statErr)).To(BeTrue(), + "settings.json should not be created when OIDC login fails") + }) + }) + + Describe("thv llm token with an expired cached access token", func() { + It("does not return the expired token", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + By("running setup to persist OIDC config") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("injecting an expired cached access token via the environment provider") + envKey := tokensource.LLMAccessTokenEnvVar(gatewayURL, issuerURL) + expiredToken := "expired-test-token" + expiredAt := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + tokenValue := expiredToken + "|" + expiredAt + + thvCmdWithExpired := func(args ...string) *e2e.THVCommand { + return thvCmd(args...).WithEnv(envKey + "=" + tokenValue) + } + + By("running thv llm token with the expired token in the environment") + // The cached access token is expired and the read-only environment + // provider holds no refresh token, so "thv llm token" falls through to + // the interactive OIDC browser flow. runWithOIDCCompletion satisfies the + // callback so the command obtains a fresh token instead of blocking. + tokenOut, stderrOut, tokenErr := runWithOIDCCompletion(thvCmdWithExpired, oidcServer, "llm", "token") + Expect(tokenErr).ToNot(HaveOccurred(), + "deferred re-auth should succeed; stdout=%q stderr=%q", tokenOut, stderrOut) + + // The expired token must never be printed; a fresh one must be returned. + Expect(strings.TrimSpace(tokenOut)).ToNot(Equal(expiredToken), + "expired token must not be returned directly") + Expect(strings.TrimSpace(tokenOut)).ToNot(BeEmpty(), + "a fresh token should have been obtained after expiry") + }) + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// createFakeBinary writes a minimal no-op shell script named `name` in dir. +// This satisfies the LLMBinaryName check in DetectedLLMGatewayClients. +func createFakeBinary(dir, name string) error { + script := []byte("#!/bin/sh\nexit 0\n") + return os.WriteFile(filepath.Join(dir, name), script, 0750) +} + +// installAllDetectedClients creates the detection directories (and binary +// stubs) for every client in the test matrix that should be detected on the +// current OS. It returns the list of client names that were installed. +func installAllDetectedClients(tempDir, binDir string) []string { + var installed []string + for _, tc := range allClientTestCases() { + if tc.skipOnOS != "" && runtime.GOOS == tc.skipOnOS { + continue + } + dir := tc.detectionDir(tempDir) + Expect(os.MkdirAll(dir, 0750)).To(Succeed()) + if tc.binaryName != "" { + Expect(createFakeBinary(binDir, tc.binaryName)).To(Succeed()) + } + installed = append(installed, tc.name) + } + return installed +} + +// jsonPointerGet resolves a simplified JSON pointer (RFC 6901) against a +// map[string]any. Returns the string value and true if the key exists, or +// ("", false) if any segment is missing or a non-map node is encountered. +// Supports arbitrary nesting depth but not array indexing (e.g. "/a/b/c" works, +// "/items/0/name" does not). +func jsonPointerGet(obj map[string]any, pointer string) (string, bool) { + segments := strings.Split(strings.TrimPrefix(pointer, "/"), "/") + var cur any = obj + for _, seg := range segments { + // Unescape RFC 6901 tokens: ~1 → /, ~0 → ~ + seg = strings.ReplaceAll(seg, "~1", "/") + seg = strings.ReplaceAll(seg, "~0", "~") + + m, ok := cur.(map[string]any) + if !ok { + return "", false + } + cur, ok = m[seg] + if !ok { + return "", false + } + } + if cur == nil { + return "", false + } + return fmt.Sprintf("%v", cur), true +} diff --git a/test/e2e/cli_llm_config_test.go b/test/e2e/cli_llm_config_test.go new file mode 100644 index 0000000000..c5edde7f16 --- /dev/null +++ b/test/e2e/cli_llm_config_test.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/pkg/llm" + "github.com/stacklok/toolhive/test/e2e" +) + +var _ = Describe("thv llm config", Label("cli", "llm", "e2e"), func() { + var ( + thvConfig *e2e.TestConfig + tempDir string + thvCmd func(args ...string) *e2e.THVCommand + ) + + BeforeEach(func() { + thvConfig = e2e.NewTestConfig() + tempDir = GinkgoT().TempDir() + + // thvCmd creates a THVCommand with an isolated config and home directory + // so these tests never touch the user's real config.yaml or secrets store. + thvCmd = func(args ...string) *e2e.THVCommand { + return e2e.NewTHVCommand(thvConfig, args...). + WithEnv( + "XDG_CONFIG_HOME="+tempDir, + "HOME="+tempDir, + ) + } + + // Configure the environment secrets provider so that commands like + // "llm config reset" never touch the user's real keychain or 1Password. + // The environment provider is non-interactive and read-only, making it + // safe for E2E tests. DeleteCachedTokens is a no-op when the provider + // cannot list or delete secrets. + By("Configuring environment secrets provider") + thvCmd("secret", "provider", "environment").ExpectSuccess() + }) + + Describe("thv llm config set", func() { + It("persists gateway URL, issuer, and client-id; show --format json reflects them", func() { + By("Setting all required fields") + thvCmd( + "llm", "config", "set", + "--gateway-url", "https://llm.example.com", + "--issuer", "https://auth.example.com", + "--client-id", "test-client", + ).ExpectSuccess() + + By("Reading back the config via show --format json") + stdout, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + + var cfg llm.Config + Expect(json.Unmarshal([]byte(stdout), &cfg)).To(Succeed()) + Expect(cfg.GatewayURL).To(Equal("https://llm.example.com")) + Expect(cfg.OIDC.Issuer).To(Equal("https://auth.example.com")) + Expect(cfg.OIDC.ClientID).To(Equal("test-client")) + }) + + It("rejects an HTTP gateway URL (HTTPS enforcement)", func() { + By("Attempting to set an HTTP gateway URL") + stdout, stderr, err := thvCmd( + "llm", "config", "set", + "--gateway-url", "http://llm.example.com", + ).Run() + + By("Verifying the command fails") + Expect(err).To(HaveOccurred(), + "HTTP gateway URL should be rejected; stdout=%q stderr=%q", stdout, stderr) + + By("Verifying the error mentions gateway_url") + Expect(stderr).To(ContainSubstring("gateway_url"), + "error message should reference the failing field") + }) + + It("allows incremental configuration without error", func() { + By("Setting only the gateway URL (no issuer or client-id yet)") + thvCmd( + "llm", "config", "set", + "--gateway-url", "https://llm.example.com", + ).ExpectSuccess() + + By("Reading back the partial config via show --format json") + stdout, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + + var cfg llm.Config + Expect(json.Unmarshal([]byte(stdout), &cfg)).To(Succeed()) + Expect(cfg.GatewayURL).To(Equal("https://llm.example.com")) + }) + }) + + Describe("thv llm config show", func() { + It("prints the 'not configured' message on a clean config", func() { + By("Running show before any config has been set") + stdout, _ := thvCmd("llm", "config", "show").ExpectSuccess() + + By("Verifying the not-configured message is present") + Expect(stdout).To(ContainSubstring("not configured"), + "show should explain that the LLM gateway is not configured") + }) + + It("prints human-readable text after configuration", func() { + By("Setting all required fields") + thvCmd( + "llm", "config", "set", + "--gateway-url", "https://llm.example.com", + "--issuer", "https://auth.example.com", + "--client-id", "test-client", + ).ExpectSuccess() + + By("Running show in text format") + stdout, _ := thvCmd("llm", "config", "show").ExpectSuccess() + + Expect(stdout).To(ContainSubstring("https://llm.example.com")) + Expect(stdout).To(ContainSubstring("https://auth.example.com")) + Expect(stdout).To(ContainSubstring("test-client")) + }) + }) + + Describe("thv llm config reset", func() { + It("clears the config so that show returns the not-configured message", func() { + By("Setting all required fields first") + thvCmd( + "llm", "config", "set", + "--gateway-url", "https://llm.example.com", + "--issuer", "https://auth.example.com", + "--client-id", "test-client", + ).ExpectSuccess() + + By("Resetting the config") + thvCmd("llm", "config", "reset").ExpectSuccess() + + By("Verifying show returns the not-configured message") + stdout, _ := thvCmd("llm", "config", "show").ExpectSuccess() + Expect(stdout).To(ContainSubstring("not configured")) + + By("Verifying show --format json returns an empty config") + stdout, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + + var cfg llm.Config + Expect(json.Unmarshal([]byte(stdout), &cfg)).To(Succeed()) + Expect(cfg.GatewayURL).To(BeEmpty()) + Expect(cfg.OIDC.Issuer).To(BeEmpty()) + Expect(cfg.OIDC.ClientID).To(BeEmpty()) + }) + }) +}) diff --git a/test/e2e/cli_llm_setup_test.go b/test/e2e/cli_llm_setup_test.go new file mode 100644 index 0000000000..196ce7e6a3 --- /dev/null +++ b/test/e2e/cli_llm_setup_test.go @@ -0,0 +1,479 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + "github.com/stacklok/toolhive/pkg/llm" + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/test/e2e" +) + +// runSetupWithOIDCCompletion runs "thv llm setup" in a goroutine and +// concurrently satisfies the OIDC authorization request so the command +// completes without a real browser. +func runSetupWithOIDCCompletion( + thvCmd func(args ...string) *e2e.THVCommand, + oidcServer *e2e.OIDCMockServer, + extraArgs ...string, +) (string, string, error) { + return runWithOIDCCompletion(thvCmd, oidcServer, append([]string{"llm", "setup"}, extraArgs...)...) +} + +// runWithOIDCCompletion runs an arbitrary "thv" command in a goroutine and +// concurrently satisfies the OIDC authorization request it triggers, so a +// command that performs the interactive browser login flow (e.g. "thv llm +// token" after a lazy setup) completes without a real browser. It returns an +// error if the command exits before an auth request arrives — i.e. it did not +// actually start the browser flow. +func runWithOIDCCompletion( + thvCmd func(args ...string) *e2e.THVCommand, + oidcServer *e2e.OIDCMockServer, + args ...string, +) (string, string, error) { + type result struct { + stdout, stderr string + err error + } + done := make(chan result, 1) + + cmd := thvCmd(args...) + go func() { + stdout, stderr, err := cmd.RunWithTimeout(60 * time.Second) + done <- result{stdout, stderr, err} + }() + + // drainWithInterrupt interrupts the command and waits for the goroutine to + // finish, with a 5s safety bound to avoid blocking test runs indefinitely. + drainWithInterrupt := func() { + _ = cmd.Interrupt() + select { + case <-done: + case <-time.After(5 * time.Second): + } + } + + // Race the command exit against the OIDC auth request so that an early + // failure (e.g., misconfigured gateway) returns immediately rather than + // blocking for the full 30s WaitForAuthRequest timeout. A cancellable + // context ensures the auth goroutine exits promptly when the command + // finishes before the auth request arrives. + authCtx, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + + type authResult struct { + req *e2e.AuthRequest + err error + } + authCh := make(chan authResult, 1) + go func() { + req, err := oidcServer.WaitForAuthRequest(authCtx, 30*time.Second) + authCh <- authResult{req, err} + }() + + var authReq *e2e.AuthRequest + select { + case r := <-done: + // Command exited before the OIDC auth request arrived. + // cancelAuth() will be called by defer, unblocking the auth goroutine. + if r.err != nil { + return r.stdout, r.stderr, fmt.Errorf("setup exited before OIDC auth request: %w", r.err) + } + return r.stdout, r.stderr, fmt.Errorf("setup exited cleanly before OIDC auth request (unexpected success without browser flow)") + case ar := <-authCh: + if ar.err != nil { + drainWithInterrupt() + return "", "", fmt.Errorf("waiting for OIDC auth request: %w", ar.err) + } + authReq = ar.req + } + + if err := oidcServer.CompleteAuthRequest(authReq); err != nil { + drainWithInterrupt() + return "", "", fmt.Errorf("completing OIDC auth request: %w", err) + } + + r := <-done + return r.stdout, r.stderr, r.err +} + +var _ = Describe("thv llm setup / teardown", Label("cli", "llm", "setup", "e2e"), func() { + // The fake-browser script uses POSIX /bin/sh and stubs open/xdg-open. + // github.com/pkg/browser uses different mechanisms on Windows and would + // not pick up these stubs, causing tests to hang. CI runs Linux only. + if runtime.GOOS == "windows" { + Skip("fake-browser stub is POSIX-only; skipping on Windows") + } + + var ( + thvConfig *e2e.TestConfig + tempDir string + binDir string + oidcPort int + oidcServer *e2e.OIDCMockServer + thvCmd func(args ...string) *e2e.THVCommand + gatewayURL = "https://llm.example.com" + clientID = "test-client" + clientSecret = "test-secret" + ) + + BeforeEach(func() { + thvConfig = e2e.NewTestConfig() + tempDir = GinkgoT().TempDir() + + // Install a fake browser so OIDC login completes in headless/CI + // environments where no real browser is available. + var binDirErr error + binDir, binDirErr = e2e.CreateFakeBrowserDir(tempDir) + Expect(binDirErr).ToNot(HaveOccurred()) + + // Isolated environment: XDG_CONFIG_HOME and HOME point to tempDir so + // these tests never touch the user's real config.yaml or secrets store. + thvCmd = func(args ...string) *e2e.THVCommand { + return e2e.NewTHVCommand(thvConfig, args...). + WithEnv( + "XDG_CONFIG_HOME="+tempDir, + "HOME="+tempDir, + "PATH="+binDir+":"+os.Getenv("PATH"), + ) + } + + // Use the environment secrets provider to avoid touching the system + // keychain. This provider is read-only (CanWrite=false), so OIDC login + // will succeed (tokens are obtained) but the refresh token cannot be + // persisted between invocations. Tests that specifically need a persisted + // CachedRefreshTokenRef (e.g. --purge-tokens) inject the value directly + // into config.yaml instead. + By("Configuring environment secrets provider") + thvCmd("secret", "provider", "environment").ExpectSuccess() + + // Allocate a free port for the OIDC mock server. + var err error + oidcPort, err = networking.FindOrUsePort(0) + Expect(err).ToNot(HaveOccurred()) + + // Create and start the mock OIDC server. + By(fmt.Sprintf("Starting OIDC mock server on port %d", oidcPort)) + oidcServer, err = e2e.NewOIDCMockServer(oidcPort, clientID, clientSecret) + Expect(err).ToNot(HaveOccurred()) + oidcServer.EnableAutoComplete() + Expect(oidcServer.Start()).To(Succeed()) + + // Wait for the OIDC discovery endpoint to be ready. + Eventually(func() error { + return checkServerHealth(fmt.Sprintf("http://localhost:%d/.well-known/openid-configuration", oidcPort)) + }, 10*time.Second, 200*time.Millisecond).Should(Succeed()) + }) + + AfterEach(func() { + if oidcServer != nil { + _ = oidcServer.Stop() + } + }) + + // ── Test 1 ──────────────────────────────────────────────────────────────── + + Describe("thv llm setup with inline flags", func() { + It("patches detected tools and persists config", func() { + // Create ~/.claude/ and stub the claude binary so the Claude Code adapter detects the tool. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running thv llm setup with inline flags (OIDC auto-completes)") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("Verifying ~/.claude/settings.json was patched") + settingsPath := filepath.Join(tempDir, ".claude", "settings.json") + data, err := os.ReadFile(settingsPath) + Expect(err).ToNot(HaveOccurred(), "settings.json should exist after setup") + + var settings map[string]any + Expect(json.Unmarshal(data, &settings)).To(Succeed()) + Expect(settings).To(HaveKey("apiKeyHelper"), + "apiKeyHelper should be set in settings.json") + Expect(fmt.Sprintf("%v", settings["apiKeyHelper"])).To(ContainSubstring("llm token"), + "apiKeyHelper should invoke thv llm token") + + By("Verifying config show --format json reflects ConfiguredTools") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).ToNot(BeEmpty(), "at least one tool should be configured") + + found := false + for _, tc := range cfg.ConfiguredTools { + if tc.Tool == "claude-code" { + found = true + Expect(tc.Mode).To(Equal("direct")) + break + } + } + Expect(found).To(BeTrue(), "claude-code should appear in ConfiguredTools") + }) + }) + + // ── Test 2 ──────────────────────────────────────────────────────────────── + + Describe("thv llm teardown", func() { + It("reverts all tool configs", func() { + // Create ~/.claude/ and stub the claude binary to trigger Claude Code detection. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running setup first") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("Verifying settings.json was patched") + settingsPath := filepath.Join(tempDir, ".claude", "settings.json") + data, err := os.ReadFile(settingsPath) + Expect(err).ToNot(HaveOccurred()) + var before map[string]any + Expect(json.Unmarshal(data, &before)).To(Succeed()) + Expect(before).To(HaveKey("apiKeyHelper")) + + By("Running thv llm teardown") + thvCmd("llm", "teardown").ExpectSuccess() + + By("Verifying apiKeyHelper is no longer in settings.json") + data, err = os.ReadFile(settingsPath) + Expect(err).ToNot(HaveOccurred()) + var after map[string]any + Expect(json.Unmarshal(data, &after)).To(Succeed()) + Expect(after).ToNot(HaveKey("apiKeyHelper"), + "apiKeyHelper should be removed after teardown") + + By("Verifying ConfiguredTools is empty") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty()) + }) + }) + + // ── Test 3 ──────────────────────────────────────────────────────────────── + + Describe("thv llm teardown ", func() { + It("tears down a named tool and rejects an unknown tool name", func() { + // The test environment only has one detectable tool (claude-code via + // ~/.claude), so we cannot verify that other tools remain configured + // after a targeted teardown. Instead this test covers the targeted + // teardown path itself and confirms that an unknown tool name is rejected. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running setup") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("Tearing down only claude-code by name") + thvCmd("llm", "teardown", "claude-code").ExpectSuccess() + + By("Verifying apiKeyHelper was removed") + settingsPath := filepath.Join(tempDir, ".claude", "settings.json") + data, err := os.ReadFile(settingsPath) + Expect(err).ToNot(HaveOccurred()) + var settings map[string]any + Expect(json.Unmarshal(data, &settings)).To(Succeed()) + Expect(settings).ToNot(HaveKey("apiKeyHelper")) + + By("Verifying teardown of unknown tool returns error") + _, _, err = thvCmd("llm", "teardown", "nonexistent-tool").Run() + Expect(err).To(HaveOccurred(), "teardown of unknown tool should fail") + }) + }) + + // ── Test 4 ──────────────────────────────────────────────────────────────── + + Describe("thv llm setup without config and no flags", func() { + It("returns an error about not configured", func() { + By("Running setup with no prior config and no inline flags") + stdout, stderr, err := thvCmd("llm", "setup").Run() + + By("Verifying the command fails") + Expect(err).To(HaveOccurred(), + "setup without config should fail; stdout=%q stderr=%q", stdout, stderr) + + By("Verifying the error message references configuration") + Expect(stderr).To(ContainSubstring("not configured"), + "error should mention the gateway is not configured") + }) + }) + + // ── Test 5 ──────────────────────────────────────────────────────────────── + + Describe("thv llm teardown --purge-tokens", func() { + It("clears cached tokens in addition to reverting tool configs", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running setup") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, + oidcServer, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("Injecting a fake CachedRefreshTokenRef into config to verify purge clears it") + // The environment secrets provider is read-only, so the OIDC login flow + // cannot persist a refresh token ref. We write one directly into + // config.yaml so the subsequent --purge-tokens assertion is meaningful. + configPath := filepath.Join(tempDir, "toolhive", "config.yaml") + configData, readErr := os.ReadFile(configPath) + Expect(readErr).ToNot(HaveOccurred()) + + var rawCfg map[string]any + Expect(yaml.Unmarshal(configData, &rawCfg)).To(Succeed()) + llmSection, ok := rawCfg["llm"].(map[string]any) + Expect(ok).To(BeTrue(), "config.yaml should have an llm: section") + oidcSection, ok := llmSection["oidc"].(map[string]any) + Expect(ok).To(BeTrue(), "config.yaml llm section should have an oidc: key") + oidcSection["cached_refresh_token_ref"] = "fake-ref-for-purge-test" + + patched, marshalErr := yaml.Marshal(rawCfg) + Expect(marshalErr).ToNot(HaveOccurred()) + Expect(os.WriteFile(configPath, patched, 0600)).To(Succeed()) + + By("Running teardown with --purge-tokens") + thvCmd("llm", "teardown", "--purge-tokens").ExpectSuccess() + + By("Verifying config is cleared (ConfiguredTools empty and token ref removed)") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown --purge-tokens") + Expect(cfg.OIDC.CachedRefreshTokenRef).To(BeEmpty(), + "cached token reference should be cleared after purge") + }) + }) + + // ── Test 6 ──────────────────────────────────────────────────────────────── + + Describe("thv llm setup --lazy", func() { + It("configures tools without logging in and prints a deferred-login message", func() { + // Create ~/.claude/ and stub the claude binary so Claude Code is detected. + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running thv llm setup --lazy (must complete with no browser flow)") + // Run directly: lazy mode must finish without ever triggering OIDC, so + // there is no auth request to satisfy. A bounded timeout guards against a + // regression that re-introduces the inline login. + stdout, stderr, err := thvCmd( + "llm", "setup", "--lazy", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ).RunWithTimeout(30 * time.Second) + Expect(err).ToNot(HaveOccurred(), + "lazy setup should succeed without a browser; stdout=%q stderr=%q", stdout, stderr) + + By("Verifying the deferred-login message was printed") + Expect(stdout).To(ContainSubstring("Lazy mode"), + "lazy mode should tell the user login is deferred") + + By("Verifying ~/.claude/settings.json was patched just like a normal setup") + settingsPath := filepath.Join(tempDir, ".claude", "settings.json") + data, err := os.ReadFile(settingsPath) + Expect(err).ToNot(HaveOccurred(), "settings.json should exist after lazy setup") + var settings map[string]any + Expect(json.Unmarshal(data, &settings)).To(Succeed()) + Expect(settings).To(HaveKey("apiKeyHelper"), + "apiKeyHelper should be set even in lazy mode") + + By("Verifying ConfiguredTools was persisted") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).ToNot(BeEmpty(), + "at least one tool should be configured after lazy setup") + }) + }) + + // ── Test 7 ──────────────────────────────────────────────────────────────── + + Describe("thv llm token after a lazy setup", func() { + It("triggers the deferred OIDC login and prints a fresh token", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + + By("Running thv llm setup --lazy") + stdout, stderr, err := thvCmd( + "llm", "setup", "--lazy", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ).RunWithTimeout(30 * time.Second) + Expect(err).ToNot(HaveOccurred(), + "lazy setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("Running thv llm token — must launch the deferred OIDC browser flow") + // With no cached token after a lazy setup, "thv llm token" launches the + // same browser flow setup would have run. The fake browser triggers the + // auth request; runWithOIDCCompletion satisfies the callback. + tokenOut, tokenStderr, err := runWithOIDCCompletion(thvCmd, oidcServer, "llm", "token") + Expect(err).ToNot(HaveOccurred(), + "deferred login via token should succeed; stdout=%q stderr=%q", tokenOut, tokenStderr) + Expect(strings.TrimSpace(tokenOut)).ToNot(BeEmpty(), + "a fresh token should be printed to stdout after deferred login") + }) + }) +}) diff --git a/test/e2e/cli_registry_convert_test.go b/test/e2e/cli_registry_convert_test.go new file mode 100644 index 0000000000..d4db3cb330 --- /dev/null +++ b/test/e2e/cli_registry_convert_test.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/test/e2e" +) + +var _ = Describe("Registry convert CLI", Label("cli", "registry", "e2e"), func() { + var thvConfig *e2e.TestConfig + + BeforeEach(func() { + thvConfig = e2e.NewTestConfig() + }) + + const legacyRegistry = `{ + "version": "1.0.0", + "last_updated": "2026-01-15T10:00:00Z", + "servers": { + "filesystem": { + "description": "A filesystem MCP server", + "tier": "Official", + "status": "active", + "transport": "stdio", + "image": "ghcr.io/example/filesystem:v1.0.0", + "tools": ["read_file"], + "tags": ["filesystem"] + } + } + }` + + const upstreamRegistry = `{ + "$schema": "https://example.com/schema.json", + "version": "1.0.0", + "meta": {"last_updated": "2026-01-15T10:00:00Z"}, + "data": {"servers": []} + }` + + It("converts legacy file via --in/--out flags", func() { + dir := GinkgoT().TempDir() + inPath := filepath.Join(dir, "registry.json") + outPath := filepath.Join(dir, "out.json") + Expect(os.WriteFile(inPath, []byte(legacyRegistry), 0o600)).To(Succeed()) + + e2e.NewTHVCommand(thvConfig, "registry", "convert", "--in", inPath, "--out", outPath). + ExpectSuccess() + + converted, err := os.ReadFile(outPath) + Expect(err).ToNot(HaveOccurred()) + var parsed map[string]any + Expect(json.Unmarshal(converted, &parsed)).To(Succeed()) + data, ok := parsed["data"].(map[string]any) + Expect(ok).To(BeTrue(), "converted output must wrap servers under data") + servers, ok := data["servers"].([]any) + Expect(ok).To(BeTrue()) + Expect(servers).To(HaveLen(1)) + }) + + It("converts via stdin to stdout when no flags are given", func() { + stdout, _ := e2e.NewTHVCommand(thvConfig, "registry", "convert"). + WithStdin(legacyRegistry). + ExpectSuccess() + + var parsed map[string]any + Expect(json.Unmarshal([]byte(stdout), &parsed)).To(Succeed()) + Expect(parsed).To(HaveKey("data")) + }) + + It("rewrites in place and creates a .bak by default", func() { + dir := GinkgoT().TempDir() + inPath := filepath.Join(dir, "registry.json") + Expect(os.WriteFile(inPath, []byte(legacyRegistry), 0o600)).To(Succeed()) + + e2e.NewTHVCommand(thvConfig, "registry", "convert", "--in", inPath, "--in-place"). + ExpectSuccess() + + updated, err := os.ReadFile(inPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(updated)).To(ContainSubstring(`"data"`), + "in-place output must be in upstream format") + + bak, err := os.ReadFile(inPath + ".bak") + Expect(err).ToNot(HaveOccurred()) + Expect(string(bak)).To(ContainSubstring(`"servers": {`), + ".bak must hold the legacy original") + }) + + It("emits a friendly stderr message and exits 0 when input is already upstream", func() { + dir := GinkgoT().TempDir() + inPath := filepath.Join(dir, "registry.json") + Expect(os.WriteFile(inPath, []byte(upstreamRegistry), 0o600)).To(Succeed()) + + _, stderr := e2e.NewTHVCommand(thvConfig, "registry", "convert", "--in", inPath). + ExpectSuccess() + Expect(stderr).To(ContainSubstring("already in upstream format")) + }) +}) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index da9eb40f7b..cbf40af20d 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "syscall" "time" @@ -72,12 +73,6 @@ func (c *THVCommand) WithEnv(env ...string) *THVCommand { return c } -// WithDir sets the working directory for the command -func (c *THVCommand) WithDir(dir string) *THVCommand { - c.dir = dir - return c -} - // WithStdin sets the stdin input for the command func (c *THVCommand) WithStdin(stdin string) *THVCommand { c.stdin = stdin @@ -329,3 +324,34 @@ func CreateAndTrackGroup(config *TestConfig, groupName string, createdGroups *[] NewTHVCommand(config, "group", "create", groupName).ExpectSuccess() *createdGroups = append(*createdGroups, groupName) } + +// CreateFakeBrowserDir writes stub open/xdg-open scripts into a "fakebin" +// subdirectory of tempDir. The stubs GET the auth URL without following the +// redirect, so the OIDC mock server receives the request and populates +// authRequestChan while CompleteAuthRequest drives the callback. +// Returns the directory so callers can prepend it to PATH. +func CreateFakeBrowserDir(tempDir string) (string, error) { + dir := filepath.Join(tempDir, "fakebin") + if err := os.MkdirAll(dir, 0750); err != nil { + return "", err + } + // curl: -sf = silent + fail-on-HTTP-error; no -L so 302 is not followed. + // wget: --max-redirect=0 prevents following the 302 to the callback URL, + // which would race with CompleteAuthRequest and make the test flaky. + // If neither tool is available the script exits 1 with a clear message so + // the test fails fast instead of hanging until WaitForAuthRequest times out. + script := []byte("#!/bin/sh\n" + + "if command -v curl >/dev/null 2>&1; then\n" + + " curl -sf \"$1\" >/dev/null 2>&1\n" + + "elif command -v wget >/dev/null 2>&1; then\n" + + " wget -q --max-redirect=0 \"$1\" -O /dev/null 2>&1\n" + + "else\n" + + " echo 'fake-browser: neither curl nor wget found' >&2; exit 1\n" + + "fi\n") + for _, name := range []string{"open", "xdg-open"} { + if err := os.WriteFile(filepath.Join(dir, name), script, 0750); err != nil { //nolint:gosec // shell scripts must be executable + return "", err + } + } + return dir, nil +} diff --git a/test/e2e/llm_gateway_mock.go b/test/e2e/llm_gateway_mock.go new file mode 100644 index 0000000000..f7fb27f208 --- /dev/null +++ b/test/e2e/llm_gateway_mock.go @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// GatewayRequest records a single request received by the mock LLM gateway. +type GatewayRequest struct { + Method string + Path string + // Bearer is the token value stripped from the Authorization header, + // or empty if no Authorization header was present. + Bearer string +} + +// LLMGatewayMock is a server that responds to OpenAI-compatible requests +// (/v1/models, /v1/chat/completions) and records every request it receives. +// +// Use NewLLMGatewayMock for HTTPS (self-signed cert) or NewLLMGatewayMockHTTP +// for plain HTTP. The HTTP variant is simpler for e2e tests where the thv +// subprocess cannot be easily configured to trust a self-signed cert. +type LLMGatewayMock struct { + server *http.Server + port int + useTLS bool + certPEM []byte // non-nil only when useTLS is true + + mu sync.Mutex + requests []GatewayRequest +} + +// NewLLMGatewayMock creates a mock LLM gateway that serves HTTPS with a +// self-signed certificate. Use CertPEM / TLSClientConfig to build a trusting +// HTTP client. Call Start to begin serving. +func NewLLMGatewayMock(port int) (*LLMGatewayMock, error) { + certPEM, keyPEM, err := generateGatewayCert() + if err != nil { + return nil, err + } + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("loading TLS key pair: %w", err) + } + + m := &LLMGatewayMock{port: port, useTLS: true, certPEM: certPEM} + m.server = m.newServer() + m.server.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + return m, nil +} + +func (m *LLMGatewayMock) newServer() *http.Server { + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", m.handleModels) + mux.HandleFunc("/v1/chat/completions", m.handleChatCompletions) + // Gemini CLI sends requests to /v1beta/models/{model}:generateContent. + // The mock handles any /v1beta/ path so proxy routing tests can verify + // end-to-end request flow without a real Envoy AI Gateway. + mux.HandleFunc("/v1beta/", m.handleGeminiGenerate) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + return &http.Server{ + Addr: fmt.Sprintf(":%d", m.port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } +} + +// Start begins serving requests. It blocks briefly until the port is open. +func (m *LLMGatewayMock) Start() error { + errCh := make(chan error, 1) + go func() { + var err error + if m.useTLS { + err = m.server.ListenAndServeTLS("", "") + } else { + err = m.server.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + addr := fmt.Sprintf("127.0.0.1:%d", m.port) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + select { + case err := <-errCh: + return err + default: + time.Sleep(50 * time.Millisecond) + } + } + return fmt.Errorf("mock LLM gateway did not start on port %d within 5s", m.port) +} + +// Stop shuts down the mock gateway. +func (m *LLMGatewayMock) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return m.server.Shutdown(ctx) +} + +// URL returns the base URL of the mock gateway (https:// or http:// depending +// on how the mock was created). +func (m *LLMGatewayMock) URL() string { + scheme := "https" + if !m.useTLS { + scheme = "http" + } + return fmt.Sprintf("%s://localhost:%d", scheme, m.port) +} + +// CertPEM returns the PEM-encoded self-signed certificate. Use this to build a +// custom *http.Client or x509.CertPool that trusts the mock gateway. +func (m *LLMGatewayMock) CertPEM() []byte { + return m.certPEM +} + +// LastBearerToken returns the Bearer token from the most recent request, or +// empty string if no requests have been received or none carried a token. +func (m *LLMGatewayMock) LastBearerToken() string { + m.mu.Lock() + defer m.mu.Unlock() + for i := len(m.requests) - 1; i >= 0; i-- { + if m.requests[i].Bearer != "" { + return m.requests[i].Bearer + } + } + return "" +} + +// record saves a request observation. +func (m *LLMGatewayMock) record(r *http.Request) { + bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if bearer == r.Header.Get("Authorization") { + bearer = "" // no "Bearer " prefix → no token + } + m.mu.Lock() + m.requests = append(m.requests, GatewayRequest{ + Method: r.Method, + Path: r.URL.Path, + Bearer: bearer, + }) + m.mu.Unlock() +} + +func (m *LLMGatewayMock) handleModels(w http.ResponseWriter, r *http.Request) { + m.record(r) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]any{ + {"id": "mock-gpt-4", "object": "model", "created": 1700000000, "owned_by": "mock"}, + {"id": "mock-claude-3", "object": "model", "created": 1700000000, "owned_by": "mock"}, + }, + }) +} + +func (m *LLMGatewayMock) handleChatCompletions(w http.ResponseWriter, r *http.Request) { + m.record(r) + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + + model := "mock-gpt-4" + if v, ok := body["model"].(string); ok { + model = v + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-mock-001", + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "Hello from the mock LLM gateway!", + }, + "finish_reason": "stop", + }}, + "usage": map[string]any{ + "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30, + }, + }) +} + +// handleGeminiGenerate handles Gemini CLI's native API path: +// /v1beta/models/{model}:generateContent (and other /v1beta/* variants). +// It returns a minimal Gemini GenerateContent response so proxy routing tests +// can verify end-to-end flow without a real Envoy AI Gateway. +func (m *LLMGatewayMock) handleGeminiGenerate(w http.ResponseWriter, r *http.Request) { + m.record(r) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{ + "parts": []map[string]any{{"text": "Hello from the mock LLM gateway!"}}, + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + }}, + "usageMetadata": map[string]any{ + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + }, + }) +} + +// generateGatewayCert creates a self-signed ECDSA certificate for localhost. +func generateGatewayCert() (certPEM, keyPEM []byte, err error) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generating key: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{Organization: []string{"thv-llm-mock"}}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) + if err != nil { + return nil, nil, fmt.Errorf("creating certificate: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + return nil, nil, fmt.Errorf("marshaling key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM, nil +} diff --git a/test/e2e/mcp_client_helpers.go b/test/e2e/mcp_client_helpers.go index a78c4dd765..f08b8a2e10 100644 --- a/test/e2e/mcp_client_helpers.go +++ b/test/e2e/mcp_client_helpers.go @@ -6,10 +6,13 @@ package e2e import ( "context" "fmt" + "io" + "net/http" "strings" "time" "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // Standard practice for Ginkgo . "github.com/onsi/gomega" //nolint:staticcheck // Standard practice for Gomega @@ -46,6 +49,61 @@ func NewMCPClientForStreamableHTTP(config *TestConfig, serverURL string) (*MCPCl }, nil } +// NewMCPClientForStreamableHTTPWithToken creates a new MCP client for streamable HTTP +// transport that sends an Authorization Bearer token on every request. Use this when +// the vMCP server has OIDC incoming auth enabled. +func NewMCPClientForStreamableHTTPWithToken(config *TestConfig, serverURL, token string) (*MCPClientHelper, error) { + mcpClient, err := client.NewStreamableHttpClient(serverURL, + transport.WithHTTPHeaders(map[string]string{ + "Authorization": "Bearer " + token, + }), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Streamable HTTP MCP client: %w", err) + } + return &MCPClientHelper{client: mcpClient, config: config}, nil +} + +// WaitForVMCPHealthReady polls the vMCP /health endpoint until it returns 200 OK or +// the timeout is reached. Use this instead of WaitForMCPServerReady when incoming auth +// is configured (MCP Initialize would fail with 401 for unauthenticated probes). +func WaitForVMCPHealthReady(healthURL string, timeout time.Duration) error { + httpClient := &http.Client{Timeout: 5 * time.Second} + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + var lastErr error + var lastStatus int + var lastBody string + for { + select { + case <-ctx.Done(): + if lastErr != nil { + return fmt.Errorf("timeout waiting for vMCP health endpoint at %s: last error: %w", healthURL, lastErr) + } + return fmt.Errorf("timeout waiting for vMCP health endpoint at %s: last status: %d, body: %s", healthURL, lastStatus, lastBody) + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) //nolint:gosec // URL is test-controlled + if err != nil { + lastErr = err + continue + } + resp, err := httpClient.Do(req) + if resp != nil { + lastStatus = resp.StatusCode + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + _ = resp.Body.Close() + lastBody = string(bodyBytes) + } + lastErr = err + if err == nil && resp.StatusCode == http.StatusOK { + return nil + } + } + } +} + // Initialize initializes the MCP connection func (h *MCPClientHelper) Initialize(ctx context.Context) error { // Start the transport first @@ -93,19 +151,6 @@ func (h *MCPClientHelper) CallTool( return h.client.CallTool(ctx, request) } -// ListResources lists all available resources from the MCP server -func (h *MCPClientHelper) ListResources(ctx context.Context) (*mcp.ListResourcesResult, error) { - request := mcp.ListResourcesRequest{} - return h.client.ListResources(ctx, request) -} - -// ReadResource reads a specific resource -func (h *MCPClientHelper) ReadResource(ctx context.Context, uri string) (*mcp.ReadResourceResult, error) { - request := mcp.ReadResourceRequest{} - request.Params.URI = uri - return h.client.ReadResource(ctx, request) -} - // Ping sends a ping to test connectivity func (h *MCPClientHelper) Ping(ctx context.Context) error { return h.client.Ping(ctx) @@ -136,21 +181,6 @@ func (h *MCPClientHelper) ExpectToolCall( return result } -// ExpectResourceExists verifies that a resource with the given URI exists -func (h *MCPClientHelper) ExpectResourceExists(ctx context.Context, uri string) { - resources, err := h.ListResources(ctx) - ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Should be able to list resources") - - found := false - for _, resource := range resources.Resources { - if resource.URI == uri { - found = true - break - } - } - ExpectWithOffset(1, found).To(BeTrue(), fmt.Sprintf("Resource '%s' should exist", uri)) -} - // WaitForMCPServerReady waits for an MCP server to be ready and responsive func WaitForMCPServerReady(config *TestConfig, serverURL string, mode string, timeout time.Duration) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) @@ -212,47 +242,3 @@ func extractServerNameFromURL(serverURL string) string { } return "unknown" } - -// TestMCPServerBasicFunctionality tests basic MCP server functionality -func TestMCPServerBasicFunctionality(config *TestConfig, serverURL string) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Create MCP client - mcpClient, err := NewMCPClientForSSE(config, serverURL) - if err != nil { - return fmt.Errorf("failed to create MCP client: %w", err) - } - defer func() { - // Error ignored in test cleanup - the test may have already closed the connection - _ = mcpClient.Close() - }() - - // Initialize the connection - if err := mcpClient.Initialize(ctx); err != nil { - return fmt.Errorf("failed to initialize MCP connection: %w", err) - } - - // Test ping - if err := mcpClient.Ping(ctx); err != nil { - return fmt.Errorf("ping failed: %w", err) - } - - // List tools - tools, err := mcpClient.ListTools(ctx) - if err != nil { - return fmt.Errorf("failed to list tools: %w", err) - } - - if len(tools.Tools) == 0 { - return fmt.Errorf("no tools available from MCP server") - } - - // List resources (if supported) - // Note: Not all MCP servers support resources, so we don't fail on this - if _, err := mcpClient.ListResources(ctx); err != nil { - GinkgoWriter.Printf("Note: Server does not support resources: %v\n", err) - } - - return nil -} diff --git a/test/e2e/oidc_mock.go b/test/e2e/oidc_mock.go index 3e9a2aec4f..af670d9f62 100644 --- a/test/e2e/oidc_mock.go +++ b/test/e2e/oidc_mock.go @@ -16,9 +16,11 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/compose" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/storage" "github.com/ory/fosite/token/jwt" + "golang.org/x/crypto/bcrypt" ) // OIDCMockServer represents a lightweight OIDC server using Ory Fosite @@ -44,42 +46,89 @@ type AuthRequest struct { Scope string } -// NewOIDCMockServer creates a new OIDC mock server using Ory Fosite -func NewOIDCMockServer(port int, clientID, clientSecret string, opts ...func(*fosite.Config)) (*OIDCMockServer, error) { +// jwtKeyID is the key ID used in both the JWKS response and the JWT header. +// pkg/auth's token validator requires the kid claim to look up the signing key. +const jwtKeyID = "test-key-1" + +// OIDCMockOption is a unified option type for configuring the OIDC mock server. +// Use WithClientAudience for client-registration settings and WithAccessTokenLifespan +// (or other fosite-level helpers) for token-lifecycle settings. A single constructor +// accepts both, so tests needing both a custom token lifetime and a specific audience +// no longer require separate constructors. +type OIDCMockOption struct { + fositeOpt func(*fosite.Config) + clientOpt func(*fosite.DefaultClient) +} + +// WithClientAudience sets the allowed audience(s) on the registered test client. +// Use this when the vMCP OIDC config requires a specific audience claim in tokens. +func WithClientAudience(audiences ...string) OIDCMockOption { + return OIDCMockOption{clientOpt: func(c *fosite.DefaultClient) { + c.Audience = audiences + }} +} + +// NewOIDCMockServer creates a new OIDC mock server using Ory Fosite. +// Use WithClientAudience to set client-level options and WithAccessTokenLifespan +// for Fosite-level settings. Both option kinds may be mixed in a single call. +func NewOIDCMockServer(port int, clientID, clientSecret string, opts ...OIDCMockOption) (*OIDCMockServer, error) { + config := defaultFositeConfig(port) + for _, opt := range opts { + if opt.fositeOpt != nil { + opt.fositeOpt(config) + } + } + return newOIDCMockServer(port, clientID, clientSecret, config, opts...) +} + +// defaultFositeConfig returns the standard Fosite config for the mock server. +func defaultFositeConfig(port int) *fosite.Config { + issuer := fmt.Sprintf("http://localhost:%d", port) + return &fosite.Config{ + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: time.Hour * 24, + AuthorizeCodeLifespan: time.Minute * 10, + IDTokenLifespan: time.Hour, + IDTokenIssuer: issuer, + AccessTokenIssuer: issuer, + HashCost: 12, + } +} + +// newOIDCMockServer is the shared implementation for NewOIDCMockServer. +func newOIDCMockServer( + port int, clientID, clientSecret string, config *fosite.Config, opts ...OIDCMockOption, +) (*OIDCMockServer, error) { // Generate RSA key for JWT signing key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, fmt.Errorf("failed to generate RSA key: %w", err) } - // Create memory store - store := storage.NewMemoryStore() + // Hash the client secret — Fosite's DefaultClientAuthenticationStrategy uses + // BCryptHasher.Compare, so the stored secret must be bcrypt-hashed. + // Use the same cost as the Fosite config to keep them consistent. + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), config.HashCost) + if err != nil { + return nil, fmt.Errorf("failed to hash client secret: %w", err) + } - // Add test client + // Create memory store and register the test client. + store := storage.NewMemoryStore() client := &fosite.DefaultClient{ ID: clientID, - Secret: []byte(clientSecret), + Secret: hashedSecret, RedirectURIs: []string{"http://localhost:8080/callback", "http://127.0.0.1:8080/callback"}, ResponseTypes: []string{"code"}, GrantTypes: []string{"authorization_code", "refresh_token", "client_credentials"}, Scopes: []string{"openid", "profile", "email"}, } - store.Clients[clientID] = client - - // Create Fosite configuration - config := &fosite.Config{ - AccessTokenLifespan: time.Hour, - RefreshTokenLifespan: time.Hour * 24, - AuthorizeCodeLifespan: time.Minute * 10, - IDTokenLifespan: time.Hour, - IDTokenIssuer: fmt.Sprintf("http://localhost:%d", port), - HashCost: 12, - } - - // Apply any overrides provided via opts for _, opt := range opts { - opt(config) + if opt.clientOpt != nil { + opt.clientOpt(client) + } } + store.Clients[clientID] = client // Create JWT strategy jwtStrategy := compose.NewOAuth2JWTStrategy( @@ -165,6 +214,7 @@ func (m *OIDCMockServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid", "profile", "email"}, + "client_id_metadata_document_supported": true, } w.Header().Set("Content-Type", "application/json") @@ -271,13 +321,38 @@ func (m *OIDCMockServer) handleToken(w http.ResponseWriter, r *http.Request) { return } - // Create token request - accessRequest, err := m.provider.NewAccessRequest(ctx, r, &openid.DefaultSession{}) + // Create token request. + // Use JWTSession so DefaultJWTStrategy can populate JWT claims; + // openid.DefaultSession does not implement JWTSessionContainer and causes + // a 500 for client_credentials flows. + accessRequest, err := m.provider.NewAccessRequest(ctx, r, &fositeoauth2.JWTSession{}) if err != nil { m.provider.WriteAccessError(ctx, w, accessRequest, err) return } + // For client_credentials, grant requested scopes and audiences so they appear + // in the issued token's scp/aud claims. Other grant types handle this during + // the authorization step, but client_credentials has no authorization step. + // Also set the kid in the JWT header so pkg/auth's token validator can look + // up the signing key in the JWKS by key ID — it rejects tokens without a kid. + if accessRequest.GetGrantTypes().ExactOne("client_credentials") { + for _, scope := range accessRequest.GetRequestedScopes() { + accessRequest.GrantScope(scope) + } + for _, aud := range accessRequest.GetRequestedAudience() { + accessRequest.GrantAudience(aud) + } + if jwtSess, ok := accessRequest.GetSession().(*fositeoauth2.JWTSession); ok { + jwtSess.GetJWTHeader().Add("kid", jwtKeyID) + // Set subject to the client ID — OIDC Core § 5.1 requires a non-empty + // sub claim and pkg/auth rejects tokens without one. + if jwtClaims, ok := jwtSess.GetJWTClaims().(*jwt.JWTClaims); ok { + jwtClaims.Subject = accessRequest.GetClient().GetID() + } + } + } + // Create token response response, err := m.provider.NewAccessResponse(ctx, accessRequest) if err != nil { @@ -340,7 +415,7 @@ func (m *OIDCMockServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { { "kty": "RSA", "use": "sig", - "kid": "test-key-1", + "kid": jwtKeyID, "alg": "RS256", "n": nB64, "e": eB64, @@ -375,28 +450,30 @@ func (m *OIDCMockServer) Stop() error { return nil } -// GetBaseURL returns the base URL of the mock server -func (m *OIDCMockServer) GetBaseURL() string { - return fmt.Sprintf("http://localhost:%d", m.port) -} - // EnableAutoComplete enables automatic OAuth flow completion for testing func (m *OIDCMockServer) EnableAutoComplete() { m.autoComplete = true m.authRequestChan = make(chan *AuthRequest, 1) } -// WaitForAuthRequest waits for an OAuth authorization request and returns its parameters -func (m *OIDCMockServer) WaitForAuthRequest(timeout time.Duration) (*AuthRequest, error) { +// WaitForAuthRequest waits for an OAuth authorization request and returns its +// parameters. The call returns as soon as ctx is cancelled, the timeout +// elapses, or an auth request arrives — whichever comes first. +func (m *OIDCMockServer) WaitForAuthRequest(ctx context.Context, timeout time.Duration) (*AuthRequest, error) { if m.authRequestChan == nil { return nil, fmt.Errorf("auto-complete not enabled") } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { case req := <-m.authRequestChan: return req, nil - case <-time.After(timeout): + case <-timer.C: return nil, fmt.Errorf("timeout waiting for auth request") + case <-ctx.Done(): + return nil, ctx.Err() } } @@ -427,8 +504,8 @@ func (*OIDCMockServer) CompleteAuthRequest(authReq *AuthRequest) error { } // WithAccessTokenLifespan sets the lifespan of access tokens for the OIDC mock server. -func WithAccessTokenLifespan(d time.Duration) func(*fosite.Config) { - return func(c *fosite.Config) { +func WithAccessTokenLifespan(d time.Duration) OIDCMockOption { + return OIDCMockOption{fositeOpt: func(c *fosite.Config) { c.AccessTokenLifespan = d - } + }} } diff --git a/test/e2e/stateless_proxy_test.go b/test/e2e/stateless_proxy_test.go index 3b9c64baf8..fd0f85f8ce 100644 --- a/test/e2e/stateless_proxy_test.go +++ b/test/e2e/stateless_proxy_test.go @@ -257,7 +257,3 @@ func (m *statelessMockMCPServer) Stop() { func (m *statelessMockMCPServer) GetCount() int32 { return m.postHits.Load() } - -func (m *statelessMockMCPServer) GotGET() bool { - return m.gotGET.Load() -} diff --git a/test/e2e/testdata/upgrade/registry-high.json b/test/e2e/testdata/upgrade/registry-high.json new file mode 100644 index 0000000000..11f367aea1 --- /dev/null +++ b/test/e2e/testdata/upgrade/registry-high.json @@ -0,0 +1,31 @@ +{ + "version": "1.0.0", + "meta": { + "last_updated": "2026-01-15T10:00:00Z" + }, + "data": { + "servers": [ + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "osv-upgrade-test", + "title": "OSV (upgrade e2e fixture)", + "description": "OSV MCP server fixture for upgrade e2e tests (high tag). Mirrors the bundled osv entry's transport (streamable-http) and image exactly, pinned to the higher tag advertised as the upgrade candidate. The transport url port 8080 is the image-INTERNAL target port (becomes RunConfig.TargetPort); thv still assigns an OS-chosen host proxy port, so there is no fixed host-port clash. Only the image tag differs between this file and registry-low.json.", + "version": "1.0.0", + "repository": { + "url": "https://github.com/StacklokLabs/osv-mcp", + "source": "github" + }, + "packages": [ + { + "identifier": "ghcr.io/stackloklabs/osv-mcp/server:0.1.3", + "registryType": "oci", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8080" + } + } + ] + } + ] + } +} diff --git a/test/e2e/testdata/upgrade/registry-low.json b/test/e2e/testdata/upgrade/registry-low.json new file mode 100644 index 0000000000..79aa66dcc4 --- /dev/null +++ b/test/e2e/testdata/upgrade/registry-low.json @@ -0,0 +1,31 @@ +{ + "version": "1.0.0", + "meta": { + "last_updated": "2026-01-15T10:00:00Z" + }, + "data": { + "servers": [ + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "osv-upgrade-test", + "title": "OSV (upgrade e2e fixture)", + "description": "OSV MCP server fixture for upgrade e2e tests (low tag). Mirrors the bundled osv entry's transport (streamable-http) and image exactly, pinned to the lower tag so the upgrade checker reports an available upgrade once repointed to registry-high.json. The transport url port 8080 is the image-INTERNAL target port (becomes RunConfig.TargetPort); thv still assigns an OS-chosen host proxy port, so there is no fixed host-port clash. Only the image tag differs between this file and registry-high.json.", + "version": "1.0.0", + "repository": { + "url": "https://github.com/StacklokLabs/osv-mcp", + "source": "github" + }, + "packages": [ + { + "identifier": "ghcr.io/stackloklabs/osv-mcp/server:0.1.1", + "registryType": "oci", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8080" + } + } + ] + } + ] + } +} diff --git a/test/e2e/thv-operator/acceptance_tests/suite_test.go b/test/e2e/thv-operator/acceptance_tests/suite_test.go index 0d4d0c4e0a..7463dc4bef 100644 --- a/test/e2e/thv-operator/acceptance_tests/suite_test.go +++ b/test/e2e/thv-operator/acceptance_tests/suite_test.go @@ -23,6 +23,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) @@ -64,6 +65,9 @@ var _ = ginkgo.BeforeSuite(func() { cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = mcpv1alpha1.AddToScheme(scheme.Scheme) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = mcpv1beta1.AddToScheme(scheme.Scheme) gomega.Expect(err).NotTo(gomega.HaveOccurred()) err = appsv1.AddToScheme(scheme.Scheme) diff --git a/test/e2e/thv-operator/virtualmcp/helpers.go b/test/e2e/thv-operator/virtualmcp/helpers.go index d91e314b72..29bf374a78 100644 --- a/test/e2e/thv-operator/virtualmcp/helpers.go +++ b/test/e2e/thv-operator/virtualmcp/helpers.go @@ -5,7 +5,6 @@ package virtualmcp import ( - "bytes" "context" "encoding/json" "errors" @@ -258,167 +257,6 @@ func WaitForCondition( // OIDC Testing Helpers -// DeployMockOIDCServerHTTP deploys a mock OIDC server with HTTP (for testing) -func DeployMockOIDCServerHTTP(ctx context.Context, c client.Client, namespace, serverName string) { - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: serverName, - Namespace: namespace, - Labels: map[string]string{"app": serverName}, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: int32Ptr(1), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": serverName}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": serverName}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "mock-oidc", - Image: images.PythonImage, - Command: []string{"sh", "-c"}, - Args: []string{MockOIDCServerHTTPScript}, - Ports: []corev1.ContainerPort{ - {ContainerPort: 80, Name: "http"}, - }, - }, - }, - }, - }, - }, - } - gomega.Expect(c.Create(ctx, deployment)).To(gomega.Succeed()) - - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serverName, - Namespace: namespace, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{"app": serverName}, - Ports: []corev1.ServicePort{ - { - Port: 80, - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } - gomega.Expect(c.Create(ctx, service)).To(gomega.Succeed()) - - gomega.Eventually(func() bool { - dep := &appsv1.Deployment{} - err := c.Get(ctx, types.NamespacedName{Name: serverName, Namespace: namespace}, dep) - return err == nil && dep.Status.ReadyReplicas > 0 - }, 3*time.Minute, 1*time.Second).Should(gomega.BeTrue(), "Mock OIDC server should be ready") -} - -// DeployInstrumentedBackendServer deploys a backend server that logs all headers -func DeployInstrumentedBackendServer(ctx context.Context, c client.Client, namespace, serverName string) { - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: serverName, - Namespace: namespace, - Labels: map[string]string{"app": serverName}, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: int32Ptr(1), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": serverName}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": serverName}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "instrumented-backend", - Image: images.PythonImage, - Command: []string{"sh", "-c"}, - Args: []string{InstrumentedBackendScript}, - Ports: []corev1.ContainerPort{ - {ContainerPort: 8080, Name: "http"}, - }, - }, - }, - }, - }, - }, - } - gomega.Expect(c.Create(ctx, deployment)).To(gomega.Succeed()) - - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serverName, - Namespace: namespace, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{"app": serverName}, - Ports: []corev1.ServicePort{ - { - Port: 8080, - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } - gomega.Expect(c.Create(ctx, service)).To(gomega.Succeed()) - - gomega.Eventually(func() bool { - dep := &appsv1.Deployment{} - err := c.Get(ctx, types.NamespacedName{Name: serverName, Namespace: namespace}, dep) - return err == nil && dep.Status.ReadyReplicas > 0 - }, 3*time.Minute, 1*time.Second).Should(gomega.BeTrue(), "Instrumented backend should be ready") -} - -// CleanupMockServer cleans up a mock server deployment, service, and optionally its TLS secret -func CleanupMockServer(ctx context.Context, c client.Client, namespace, serverName, tlsSecretName string) { - _ = c.Delete(ctx, &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - }) - _ = c.Delete(ctx, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - }) - if tlsSecretName != "" { - _ = c.Delete(ctx, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: tlsSecretName, Namespace: namespace}, - }) - } -} - -// GetPodLogsForDeployment returns logs from pods for a deployment (for debugging) -func GetPodLogsForDeployment(ctx context.Context, c client.Client, namespace, deploymentName string) string { - pods := &corev1.PodList{} - listOpts := []client.ListOption{ - client.InNamespace(namespace), - client.MatchingLabels{"app": deploymentName}, - } - - err := c.List(ctx, pods, listOpts...) - if err != nil || len(pods.Items) == 0 { - return fmt.Sprintf("No pods found for deployment %s", deploymentName) - } - - pod := pods.Items[0] - if len(pod.Spec.Containers) == 0 { - return fmt.Sprintf("No containers found in pod %s", pod.Name) - } - - // Get logs from the first container - containerName := pod.Spec.Containers[0].Name - logs, err := getPodLogs(ctx, namespace, pod.Name, containerName, false) - if err != nil { - return fmt.Sprintf("Failed to get logs for pod %s: %v", pod.Name, err) - } - - return logs -} - // GetPodLogs returns logs from a specific pod and container func GetPodLogs(ctx context.Context, podName, namespace, containerName string) (string, error) { logs, err := getPodLogs(ctx, namespace, podName, containerName, false) @@ -432,34 +270,6 @@ func int32Ptr(i int32) *int32 { return &i } -// GetMCPServerDeployment retrieves the deployment for an MCPServer by name. -// MCPServer deployments use the same name as the MCPServer resource. -func GetMCPServerDeployment(ctx context.Context, c client.Client, serverName, namespace string) *appsv1.Deployment { - deployment := &appsv1.Deployment{} - err := c.Get(ctx, types.NamespacedName{ - Name: serverName, - Namespace: namespace, - }, deployment) - if err != nil { - return nil - } - return deployment -} - -// GetMCPServerStatefulSet retrieves the StatefulSet for an MCPServer by name. -// MCPServer StatefulSets use the same name as the MCPServer resource for the workload pods. -func GetMCPServerStatefulSet(ctx context.Context, c client.Client, serverName, namespace string) *appsv1.StatefulSet { - statefulset := &appsv1.StatefulSet{} - err := c.Get(ctx, types.NamespacedName{ - Name: serverName, - Namespace: namespace, - }, statefulset) - if err != nil { - return nil - } - return statefulset -} - // WaitForPodDeletion waits for a pod to be fully deleted from the cluster. // This is useful in AfterAll cleanup to ensure pods are gone before tests repeat. func WaitForPodDeletion(ctx context.Context, c client.Client, name, namespace string, timeout, pollingInterval time.Duration) { @@ -517,36 +327,6 @@ func GetServiceStats(ctx context.Context, c client.Client, namespace, serviceNam return logs, nil } -// GetMockOIDCStats queries the /stats endpoint of the mock OIDC server -func GetMockOIDCStats(ctx context.Context, c client.Client, namespace, serviceName string) (map[string]int, error) { - logs, err := GetServiceStats(ctx, c, namespace, serviceName, 80) - if err != nil { - return nil, err - } - - // Parse JSON response - check if discovery_requests field exists - stats := make(map[string]int) - if len(logs) > 0 && bytes.Contains([]byte(logs), []byte("discovery_requests")) { - stats["discovery_requests"] = 1 // Simplified - just check if field exists - } - return stats, nil -} - -// GetInstrumentedBackendStats queries the /stats endpoint of the instrumented backend -func GetInstrumentedBackendStats(ctx context.Context, c client.Client, namespace, serviceName string) (map[string]int, error) { - logs, err := GetServiceStats(ctx, c, namespace, serviceName, 8080) - if err != nil { - return nil, err - } - - // Parse JSON response - check if bearer_token_requests field exists - stats := make(map[string]int) - if len(logs) > 0 && bytes.Contains([]byte(logs), []byte("bearer_token_requests")) { - stats["bearer_token_requests"] = 1 // Simplified - just check if field exists and > 0 - } - return stats, nil -} - // GetMockOAuth2Stats queries the /stats endpoint of the mock OAuth2 server (port 8080) // and returns the number of client_credentials grant requests recorded so far. func GetMockOAuth2Stats(ctx context.Context, c client.Client, namespace, serviceName string) (int, error) { diff --git a/test/e2e/thv-operator/virtualmcp/suite_test.go b/test/e2e/thv-operator/virtualmcp/suite_test.go index d9dd5da882..c89ff7731f 100644 --- a/test/e2e/thv-operator/virtualmcp/suite_test.go +++ b/test/e2e/thv-operator/virtualmcp/suite_test.go @@ -24,6 +24,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) @@ -80,6 +81,9 @@ var _ = ginkgo.BeforeSuite(func() { gomega.Expect(cfg).NotTo(gomega.BeNil()) // Register schemes + err = mcpv1alpha1.AddToScheme(scheme.Scheme) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = mcpv1beta1.AddToScheme(scheme.Scheme) gomega.Expect(err).NotTo(gomega.HaveOccurred()) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go index 12cebf47d2..61a24c86be 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go @@ -10,6 +10,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -460,9 +461,30 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { backend.Spec.Image = images.YardstickServerImage Expect(k8sClient.Update(ctx, backend)).To(Succeed()) + By("Waiting for backend StatefulSet template to use the fixed image") + Eventually(func() error { + sts := &appsv1.StatefulSet{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backend2Name, + Namespace: testNamespace, + }, sts); err != nil { + return err + } + for _, container := range sts.Spec.Template.Spec.Containers { + if container.Name == "mcp" { + if container.Image != images.YardstickServerImage { + return fmt.Errorf("statefulset still has image %q", container.Image) + } + return nil + } + } + return fmt.Errorf("mcp container not found in statefulset template") + }, timeout, pollingInterval).Should(Succeed()) + By("Deleting stuck pods to force recreation with fixed image") // Pods in ImagePullBackOff don't automatically recreate when image is fixed - // Delete them to force the statefulset to create new pods with the correct image + // Delete them after the statefulset template is updated, otherwise the old template + // can immediately recreate the pod with the broken image again. podList := &corev1.PodList{} Expect(k8sClient.List(ctx, podList, client.InNamespace(testNamespace), diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go index 1886fa492a..4e763c220e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go @@ -10,6 +10,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -297,6 +298,32 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun backend.Spec.Image = images.YardstickServerImage Expect(k8sClient.Update(ctx, backend)).To(Succeed()) + By("Waiting for backend StatefulSet template to use the fixed image") + // Without this wait we can race the proxyrunner: deleting the + // Pending pod before the StatefulSet template has flipped to the + // good image just causes it to be recreated against the old (bad) + // template, and the StatefulSet controller may not re-roll an + // already-unhealthy pod afterwards. Mirrors the same guard in + // virtualmcp_circuit_breaker_test.go added in #5079. + Eventually(func() error { + sts := &appsv1.StatefulSet{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: unstableName, + Namespace: testNamespace, + }, sts); err != nil { + return err + } + for _, container := range sts.Spec.Template.Spec.Containers { + if container.Name == "mcp" { + if container.Image != images.YardstickServerImage { + return fmt.Errorf("statefulset still has image %q", container.Image) + } + return nil + } + } + return fmt.Errorf("mcp container not found in statefulset template") + }, timeout, pollingInterval).Should(Succeed()) + By("Deleting stuck pods to force recreation with fixed image") podList := &corev1.PodList{} Expect(k8sClient.List(ctx, podList, diff --git a/test/e2e/thv-operator/virtualmcp/wait_for_tools_helpers.go b/test/e2e/thv-operator/virtualmcp/wait_for_tools_helpers.go index 437c3e7207..9699e21fc2 100644 --- a/test/e2e/thv-operator/virtualmcp/wait_for_tools_helpers.go +++ b/test/e2e/thv-operator/virtualmcp/wait_for_tools_helpers.go @@ -138,29 +138,6 @@ func ToolsContainAll(tools []mcp.Tool, expectedNames ...string) error { return nil } -// ToolsContainSubstring checks if the tool list contains at least one tool whose -// name contains each of the given substrings. Returns an error if any substring -// has no matching tool. -func ToolsContainSubstring(tools []mcp.Tool, substrings ...string) error { - var missing []string - for _, sub := range substrings { - found := false - for _, t := range tools { - if strings.Contains(t.Name, sub) { - found = true - break - } - } - if !found { - missing = append(missing, sub) - } - } - if len(missing) > 0 { - return fmt.Errorf("no tools matching substrings %v; got %v", missing, toolNames(tools)) - } - return nil -} - // ToolsHavePrefix checks if there is at least one tool with each of the given prefixes. // Returns an error listing missing prefixes, or nil if all are found. func ToolsHavePrefix(tools []mcp.Tool, prefixes ...string) error { diff --git a/test/e2e/upgrade_e2e_test.go b/test/e2e/upgrade_e2e_test.go new file mode 100644 index 0000000000..5e6e4eb42a --- /dev/null +++ b/test/e2e/upgrade_e2e_test.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" + "github.com/stacklok/toolhive/test/e2e" +) + +const ( + // rawOSVImage is the concrete image the bundled "osv" registry entry resolves + // to. Running it by its raw reference (rather than by a registry name) + // produces a working MCP server whose RunConfig has no RegistryServerName, + // which is exactly the input the "not-registry-sourced" path needs. + rawOSVImage = "ghcr.io/stackloklabs/osv-mcp/server:0.1.3" + + // osvImageLow and osvImageHigh are two real, pullable osv-mcp releases. The + // upgrade-flow fixtures advertise these tags so the checker reports an + // available upgrade from low to high. Both tags run identically (only a minor + // version bump), and osv declares no required env vars or secrets, so + // `upgrade apply --yes` runs non-interactively without prompting. + osvImageLow = "ghcr.io/stackloklabs/osv-mcp/server:0.1.1" + osvImageHigh = "ghcr.io/stackloklabs/osv-mcp/server:0.1.3" + + // upgradeRegistryServerName is the server name the fixtures expose (a bare + // "name": "osv-upgrade-test", so the recorded RegistryServerName matches it + // exactly). `thv run` is invoked with this name so RegistryServerName is set. + upgradeRegistryServerName = "osv-upgrade-test" +) + +// upgradeCheckResults mirrors the JSON emitted by `thv upgrade check --format +// json`, which is an array of upgrade.CheckResult. We decode into the real type +// so the test breaks if the wire shape changes. +type upgradeCheckResults []upgrade.CheckResult + +var _ = Describe("Upgrade Command", Label("core", "upgrade", "e2e"), func() { + var ( + config *e2e.TestConfig + serverName string + + // tempHome / tempData isolate this spec's ToolHive config and workload + // state from the developer's / CI runner's real ToolHive directories. + // `thv config set-registry` writes to $XDG_CONFIG_HOME/toolhive, and + // workload state lives under $XDG_DATA_HOME/toolhive; routing every thv + // invocation (run, check, apply, export, set/unset-registry, and cleanup) + // through thvCmd keeps both off the real config and consistent across the + // run -> check -> apply -> export -> cleanup sequence so they all see the + // same workload. The container runtime (Docker/Podman socket) is not + // HOME-dependent, so an isolated HOME does not affect it; PATH and the rest + // of the environment are inherited unchanged. + tempHome string + tempData string + + // thvCmd builds a THVCommand bound to the isolated config/home/data dirs. + thvCmd func(args ...string) *e2e.THVCommand + ) + + BeforeEach(func() { + config = e2e.NewTestConfig() + serverName = e2e.GenerateUniqueServerName("upgrade-test") + tempHome = GinkgoT().TempDir() + tempData = GinkgoT().TempDir() + + thvCmd = func(args ...string) *e2e.THVCommand { + return e2e.NewTHVCommand(config, args...). + WithEnv( + "XDG_CONFIG_HOME="+tempHome, + "HOME="+tempHome, + "XDG_DATA_HOME="+tempData, + ) + } + + err := e2e.CheckTHVBinaryAvailable(config) + Expect(err).ToNot(HaveOccurred(), "thv binary should be available") + }) + + AfterEach(func() { + if config.CleanupAfter { + // Stop and remove the workload using the SAME isolated env so cleanup + // targets the workload created by this spec. Tolerate a missing + // workload (a spec may have failed before creating it). + _, _, _ = thvCmd("stop", serverName).Run() + _, _, _ = thvCmd("rm", serverName).Run() + } + // The registry config lives under the discarded temp dirs, so there is + // nothing to restore; the real ToolHive config is never touched. + }) + + // Negative / cheap path: a workload created from a raw image reference is not + // registry-sourced, so no upgrade can ever be determined for it. This needs + // no registry fixture and is the safety-net coverage. + Describe("Checking a workload that is not registry-sourced", func() { + Context("when the workload was run from a raw image reference", func() { + It("should report not-registry-sourced in text output", func() { + By("Running an MCP server from a raw image reference") + thvCmd("run", "--name", serverName, rawOSVImage).ExpectSuccess() + + By("Waiting for the server to be running") + waitForIsolatedMCPServer(thvCmd, serverName, 60*time.Second) + + By("Checking the workload for an available upgrade") + stdout, _ := thvCmd("upgrade", "check", serverName).ExpectSuccess() + + By("Verifying the report says the workload is not registry-sourced") + Expect(stdout).To(ContainSubstring(serverName), "Report should name the workload") + Expect(stdout).To(ContainSubstring(string(upgrade.StatusNotRegistrySourced)), + "Report should show the not-registry-sourced status") + }) + + It("should emit parseable JSON with the expected fields", func() { + By("Running an MCP server from a raw image reference") + thvCmd("run", "--name", serverName, rawOSVImage).ExpectSuccess() + + By("Waiting for the server to be running") + waitForIsolatedMCPServer(thvCmd, serverName, 60*time.Second) + + By("Checking the workload for an available upgrade in JSON format") + stdout, _ := thvCmd("upgrade", "check", serverName, "--format", "json").ExpectSuccess() + + By("Verifying the JSON output is valid and contains the expected fields") + var results upgradeCheckResults + err := json.Unmarshal([]byte(stdout), &results) + Expect(err).ToNot(HaveOccurred(), "Output should be valid JSON") + Expect(results).To(HaveLen(1), "JSON should contain exactly one result") + + result := results[0] + Expect(result.WorkloadName).To(Equal(serverName), "JSON should name the workload") + Expect(result.Status).To(Equal(upgrade.StatusNotRegistrySourced), + "JSON should show the not-registry-sourced status") + Expect(result.CurrentImage).To(Equal(rawOSVImage), + "JSON should record the raw image the workload is running") + Expect(result.RegistryServer).To(BeEmpty(), + "A raw-image workload should not have a registry server name") + Expect(result.CandidateImage).To(BeEmpty(), + "There should be no candidate image when no upgrade can be determined") + }) + }) + }) + + // Full upgrade flow: a registry-sourced workload whose registry advertises a + // newer tag is upgraded in place, and we verify it runs on the new image with + // its prior configuration preserved. + // + // Determinism: the two fixtures (testdata/upgrade/registry-{low,high}.json) + // advertise tags 0.1.1 and 0.1.3 of ghcr.io/stackloklabs/osv-mcp/server, both + // real, pullable releases that run identically. The fixtures are in the + // upstream MCP-registry format that `thv config set-registry` requires for a + // local file (the local provider rejects the legacy ToolHive-native format), + // and they mirror the bundled osv entry's transport (streamable-http) and + // internal target port (8080) exactly so the workload comes up the same way + // `thv run osv` would. osv declares no required env vars, so `upgrade apply + // --yes` never prompts. This runs by default in CI. + Describe("Applying an available upgrade to a registry-sourced workload", func() { + var ( + tempDir string + fixtureLow string + fixtureHigh string + ) + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + + // set-registry persists the path and resolves it later, so it must be + // absolute regardless of the working directory at resolution time. + var err error + fixtureLow, err = filepath.Abs(filepath.Join("testdata", "upgrade", "registry-low.json")) + Expect(err).ToNot(HaveOccurred()) + fixtureHigh, err = filepath.Abs(filepath.Join("testdata", "upgrade", "registry-high.json")) + Expect(err).ToNot(HaveOccurred()) + Expect(fixtureLow).To(BeAnExistingFile(), "low-tag registry fixture should exist") + Expect(fixtureHigh).To(BeAnExistingFile(), "high-tag registry fixture should exist") + }) + + It("should pull the candidate, recreate the workload, and preserve config", func() { + By("Pointing thv at the older-tag registry fixture") + thvCmd("config", "set-registry", fixtureLow).ExpectSuccess() + + By("Running the server by its registry name with an environment variable") + thvCmd("run", "--name", serverName, "--env", "FOO=bar", upgradeRegistryServerName).ExpectSuccess() + + By("Waiting for the server to be running") + waitForIsolatedMCPServer(thvCmd, serverName, 120*time.Second) + + By("Confirming the workload is registry-sourced and up to date against the older fixture") + stdout, _ := thvCmd("upgrade", "check", serverName, "--format", "json").ExpectSuccess() + var before upgradeCheckResults + Expect(json.Unmarshal([]byte(stdout), &before)).To(Succeed(), "Output should be valid JSON") + Expect(before).To(HaveLen(1)) + Expect(before[0].RegistryServer).To(Equal(upgradeRegistryServerName), + "Running by registry name should record the registry server name") + Expect(before[0].CurrentImage).To(Equal(osvImageLow), + "The workload should be running the lower-tag image") + Expect(before[0].Status).To(Equal(upgrade.StatusUpToDate), + "No upgrade should be available against the older-tag fixture") + + By("Repointing thv at the registry fixture advertising the newer tag") + thvCmd("config", "set-registry", fixtureHigh).ExpectSuccess() + + By("Checking that an upgrade is now available") + stdout, _ = thvCmd("upgrade", "check", serverName, "--format", "json").ExpectSuccess() + var avail upgradeCheckResults + Expect(json.Unmarshal([]byte(stdout), &avail)).To(Succeed(), "Output should be valid JSON") + Expect(avail).To(HaveLen(1)) + Expect(avail[0].Status).To(Equal(upgrade.StatusUpgradeAvailable), + "An upgrade should be available after repointing to the newer tag") + Expect(avail[0].CandidateImage).To(Equal(osvImageHigh), + "The candidate image should carry the newer tag") + + By("Applying the upgrade non-interactively") + thvCmd("upgrade", "apply", serverName, "--yes").ExpectSuccess() + + By("Waiting for the upgraded workload to be running again") + waitForIsolatedMCPServer(thvCmd, serverName, 120*time.Second) + + By("Verifying the recorded image carries the newer tag and config is preserved") + exportPath := filepath.Join(tempDir, "upgraded-export.json") + thvCmd("export", serverName, exportPath).ExpectSuccess() + + fileContent, err := os.ReadFile(exportPath) + Expect(err).ToNot(HaveOccurred()) + + var runConfig runner.RunConfig + Expect(json.Unmarshal(fileContent, &runConfig)).To(Succeed(), "Export should be valid JSON") + Expect(runConfig.Image).To(Equal(osvImageHigh), + "The upgraded workload should record the newer image tag") + Expect(runConfig.EnvVars).To(HaveKeyWithValue("FOO", "bar"), + "The upgrade should preserve the workload's environment variables") + }) + }) +}) + +// waitForIsolatedMCPServer polls `thv list` (through the supplied isolated-env +// command builder) until the named workload reports running, or fails the spec +// on timeout. It mirrors e2e.WaitForMCPServer but runs every poll under the same +// isolated config/home/data env as the rest of the spec, so it observes the +// workload created in that isolated state rather than the real ToolHive config. +func waitForIsolatedMCPServer(thvCmd func(args ...string) *e2e.THVCommand, serverName string, timeout time.Duration) { + GinkgoHelper() + Eventually(func() bool { + stdout, _, err := thvCmd("list").Run() + if err != nil { + return false + } + return strings.Contains(stdout, serverName) && strings.Contains(stdout, "running") + }, timeout, 1*time.Second).Should(BeTrue(), + "workload %q should be running within %s", serverName, timeout) +} diff --git a/test/e2e/vmcp_cli_features_test.go b/test/e2e/vmcp_cli_features_test.go index 5351eeca4d..ea0c2348c7 100644 --- a/test/e2e/vmcp_cli_features_test.go +++ b/test/e2e/vmcp_cli_features_test.go @@ -61,10 +61,10 @@ type singleBackendFixture struct { tmpDir string // empty when withTmpDir is false } -func (f *singleBackendFixture) setup(groupPrefix, backendPrefix, tmpDirPattern string) { +func (f *singleBackendFixture) setup(groupPrefix, tmpDirPattern string) { f.cfg = e2e.NewTestConfig() f.groupName = e2e.GenerateUniqueServerName(groupPrefix) - f.backendName = e2e.GenerateUniqueServerName(backendPrefix) + f.backendName = e2e.GenerateUniqueServerName("yardstick") f.vMCPPort = allocateVMCPPort() if tmpDirPattern != "" { @@ -81,6 +81,9 @@ func (f *singleBackendFixture) setup(groupPrefix, backendPrefix, tmpDirPattern s } func (f *singleBackendFixture) teardown() { + if f.cfg == nil { + return + } stopVMCPProcess(f.vMCPCmd) if f.cfg.CleanupAfter { if err := e2e.StopAndRemoveMCPServer(f.cfg, f.backendName); err != nil { @@ -131,6 +134,9 @@ func (f *twoBackendFixture) setupBackends(groupPrefix string) { // teardownBackends stops and removes the group and both backends. Call in // AfterAll to match setupBackends. func (f *twoBackendFixture) teardownBackends() { + if f.cfg == nil { + return + } if f.cfg.CleanupAfter { if err := e2e.StopAndRemoveMCPServer(f.cfg, f.backendAName); err != nil { GinkgoWriter.Printf("cleanup: StopAndRemoveMCPServer(%s) failed: %v\n", f.backendAName, err) @@ -305,7 +311,7 @@ var _ = Describe("vMCP CLI features", Label("vmcp", "e2e", "features"), func() { Context("global ExcludeAll (all tools hidden)", func() { var fx singleBackendFixture - BeforeEach(func() { fx.setup("vmcp-feat-excludeall", "yardstick", "vmcp-feat-excludeall-*") }) + BeforeEach(func() { fx.setup("vmcp-feat-excludeall", "vmcp-feat-excludeall-*") }) AfterEach(func() { fx.teardown() }) It("returns an empty tools list when ExcludeAllTools is true", func() { @@ -351,7 +357,7 @@ var _ = Describe("vMCP CLI features", Label("vmcp", "e2e", "features"), func() { Context("composite sequential tool", func() { var fx singleBackendFixture - BeforeEach(func() { fx.setup("vmcp-feat-composite", "yardstick", "vmcp-feat-composite-*") }) + BeforeEach(func() { fx.setup("vmcp-feat-composite", "vmcp-feat-composite-*") }) AfterEach(func() { fx.teardown() }) It("exposes the composite tool in tools/list and executes it successfully", func() { @@ -452,7 +458,7 @@ var _ = Describe("vMCP CLI features", Label("vmcp", "e2e", "features"), func() { Context("Tier-1 optimizer (--optimizer flag, quick mode)", func() { var fx singleBackendFixture - BeforeEach(func() { fx.setup("vmcp-feat-optimizer", "yardstick", "") }) + BeforeEach(func() { fx.setup("vmcp-feat-optimizer", "") }) AfterEach(func() { fx.teardown() }) It("exposes only find_tool and call_tool when --optimizer is set", func() { diff --git a/test/e2e/vmcp_cli_helpers_test.go b/test/e2e/vmcp_cli_helpers_test.go index 4d5bbcc41a..daf08aebdf 100644 --- a/test/e2e/vmcp_cli_helpers_test.go +++ b/test/e2e/vmcp_cli_helpers_test.go @@ -18,6 +18,12 @@ import ( "github.com/stacklok/toolhive/test/e2e/images" ) +// vmcpEndpointURL returns the MCP endpoint URL for a vMCP serve process +// listening on the given port. +func vmcpEndpointURL(port int) string { + return fmt.Sprintf("http://127.0.0.1:%d/mcp", port) +} + // allocateVMCPPort returns a free TCP port on 127.0.0.1 for use by thv vmcp serve. func allocateVMCPPort() int { l, err := net.Listen("tcp", "127.0.0.1:0") @@ -31,7 +37,7 @@ func allocateVMCPPort() int { // prevent the test suite from hanging. Safe to call on a nil cmd or on a cmd // whose process has already exited. func stopVMCPProcess(cmd *exec.Cmd) { - if cmd == nil || cmd.Process == nil { + if cmd == nil || cmd.Process == nil || cmd.ProcessState != nil { return } _ = cmd.Process.Signal(syscall.SIGINT) diff --git a/test/e2e/vmcp_infra_features_test.go b/test/e2e/vmcp_infra_features_test.go new file mode 100644 index 0000000000..3e5c7415b1 --- /dev/null +++ b/test/e2e/vmcp_infra_features_test.go @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package e2e_test contains infrastructure-heavy vMCP CLI e2e tests that require +// external services (OIDC server, Redis) as test fixtures. +// These complement the basic feature tests in vmcp_cli_features_test.go. +package e2e_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/test/e2e" + "github.com/stacklok/toolhive/test/e2e/images" +) + +// fetchClientCredentialsToken obtains an access token from the mock OIDC server +// using the client_credentials grant. The token is suitable for use as a Bearer +// token in Authorization headers when the vMCP server has OIDC incoming auth +// configured with the same issuer. +func fetchClientCredentialsToken(oidcPort int, clientID, clientSecret, audience string) string { + tokenURL := fmt.Sprintf("http://localhost:%d/token", oidcPort) + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "scope": {"openid"}, + "audience": {audience}, + } + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.PostForm(tokenURL, form) //nolint:gosec // URL is test-controlled + Expect(err).ToNot(HaveOccurred(), "should POST to OIDC token endpoint") + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK), + "token endpoint should return 200; body: %s", body) + var result map[string]any + Expect(json.Unmarshal(body, &result)).To(Succeed()) + token, ok := result["access_token"].(string) + Expect(ok).To(BeTrue(), "token response should contain access_token; body: %s", body) + return token +} + +// startRedisContainer starts a Redis container on the given host port with the +// given container name. The container is started detached and removed on stop. +func startRedisContainer(containerName string, hostPort int) { + out, err := exec.Command("docker", "run", "-d", "--rm", + "--name", containerName, + "-p", fmt.Sprintf("127.0.0.1:%d:6379", hostPort), + images.RedisImage, + ).CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "should start Redis container: %s", out) +} + +// stopRedisContainer stops a running Redis container. +func stopRedisContainer(containerName string) { + _ = exec.Command("docker", "stop", containerName).Run() +} + +// waitForRedisReady polls the Redis container until it responds to PING. +func waitForRedisReady(containerName string, timeout time.Duration) { + GinkgoWriter.Printf("waiting for Redis container %q to respond to PING\n", containerName) + Eventually(func() error { + out, err := exec.Command("docker", "exec", containerName, "redis-cli", "ping").CombinedOutput() + if err != nil { + return fmt.Errorf("redis-cli ping: %w; output: %s", err, out) + } + if !strings.Contains(string(out), "PONG") { + return fmt.Errorf("unexpected ping response: %q", string(out)) + } + return nil + }, timeout, 2*time.Second).Should(Succeed(), "Redis should respond to PING") +} + +var _ = Describe("vMCP infra features", Label("vmcp", "e2e", "infra"), func() { + + // ------------------------------------------------------------------------- + // JWT/OIDC incoming auth + // Verifies that vMCP enforces OIDC token validation on incoming connections: + // - Unauthenticated MCP clients are rejected. + // - A client presenting a valid Bearer JWT can connect and list tools. + // + // Uses the OIDCMockServer from test/e2e/oidc_mock.go (Ory Fosite-backed) + // and obtains a token via the client_credentials grant. + // ------------------------------------------------------------------------- + Context("JWT/OIDC incoming auth (config-file mode)", func() { + var fx singleBackendFixture + var oidcServer *e2e.OIDCMockServer + var oidcPort int + + BeforeEach(func() { + fx.setup("vmcp-auth-oidc", "vmcp-auth-oidc-*") + + oidcPort = allocateVMCPPort() + var err error + oidcServer, err = e2e.NewOIDCMockServer(oidcPort, "test-client", "test-secret", + e2e.WithClientAudience("vmcp-e2e-test"), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(oidcServer.Start()).To(Succeed()) + discoveryURL := fmt.Sprintf("http://localhost:%d/.well-known/openid-configuration", oidcPort) + Eventually(func() error { + resp, err := (&http.Client{Timeout: 2 * time.Second}).Get(discoveryURL) //nolint:gosec // URL is test-controlled + if err != nil { + return err + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("OIDC discovery returned %d", resp.StatusCode) + } + return nil + }, 10*time.Second, 500*time.Millisecond).Should(Succeed(), + "mock OIDC server should be reachable before proceeding") + DeferCleanup(func() { + if oidcServer != nil { + _ = oidcServer.Stop() + } + }) + }) + + AfterEach(func() { fx.teardown() }) + + It("rejects unauthenticated clients and accepts clients with a valid JWT", func() { + issuer := fmt.Sprintf("http://localhost:%d", oidcPort) + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + c.IncomingAuth = &vmcpconfig.IncomingAuthConfig{ + Type: "oidc", + OIDC: &vmcpconfig.OIDCConfig{ + Issuer: issuer, + ClientID: "test-client", + Audience: "vmcp-e2e-test", + InsecureAllowHTTP: true, + JwksAllowPrivateIP: true, + }, + } + })).To(Succeed()) + + By("starting vMCP serve with OIDC incoming auth") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", fx.vMCPPort) + Expect(e2e.WaitForVMCPHealthReady(healthURL, 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + vMCPURL := vmcpEndpointURL(fx.vMCPPort) + + By("verifying unauthenticated request receives 401") + unauthResp, err := (&http.Client{Timeout: 10 * time.Second}).Post( //nolint:gosec // URL is test-controlled + vMCPURL, "application/json", strings.NewReader(`{}`)) + Expect(err).ToNot(HaveOccurred(), "POST to MCP endpoint should not fail at transport level") + _, _ = io.Copy(io.Discard, unauthResp.Body) + _ = unauthResp.Body.Close() + Expect(unauthResp.StatusCode).To(Equal(http.StatusUnauthorized), + "unauthenticated request must return 401") + + By("fetching a valid JWT from the mock OIDC server via client_credentials") + token := fetchClientCredentialsToken(oidcPort, "test-client", "test-secret", "vmcp-e2e-test") + Expect(token).ToNot(BeEmpty()) + + By("verifying authenticated MCP client can connect and list tools") + authClient, err := e2e.NewMCPClientForStreamableHTTPWithToken(fx.cfg, vMCPURL, token) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = authClient.Close() }() + Expect(authClient.Initialize(ctx)).To(Succeed(), + "Initialize with a valid Bearer token must succeed") + + tools, err := authClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty(), + "authenticated client should see backend tools") + }) + }) + + // ------------------------------------------------------------------------- + // Redis-backed session storage + // Verifies that vMCP starts and operates correctly when Redis is configured + // as the session storage backend via vmcpconfig.SessionStorageConfig. + // The test starts a Redis container as a fixture, wires its address into the + // vMCP config, and confirms that MCP connectivity and tool listing work. + // ------------------------------------------------------------------------- + Context("Redis-backed session storage (config-file mode)", func() { + var fx singleBackendFixture + var redisName string + var redisPort int + + BeforeEach(func() { + fx.setup("vmcp-redis-sessions", "vmcp-redis-*") + + redisPort = allocateVMCPPort() + redisName = e2e.GenerateUniqueServerName("e2e-redis") + startRedisContainer(redisName, redisPort) + DeferCleanup(func() { stopRedisContainer(redisName) }) + waitForRedisReady(redisName, 30*time.Second) + }) + + AfterEach(func() { fx.teardown() }) + + It("starts and serves tools correctly when Redis session storage is configured", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + c.SessionStorage = &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: fmt.Sprintf("127.0.0.1:%d", redisPort), + KeyPrefix: "e2e-test:", + } + })).To(Succeed()) + + By("starting vMCP serve with Redis session storage") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := vmcpEndpointURL(fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + By("connecting an MCP client and listing tools") + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty(), + "backend tools should be visible with Redis session storage") + }) + }) + +}) // end Describe("vMCP infra features") diff --git a/test/e2e/vmcp_optimizer_test.go b/test/e2e/vmcp_optimizer_test.go new file mode 100644 index 0000000000..e715c8413f --- /dev/null +++ b/test/e2e/vmcp_optimizer_test.go @@ -0,0 +1,324 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package e2e_test provides end-to-end tests for the vMCP optimizer tiers. +// +// vmcp_cli_features_test.go covers basic Tier-1 (FTS5 keyword optimizer, +// --optimizer flag) surface: tool exposure and find_tool query results. +// This file adds deeper coverage for RFC THV-0059 Phase 4: +// +// - Tier-1 find→call round-trip: verifies that find_tool locates the yardstick +// echo tool by description and call_tool invokes it end-to-end. +// - Tier-1 two-backend with conflict resolution: verifies that optimizer +// discovers tools from both backends when prefix conflict resolution is active. +// - Tier-1 composite + optimizer: verifies that composite tools are indexed by +// the optimizer and callable through call_tool. +// +// Tier-2 (TEI semantic optimizer) behaviour is covered by the unit tests in +// pkg/vmcp/cli/embedding_manager_test.go, which exercise container lifecycle, +// health polling, reuse, and error paths via mocks without requiring a running +// Docker daemon or a large model image. +package e2e_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + thvjson "github.com/stacklok/toolhive/pkg/json" + vmcp "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/test/e2e" +) + +// toolNames returns the Name field of each tool in order. +func toolNames(tools []mcp.Tool) []string { + names := make([]string, len(tools)) + for i, t := range tools { + names[i] = t.Name + } + return names +} + +// findToolNames parses the StructuredContent of a find_tool result and returns +// the names of all returned tools. Returns nil when the content is absent or +// has an unexpected shape. +func findToolNames(result *mcp.CallToolResult) []string { + content, ok := result.StructuredContent.(map[string]any) + if !ok { + return nil + } + tools, ok := content["tools"].([]any) + if !ok { + return nil + } + names := make([]string, 0, len(tools)) + for _, t := range tools { + if tool, ok := t.(map[string]any); ok { + if name, ok := tool["name"].(string); ok { + names = append(names, name) + } + } + } + return names +} + +// firstToolNameContaining returns the first tool name from a find_tool result +// that contains the given substring, or "" if none is found. +func firstToolNameContaining(result *mcp.CallToolResult, substring string) string { + for _, name := range findToolNames(result) { + if strings.Contains(name, substring) { + return name + } + } + return "" +} + +var _ = Describe("vMCP optimizer", Label("vmcp", "e2e", "optimizer"), func() { + + // ------------------------------------------------------------------------- + // Tier-1 find→call round-trip + // Verifies that find_tool locates the yardstick echo tool by description + // and that call_tool successfully invokes it, returning the echoed input. + // ------------------------------------------------------------------------- + Context("Tier-1 optimizer find→call round-trip (single backend, quick mode)", func() { + var fx singleBackendFixture + + BeforeEach(func() { fx.setup("vmcp-opt-roundtrip", "") }) + AfterEach(func() { fx.teardown() }) + + It("find_tool locates the echo tool and call_tool invokes it end-to-end", func() { + By("starting thv vmcp serve with --optimizer") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--group", fx.groupName, + "--optimizer", + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := vmcpEndpointURL(fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + By("calling find_tool to locate the echo tool by description") + findResult, err := mcpClient.CallTool(ctx, "find_tool", map[string]any{ + "tool_description": "echo a message back", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(findResult.IsError).To(BeFalse(), "find_tool must not return an error") + + echoToolName := firstToolNameContaining(findResult, "echo") + Expect(echoToolName).ToNot(BeEmpty(), + "find_tool must return a tool matching 'echo'; structured content: %v", + findResult.StructuredContent) + + By(fmt.Sprintf("invoking %s via call_tool with a test message", echoToolName)) + callResult, err := mcpClient.CallTool(ctx, "call_tool", map[string]any{ + "tool_name": echoToolName, + "parameters": map[string]any{"input": "hellooptimizer"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(callResult.IsError).To(BeFalse(), "call_tool must not return an error") + Expect(callResult.Content).ToNot(BeEmpty(), "call_tool must return content") + Expect(mcp.GetTextFromContent(callResult.Content[0])).To(ContainSubstring("hellooptimizer"), + "echo tool must return the input message") + }) + }) + + // ------------------------------------------------------------------------- + // Tier-1 two-backend with prefix conflict resolution + optimizer + // Two yardstick backends both expose "echo". With prefix conflict + // resolution both tools are indexed; find_tool must discover at least one. + // call_tool must invoke the discovered tool successfully. + // ------------------------------------------------------------------------- + Context("Tier-1 optimizer two-backend with prefix conflict resolution", Ordered, func() { + var fx twoBackendFixture + + BeforeAll(func() { fx.setupBackends("vmcp-opt-multi") }) + AfterAll(func() { fx.teardownBackends() }) + BeforeEach(func() { fx.setupPerTest("vmcp-opt-multi-*") }) + AfterEach(func() { fx.teardownPerTest() }) + + It("find_tool discovers tools from both backends and call_tool invokes one", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + c.Aggregation.ConflictResolutionConfig = &vmcpconfig.ConflictResolutionConfig{ + PrefixFormat: "{workload}_", + } + c.Optimizer = &vmcpconfig.OptimizerConfig{} + })).To(Succeed()) + + By("starting vMCP serve with prefix conflict resolution and optimizer") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := vmcpEndpointURL(fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + By("verifying only find_tool and call_tool are exposed") + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(toolNames(tools.Tools)).To(ConsistOf("find_tool", "call_tool")) + + // With prefix resolution, each backend's echo tool is named + // "_echo". Query each backend's prefixed name directly + // to confirm both are indexed independently. + By("verifying backend A's prefixed echo tool is discoverable via find_tool") + findA, err := mcpClient.CallTool(ctx, "find_tool", map[string]any{ + "tool_description": fx.backendAName + " echo", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(findA.IsError).To(BeFalse()) + Expect(findToolNames(findA)).To(ContainElement(ContainSubstring(fx.backendAName)), + "find_tool must return backend A's prefixed echo tool; got: %v", findToolNames(findA)) + + By("verifying backend B's prefixed echo tool is discoverable via find_tool") + findB, err := mcpClient.CallTool(ctx, "find_tool", map[string]any{ + "tool_description": fx.backendBName + " echo", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(findB.IsError).To(BeFalse()) + Expect(findToolNames(findB)).To(ContainElement(ContainSubstring(fx.backendBName)), + "find_tool must return backend B's prefixed echo tool; got: %v", findToolNames(findB)) + + By("invoking a discovered echo tool via call_tool") + echoToolName := firstToolNameContaining(findA, "echo") + Expect(echoToolName).ToNot(BeEmpty()) + + callResult, err := mcpClient.CallTool(ctx, "call_tool", map[string]any{ + "tool_name": echoToolName, + "parameters": map[string]any{"input": "multibackend"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(callResult.IsError).To(BeFalse(), "call_tool must not return an error") + Expect(callResult.Content).ToNot(BeEmpty(), "call_tool must return content") + Expect(mcp.GetTextFromContent(callResult.Content[0])).To(ContainSubstring("multibackend")) + }) + }) + + // ------------------------------------------------------------------------- + // Tier-1 composite tool + optimizer (config-file mode) + // Registers an echo_twice composite tool alongside optimizer. Verifies that + // find_tool indexes it and call_tool executes the workflow end-to-end. + // ------------------------------------------------------------------------- + Context("Tier-1 optimizer with composite tool (config-file mode)", func() { + var fx singleBackendFixture + + BeforeEach(func() { fx.setup("vmcp-opt-composite", "vmcp-opt-composite-*") }) + AfterEach(func() { fx.teardown() }) + + It("find_tool discovers the composite tool and call_tool executes it", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + c.Optimizer = &vmcpconfig.OptimizerConfig{} + c.CompositeTools = []vmcpconfig.CompositeToolConfig{ + { + Name: "echo_twice", + Description: "Echoes the input message twice in sequence", + Parameters: thvjson.NewMap(map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "The message to echo twice", + }, + }, + "required": []any{"message"}, + }), + Steps: []vmcpconfig.WorkflowStepConfig{ + { + ID: "first_echo", + Type: "tool", + Tool: fmt.Sprintf("%s.echo", fx.backendName), + Arguments: thvjson.NewMap(map[string]any{ + "input": "{{ .params.message }}", + }), + }, + { + ID: "second_echo", + Type: "tool", + Tool: fmt.Sprintf("%s.echo", fx.backendName), + DependsOn: []string{"first_echo"}, + Arguments: thvjson.NewMap(map[string]any{ + "input": "{{ .params.message }}", + }), + }, + }, + }, + } + })).To(Succeed()) + + By("starting vMCP serve with composite tool and optimizer") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := vmcpEndpointURL(fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + By("verifying only find_tool and call_tool are exposed") + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(toolNames(tools.Tools)).To(ConsistOf("find_tool", "call_tool")) + + By("discovering the composite tool via find_tool") + findResult, err := mcpClient.CallTool(ctx, "find_tool", map[string]any{ + "tool_description": "echo a message twice in sequence", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(findResult.IsError).To(BeFalse()) + Expect(findToolNames(findResult)).To(ContainElement(ContainSubstring("echo_twice")), + "find_tool must discover the composite tool; got: %v", findResult.StructuredContent) + + By("invoking echo_twice via call_tool and verifying the result") + callResult, err := mcpClient.CallTool(ctx, "call_tool", map[string]any{ + "tool_name": "echo_twice", + "parameters": map[string]any{"message": "hellocomposite"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(callResult.IsError).To(BeFalse(), "call_tool must not return an error for composite tool") + Expect(callResult.Content).ToNot(BeEmpty(), "call_tool must return content from composite tool") + }) + }) + +}) // end Describe("vMCP optimizer") diff --git a/test/integration/authserver/authserver_integration_test.go b/test/integration/authserver/authserver_integration_test.go index 90fd677385..2fd8e22ef2 100644 --- a/test/integration/authserver/authserver_integration_test.go +++ b/test/integration/authserver/authserver_integration_test.go @@ -9,7 +9,9 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/x509" + "encoding/json" "encoding/pem" + "io" "net/http" "net/http/httptest" "net/url" @@ -466,6 +468,147 @@ func TestEmbeddedAuthServer_ResourceCleanup(t *testing.T) { require.NoError(t, err) } +// TestEmbeddedAuthServer_BaselineClientScopes_RegressionForDCRScopeNarrowing +// is a regression test for the Claude Code DCR scope-narrowing bug +// (anthropics/claude-code#4540). Claude Code registers with a narrowed scope +// (e.g. "openid") but later requests the full set at /oauth/authorize. +// The fix unions BaselineClientScopes into every DCR registration so the +// client's registered set always includes the operator-configured baseline, +// preventing fosite from rejecting the subsequent authorize request with +// invalid_scope. +// +//nolint:paralleltest,tparallel // Subtests intentionally sequential - second reuses first's client_id +func TestEmbeddedAuthServer_BaselineClientScopes_RegressionForDCRScopeNarrowing(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + upstream := helpers.NewMockUpstreamIDP(t) + + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL(), + helpers.WithScopesSupported([]string{"openid", "offline_access"}), + helpers.WithBaselineClientScopes([]string{"offline_access"}), + ) + + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + + server := httptest.NewServer(authServer.Handler()) + t.Cleanup(server.Close) + + client := helpers.NewOAuthClient(server.URL) + + var clientID string + + t.Run("DCR response echoes the baseline-augmented scope set", func(t *testing.T) { + clientMetadata := map[string]interface{}{ + "client_name": "Claude Code", + "redirect_uris": []string{"http://localhost:8080/callback"}, + "grant_types": []string{"authorization_code", "refresh_token"}, + // Narrow scope — the bug-trigger pattern from Claude Code + "scope": "openid", + } + + result, statusCode, err := client.RegisterClient(clientMetadata) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, statusCode, "DCR registration should succeed") + + clientID = result["client_id"].(string) + require.NotEmpty(t, clientID) + + // The registered scope set must include "offline_access" from the baseline + // even though the client only requested "openid". + registeredScope, ok := result["scope"].(string) + require.True(t, ok, "scope field should be a string in DCR response") + // Order: requested scopes first, then non-overlapping baseline (unionScopes contract). + assert.Equal(t, "openid offline_access", registeredScope, + "DCR response scope must be the union of requested+baseline scopes") + }) + + t.Run("authorize accepts a request for the unioned scope set", func(t *testing.T) { + // Pre-fix: fosite would reject this with invalid_scope because the + // registered client only had "openid" in its scope set. Post-fix: the + // client has "openid offline_access" so the authorize request succeeds. + params := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {"http://localhost:8080/callback"}, + "scope": {"openid offline_access"}, + "state": {"test-state-baseline-regression"}, + "resource": {cfg.AllowedAudiences[0]}, + } + + resp, err := client.StartAuthorization(params) + require.NoError(t, err) + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + + // Must redirect to upstream — NOT a 400 invalid_scope. + assert.Equal(t, http.StatusFound, resp.StatusCode, + "authorize must accept the full scope set that includes the baseline; pre-fix this returned 400 invalid_scope") + + location := resp.Header.Get("Location") + assert.NotEmpty(t, location) + + redirectURL, err := url.Parse(location) + require.NoError(t, err) + assert.Contains(t, redirectURL.String(), upstream.URL()) + }) + + t.Run("authorize rejects a scope not in scopes_supported", func(t *testing.T) { + // Negative case: even with the baseline expansion, scopes that aren't + // in ScopesSupported must still be rejected. This guards against silent + // privilege escalation if BaselineClientScopes ever drifts. + params := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {"http://localhost:8080/callback"}, + "scope": {"openid offline_access admin:read"}, + "state": {"test-state-baseline-negative"}, + "resource": {cfg.AllowedAudiences[0]}, + } + + resp, err := client.StartAuthorization(params) + require.NoError(t, err) + // Read the body up front: the 400 branch below needs to parse it, + // and reading once is simpler than teeing around a deferred drain. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Fosite rejects with invalid_scope. Depending on whether the redirect URI + // has been validated by that point, this surfaces either as a 400 with + // a JSON body or as a redirect (3xx) to redirect_uri with + // error=invalid_scope in the query. Both must carry `invalid_scope` — + // asserting on the error code in both branches guards against a future + // fosite upgrade swapping in a different code (e.g. server_error) + // while the privilege-escalation regression silently slips through. + if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { + // Redirect-with-error case — verify it points to the registered + // redirect_uri (loopback), NOT the upstream. + location := resp.Header.Get("Location") + require.NotEmpty(t, location) + assert.NotContains(t, location, upstream.URL(), + "rejected request must NOT redirect to the upstream IDP") + redirectURL, err := url.Parse(location) + require.NoError(t, err) + assert.Contains(t, redirectURL.RawQuery, "invalid_scope", + "rejection error must be invalid_scope") + } else { + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "unsupported scope must produce 400 invalid_scope (or redirect-with-error)") + var errResp struct { + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &errResp), + "400 response body must be JSON with an error field") + assert.Equal(t, "invalid_scope", errResp.Error, + "400 rejection must carry the invalid_scope error code, not a different one") + } + }) +} + // generateTestECKey generates a test EC private key for signing. func generateTestECKey(t *testing.T) []byte { t.Helper() diff --git a/test/integration/authserver/helpers/authserver.go b/test/integration/authserver/helpers/authserver.go index ec18868660..f4939f445f 100644 --- a/test/integration/authserver/helpers/authserver.go +++ b/test/integration/authserver/helpers/authserver.go @@ -21,34 +21,14 @@ type AuthServerOption func(*authServerConfig) // authServerConfig holds configuration for creating a test auth server. type authServerConfig struct { - issuer string - upstreams []authserver.UpstreamRunConfig - allowedAudiences []string - signingKeyConfig *authserver.SigningKeyRunConfig - hmacSecretFiles []string - tokenLifespans *authserver.TokenLifespanRunConfig - scopesSupported []string -} - -// WithIssuer sets the issuer URL. -func WithIssuer(issuer string) AuthServerOption { - return func(c *authServerConfig) { - c.issuer = issuer - } -} - -// WithUpstreams sets the upstream IDP configurations. -func WithUpstreams(upstreams []authserver.UpstreamRunConfig) AuthServerOption { - return func(c *authServerConfig) { - c.upstreams = upstreams - } -} - -// WithAllowedAudiences sets the allowed resource audiences. -func WithAllowedAudiences(audiences []string) AuthServerOption { - return func(c *authServerConfig) { - c.allowedAudiences = audiences - } + issuer string + upstreams []authserver.UpstreamRunConfig + allowedAudiences []string + signingKeyConfig *authserver.SigningKeyRunConfig + hmacSecretFiles []string + tokenLifespans *authserver.TokenLifespanRunConfig + scopesSupported []string + baselineClientScopes []string } // WithSigningKey sets the signing key configuration. @@ -58,24 +38,18 @@ func WithSigningKey(cfg *authserver.SigningKeyRunConfig) AuthServerOption { } } -// WithHMACSecrets sets the HMAC secret file paths. -func WithHMACSecrets(files []string) AuthServerOption { - return func(c *authServerConfig) { - c.hmacSecretFiles = files - } -} - -// WithTokenLifespans sets the token lifespan configuration. -func WithTokenLifespans(cfg *authserver.TokenLifespanRunConfig) AuthServerOption { +// WithScopesSupported sets the supported scopes. +func WithScopesSupported(scopes []string) AuthServerOption { return func(c *authServerConfig) { - c.tokenLifespans = cfg + c.scopesSupported = scopes } } -// WithScopesSupported sets the supported scopes. -func WithScopesSupported(scopes []string) AuthServerOption { +// WithBaselineClientScopes sets the baseline client scopes that are unioned +// into every DCR registration response regardless of what the client requested. +func WithBaselineClientScopes(scopes []string) AuthServerOption { return func(c *authServerConfig) { - c.scopesSupported = scopes + c.baselineClientScopes = scopes } } @@ -130,14 +104,15 @@ func NewTestAuthServerConfig(tb testing.TB, upstreamURL string, opts ...AuthServ } return &authserver.RunConfig{ - SchemaVersion: authserver.CurrentSchemaVersion, - Issuer: cfg.issuer, - SigningKeyConfig: cfg.signingKeyConfig, - HMACSecretFiles: cfg.hmacSecretFiles, - TokenLifespans: cfg.tokenLifespans, - Upstreams: cfg.upstreams, - ScopesSupported: cfg.scopesSupported, - AllowedAudiences: cfg.allowedAudiences, + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: cfg.issuer, + SigningKeyConfig: cfg.signingKeyConfig, + HMACSecretFiles: cfg.hmacSecretFiles, + TokenLifespans: cfg.tokenLifespans, + Upstreams: cfg.upstreams, + ScopesSupported: cfg.scopesSupported, + BaselineClientScopes: cfg.baselineClientScopes, + AllowedAudiences: cfg.allowedAudiences, } } diff --git a/test/integration/authserver/helpers/http_client.go b/test/integration/authserver/helpers/http_client.go index 53eb674870..550be402e1 100644 --- a/test/integration/authserver/helpers/http_client.go +++ b/test/integration/authserver/helpers/http_client.go @@ -174,8 +174,3 @@ func (c *OAuthClient) RegisterClient(clientMetadata map[string]interface{}) (map return result, resp.StatusCode, nil } - -// Get performs a GET request to the specified path. -func (c *OAuthClient) Get(path string) (*http.Response, error) { - return c.httpClient.Get(c.baseURL + path) -} diff --git a/test/integration/authserver/helpers/mock_upstream.go b/test/integration/authserver/helpers/mock_upstream.go index 9c88da2c89..825a83e32d 100644 --- a/test/integration/authserver/helpers/mock_upstream.go +++ b/test/integration/authserver/helpers/mock_upstream.go @@ -25,27 +25,6 @@ type MockUpstreamIDP struct { // MockUpstreamOption is a functional option for configuring the mock upstream. type MockUpstreamOption func(*MockUpstreamIDP) -// WithAuthorizeHandler sets a custom authorization endpoint handler. -func WithAuthorizeHandler(h func(w http.ResponseWriter, r *http.Request)) MockUpstreamOption { - return func(m *MockUpstreamIDP) { - m.AuthorizeHandler = h - } -} - -// WithTokenHandler sets a custom token endpoint handler. -func WithTokenHandler(h func(w http.ResponseWriter, r *http.Request)) MockUpstreamOption { - return func(m *MockUpstreamIDP) { - m.TokenHandler = h - } -} - -// WithUserInfoHandler sets a custom userinfo endpoint handler. -func WithUserInfoHandler(h func(w http.ResponseWriter, r *http.Request)) MockUpstreamOption { - return func(m *MockUpstreamIDP) { - m.UserInfoHandler = h - } -} - // NewMockUpstreamIDP creates a mock upstream IDP for testing. // The server is automatically started and will be ready when this function returns. func NewMockUpstreamIDP(tb testing.TB, opts ...MockUpstreamOption) *MockUpstreamIDP {