Skip to content

Wire non-interactive registry auth for serve mode#4111

Open
ChrisJBurns wants to merge 4 commits intomainfrom
feat/registry-auth-serve-mode
Open

Wire non-interactive registry auth for serve mode#4111
ChrisJBurns wants to merge 4 commits intomainfrom
feat/registry-auth-serve-mode

Conversation

@ChrisJBurns
Copy link
Collaborator

@ChrisJBurns ChrisJBurns commented Mar 11, 2026

Summary

  • Why: When thv serve runs with OAuth-authenticated registries, it must never open a browser for OAuth flows — it's a headless API server. Desktop clients (ToolHive Studio) need: (1) machine-readable auth status to show the correct UI state, (2) structured error responses when auth is missing so they can prompt the user to authenticate, and (3) API endpoints to trigger login/logout without needing a terminal.

  • What: Adds serve-mode registry auth support to the thv serve API:

    • Non-interactive provider: WithInteractive(false) suppresses browser OAuth flows in serve mode
    • Auth status in responses: auth_status (none / configured / authenticated) and auth_type fields in all registry GET responses
    • Structured 503 errors: JSON {"code": "registry_auth_required", "message": "..."} when tokens are missing, instead of generic 500s
    • Typed HTTP errors: RegistryHTTPError with Unwrap() for 401/403 detection from upstream registries, with LimitReader on error bodies
    • Auth error propagation: 401s from the upstream registry are wrapped as ErrRegistryAuthRequired and surfaced as 503s to clients
    • API auth endpoints (serve mode only):
      • POST /api/v1beta/registry/auth/login — triggers interactive OAuth flow (desktop-only, opens browser)
      • POST /api/v1beta/registry/auth/logout — clears cached OAuth tokens and registry cache
    • OAuth config via API: PUT /api/v1beta/registry/default now accepts an optional auth object with issuer, client_id, audience, and scopes fields for configuring registry OAuth
    • AuthManager extension: GetAuthStatus() method consolidates auth state inspection for API responses

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

Changes

File Change
pkg/registry/api/client.go Add ErrRegistryUnauthorized, RegistryHTTPError type with Unwrap() for 401/403. Replace fmt.Errorf with typed errors and LimitReader in GetServer, fetchServersPage, SearchServers.
pkg/registry/factory.go Add ProviderOption / WithInteractive(), thread opts through GetDefaultProviderWithConfig and NewRegistryProvider.
pkg/registry/provider_api.go Wrap 401 from validation probe and GetRegistry() with ErrRegistryAuthRequired for structured 503 responses.
pkg/registry/auth_manager.go Add GetAuthStatus() method and AuthStatusNone / AuthStatusConfigured / AuthStatusAuthenticated constants.
pkg/registry/auth_manager_test.go Table-driven tests for GetAuthStatus().
pkg/api/v1/registry.go Serve-mode support: RegistryRoutes.serveMode, NewRegistryRoutesForServe(), RegistryRouter(serveMode bool). Structured 503 errors: writeRegistryAuthRequiredError(), isRegistryAuthError(). Auth status: resolveAuthStatus(), auth_status/auth_type fields in registryInfo and getRegistryResponse. API endpoints: POST /auth/login, POST /auth/logout. OAuth config: processAuthUpdate(), UpdateRegistryAuthRequest. Auth error handling in listRegistries, getRegistry, listServers, updateRegistry.
pkg/api/server.go Pass serveMode: true to RegistryRouter().
docs/server/* Regenerated swagger docs.

Does this introduce a user-facing change?

Yes.

  • Registry API responses (GET /api/v1beta/registry and GET /api/v1beta/registry/default) now include auth_status and auth_type fields.
  • When thv serve cannot authenticate to a registry (no cached tokens), registry endpoints return HTTP 503 with {"code": "registry_auth_required"} instead of a generic 500.
  • New POST /api/v1beta/registry/auth/login and POST /api/v1beta/registry/auth/logout endpoints (serve mode only) allow desktop clients to trigger OAuth login/logout via the API.
  • PUT /api/v1beta/registry/default now accepts an auth object to configure OAuth settings via the API.

Special notes for reviewers

  • 503 not 401: HTTP 503 is intentional per RFC-0043. Studio is authenticated to the thv serve API — the problem is that thv serve itself lacks a registry credential. 503 correctly signals a server-side dependency issue, not a client auth failure.
  • auth_status limitations: Cannot detect "expired" from config alone (would require calling the token source). We return "authenticated" when a cached token ref exists and rely on Studio handling subsequent 401s as a signal to re-login.
  • Browser-open from login endpoint: The /auth/login endpoint opens the user's default browser for the OAuth flow. This is intentional for desktop-only serve mode (Studio running thv serve locally). A follow-up could return a redirect URL instead for remote/container environments.
  • No omitempty on auth fields: auth_status and auth_type intentionally omit omitempty so clients always receive the fields, even when the value is "none" or empty string.

Generated with Claude Code

@github-actions github-actions bot added the size/M Medium PR: 300-599 lines changed label Mar 11, 2026
@ChrisJBurns ChrisJBurns changed the title Wire non-interactive registry auth for serve mode DRAFT: Wire non-interactive registry auth for serve mode Mar 11, 2026
@codecov
Copy link

codecov bot commented Mar 11, 2026

Codecov Report

❌ Patch coverage is 14.89362% with 120 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.63%. Comparing base (16b1238) to head (0a3c103).

Files with missing lines Patch % Lines
pkg/api/v1/registry.go 10.83% 103 Missing and 4 partials ⚠️
pkg/registry/provider_api.go 0.00% 11 Missing ⚠️
pkg/api/server.go 0.00% 1 Missing ⚠️
pkg/registry/factory.go 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4111      +/-   ##
==========================================
- Coverage   68.85%   68.63%   -0.22%     
==========================================
  Files         467      467              
  Lines       46983    47123     +140     
==========================================
- Hits        32348    32345       -3     
- Misses      11977    12097     +120     
- Partials     2658     2681      +23     

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

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

@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed size/S Small PR: 100-299 lines changed and removed size/M Medium PR: 300-599 lines changed size/S Small PR: 100-299 lines changed labels Mar 11, 2026
@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed size/L Large PR: 600-999 lines changed and removed size/S Small PR: 100-299 lines changed size/M Medium PR: 300-599 lines changed labels Mar 11, 2026
Copy link
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

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

Automated review from Claude Code. 14 findings total (3 HIGH, 6 MEDIUM, 5 LOW/INFO). 4 HIGH/MEDIUM findings are pre-existing from PR #3908 and should be tracked separately. 6 inline comments below on code introduced in this PR.

Pre-existing issues to track separately (from PR #3908):

  • HIGH: Refresh token rotation not persisted on cache restore path (oauth_token_source.go:94)
  • HIGH: Data race between ResetDefaultProvider and GetDefaultProviderWithConfig (factory.go)
  • MEDIUM: updateConfigTokenRef bypasses injected config provider (oauth_token_source.go:213)
  • MEDIUM: Panic on empty baseURL in api.NewClient (client.go:111)

MEDIUM (not inline — outside diff hunk): resolveTokenSource in factory.go:144 passes cfg.RegistryApiUrl directly, but auth.Login uses registryURLFromConfig(cfg) which falls back to cfg.RegistryUrl. Since the URL feeds DeriveSecretKey, a mismatch means Login could store the token under a different key than factory looks up.

🤖 Generated with Claude Code

if cfg.RegistryAuth.OAuth == nil ||
cfg.RegistryAuth.OAuth.CachedRefreshTokenRef == "" {
return authStatusConfigured, "oauth"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

[DESIGN QUESTION] Browser-open from server process (MEDIUM)

This endpoint calls auth.Login(...) with interactive=true, triggering performOAuthFlow which opens the user's default browser and starts a local callback listener.

Is this intentional for desktop-only serve mode (Studio running thv serve locally)? In headless/remote/container environments this will fail or hang.

Would it be better to return a redirect URL in the JSON response that the client (Studio) opens itself, rather than having the server process open the browser? That would also work behind reverse proxies where localhost:PORT/callback isn't reachable from the browser.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, this is intentional for desktop-only serve mode where Studio runs thv serve locally and the user has a browser. The PR description calls this out explicitly. Returning a redirect URL is a good improvement for remote/container environments — I'll track that as a follow-up.

@ChrisJBurns ChrisJBurns force-pushed the feat/registry-auth-serve-mode branch from f8aebd1 to 4713fe7 Compare March 13, 2026 22:56
@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed and removed size/L Large PR: 600-999 lines changed size/M Medium PR: 300-599 lines changed labels Mar 13, 2026
@github-actions github-actions bot added the size/L Large PR: 600-999 lines changed label Mar 13, 2026
jhrozek
jhrozek previously approved these changes Mar 16, 2026
@jhrozek
Copy link
Contributor

jhrozek commented Mar 16, 2026

looks like swagger is unhappy but the code lgtm

@ChrisJBurns ChrisJBurns changed the title DRAFT: Wire non-interactive registry auth for serve mode Wire non-interactive registry auth for serve mode Mar 17, 2026
@ChrisJBurns ChrisJBurns force-pushed the feat/registry-auth-serve-mode branch from cf7770e to b2818fb Compare March 17, 2026 19:19
@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed and removed size/L Large PR: 600-999 lines changed size/M Medium PR: 300-599 lines changed labels Mar 17, 2026
Wire non-interactive registry auth for serve mode so that browser-based
OAuth flows are never triggered from the API server. When registry auth
is configured but tokens are missing or expired, the API returns a
structured 503 response with code "registry_auth_required" instead of
hanging on a browser redirect.

Key changes:
- Add WithInteractive(false) provider option for headless contexts
- Add GetNonInteractiveProviderWithConfig for serve mode
- Add auth status fields (auth_status, auth_type) to registry API responses
- Add POST /auth/login and POST /auth/logout API endpoints
- Add auth fields to PUT registry endpoint with offline_access scope
- Return structured 503 errors when registry auth is required
- Wrap validation probe 401 with ErrRegistryAuthRequired

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ChrisJBurns ChrisJBurns force-pushed the feat/registry-auth-serve-mode branch from 51cc2d7 to b3d9fc1 Compare March 17, 2026 19:24
@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Mar 17, 2026
Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com>
@github-actions github-actions bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Mar 17, 2026
@ChrisJBurns
Copy link
Collaborator Author

@jhrozek I might need another approval

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants