Skip to content

Commit bc9b534

Browse files
aron-muonclaude
andauthored
fix: oauth issues and add tokenResponseMapping for non-standard providers (#4009)
* Fix TOOLHIVE_DEBUG env var not enabling debug logging The logger was initialized in main.go before viper.BindEnv was called in commands.go, so TOOLHIVE_DEBUG had no effect on log level. Move the env var binding before the logger initialization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Propagate upstream user name and email into JWT claims The embedded auth server resolved user identity (name, email) from the upstream IDP via the userInfo endpoint but only stored the subject claim in the JWT. This caused audit logs to show "anonymous" for the user field despite successful authentication. Propagate name and email from the upstream Identity through to the session's JWT claims as standard OIDC claims (name, email per OIDC Core Section 5.1). The auth middleware's claimsToIdentity function already reads these claims, so the audit middleware will now display the actual user name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix remote URL path not forwarded to backend server When the remote URL has a path (e.g., https://mcp.asana.com/v2/mcp), the proxy stripped it and only used the scheme+host as the target. Client requests to /mcp were forwarded to https://mcp.asana.com/mcp instead of https://mcp.asana.com/v2/mcp, causing Asana to return 401 invalid_token because the endpoint doesn't exist at /mcp. Extract the remote URL's path and pass it to the transparent proxy via WithRemoteBasePath. The proxy's Director rewrites incoming request paths to the remote server's configured path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add tokenResponseMapping for non-standard OAuth token responses Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths instead of returning them at the top level. GovSlack returns access_token under authed_user.access_token, causing the standard oauth2 library to fail with "response missing access_token". Add a tokenResponseMapping field to OAuth2UpstreamConfig that configures dot-notation paths for extracting token fields from non-standard response formats. When set, the token exchange bypasses golang.org/x/oauth2 and makes the HTTP POST directly, extracting fields using gjson (already a dependency). Example usage: tokenResponseMapping: accessTokenPath: "authed_user.access_token" tokenTypePath: "authed_user.token_type" Changes span the full config pipeline: CRD types, RunConfig, operator conversion, runtime resolution, and the upstream provider. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CRD manifests and docs for tokenResponseMapping Run `task operator-manifests` and `crd-ref-docs` to update the CRD schema with the new tokenResponseMapping field on OAuth2UpstreamConfig and regenerate the API reference docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update image build dependencies and fix run-on-main workflow * Normalize token_type to Bearer in token response rewriter GovSlack returns token_type "user" which the oauth2 library rejects since it only accepts "Bearer". Since the rewriter already handles non-standard responses, always normalize token_type to "Bearer" so the oauth2 library accepts it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: respond to reviewer * chore: fixing debug env usage in thv cli * fix: regenerated swag docs --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ff81ee4 commit bc9b534

29 files changed

Lines changed: 1047 additions & 34 deletions

cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,40 @@ type OAuth2UpstreamConfig struct {
331331
// Scopes are the OAuth scopes to request from the upstream IDP.
332332
// +optional
333333
Scopes []string `json:"scopes,omitempty"`
334+
335+
// TokenResponseMapping configures custom field extraction from non-standard token responses.
336+
// Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
337+
// instead of returning them at the top level. When set, ToolHive performs the token
338+
// exchange HTTP call directly and extracts fields using the configured dot-notation paths.
339+
// If nil, standard OAuth 2.0 token response parsing is used.
340+
// +optional
341+
TokenResponseMapping *TokenResponseMapping `json:"tokenResponseMapping,omitempty"`
342+
}
343+
344+
// TokenResponseMapping maps non-standard token response fields to standard OAuth 2.0 fields
345+
// using dot-notation JSON paths. This supports upstream providers like GovSlack that nest
346+
// the access token under paths like "authed_user.access_token".
347+
type TokenResponseMapping struct {
348+
// AccessTokenPath is the dot-notation path to the access token in the response.
349+
// Example: "authed_user.access_token"
350+
// +kubebuilder:validation:Required
351+
// +kubebuilder:validation:MinLength=1
352+
AccessTokenPath string `json:"accessTokenPath"`
353+
354+
// ScopePath is the dot-notation path to the scope string in the response.
355+
// If not specified, defaults to "scope".
356+
// +optional
357+
ScopePath string `json:"scopePath,omitempty"`
358+
359+
// RefreshTokenPath is the dot-notation path to the refresh token in the response.
360+
// If not specified, defaults to "refresh_token".
361+
// +optional
362+
RefreshTokenPath string `json:"refreshTokenPath,omitempty"`
363+
364+
// ExpiresInPath is the dot-notation path to the expires_in value (in seconds).
365+
// If not specified, defaults to "expires_in".
366+
// +optional
367+
ExpiresInPath string `json:"expiresInPath,omitempty"`
334368
}
335369

336370
// UserInfoConfig contains configuration for fetching user information from an upstream provider.

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/pkg/controllerutil/authserver.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,15 @@ func buildUpstreamRunConfig(
519519
if provider.OAuth2Config.UserInfo != nil {
520520
config.OAuth2Config.UserInfo = buildUserInfoRunConfig(provider.OAuth2Config.UserInfo)
521521
}
522+
if provider.OAuth2Config.TokenResponseMapping != nil {
523+
m := provider.OAuth2Config.TokenResponseMapping
524+
config.OAuth2Config.TokenResponseMapping = &authserver.TokenResponseMappingRunConfig{
525+
AccessTokenPath: m.AccessTokenPath,
526+
ScopePath: m.ScopePath,
527+
RefreshTokenPath: m.RefreshTokenPath,
528+
ExpiresInPath: m.ExpiresInPath,
529+
}
530+
}
522531
}
523532
}
524533

cmd/thv-proxyrunner/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import (
1515
)
1616

1717
func main() {
18+
// Bind TOOLHIVE_DEBUG env var early, before logger initialization.
19+
// This must happen before viper.GetBool("debug") so the env var
20+
// is available when configuring the log level.
21+
if err := viper.BindEnv("debug", "TOOLHIVE_DEBUG"); err != nil {
22+
slog.Error("failed to bind TOOLHIVE_DEBUG env var", "error", err)
23+
}
24+
1825
// Initialize the logger
1926
var opts []logging.Option
2027
if viper.GetBool("debug") {

cmd/thv/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ import (
2323
)
2424

2525
func main() {
26+
// Bind TOOLHIVE_DEBUG env var early, before logger initialization.
27+
// This must happen before viper.GetBool("debug") so the env var
28+
// is available when configuring the log level.
29+
if err := viper.BindEnv("debug", "TOOLHIVE_DEBUG"); err != nil {
30+
slog.Error("failed to bind TOOLHIVE_DEBUG env var", "error", err)
31+
}
32+
2633
// Initialize the logger
2734
var opts []logging.Option
2835
if viper.GetBool("debug") {

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,38 @@ spec:
441441
token endpoint.
442442
pattern: ^https?://.*$
443443
type: string
444+
tokenResponseMapping:
445+
description: |-
446+
TokenResponseMapping configures custom field extraction from non-standard token responses.
447+
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
448+
instead of returning them at the top level. When set, ToolHive performs the token
449+
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
450+
If nil, standard OAuth 2.0 token response parsing is used.
451+
properties:
452+
accessTokenPath:
453+
description: |-
454+
AccessTokenPath is the dot-notation path to the access token in the response.
455+
Example: "authed_user.access_token"
456+
minLength: 1
457+
type: string
458+
expiresInPath:
459+
description: |-
460+
ExpiresInPath is the dot-notation path to the expires_in value (in seconds).
461+
If not specified, defaults to "expires_in".
462+
type: string
463+
refreshTokenPath:
464+
description: |-
465+
RefreshTokenPath is the dot-notation path to the refresh token in the response.
466+
If not specified, defaults to "refresh_token".
467+
type: string
468+
scopePath:
469+
description: |-
470+
ScopePath is the dot-notation path to the scope string in the response.
471+
If not specified, defaults to "scope".
472+
type: string
473+
required:
474+
- accessTokenPath
475+
type: object
444476
userInfo:
445477
description: |-
446478
UserInfo contains configuration for fetching user information from the upstream provider.

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,38 @@ spec:
444444
token endpoint.
445445
pattern: ^https?://.*$
446446
type: string
447+
tokenResponseMapping:
448+
description: |-
449+
TokenResponseMapping configures custom field extraction from non-standard token responses.
450+
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
451+
instead of returning them at the top level. When set, ToolHive performs the token
452+
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
453+
If nil, standard OAuth 2.0 token response parsing is used.
454+
properties:
455+
accessTokenPath:
456+
description: |-
457+
AccessTokenPath is the dot-notation path to the access token in the response.
458+
Example: "authed_user.access_token"
459+
minLength: 1
460+
type: string
461+
expiresInPath:
462+
description: |-
463+
ExpiresInPath is the dot-notation path to the expires_in value (in seconds).
464+
If not specified, defaults to "expires_in".
465+
type: string
466+
refreshTokenPath:
467+
description: |-
468+
RefreshTokenPath is the dot-notation path to the refresh token in the response.
469+
If not specified, defaults to "refresh_token".
470+
type: string
471+
scopePath:
472+
description: |-
473+
ScopePath is the dot-notation path to the scope string in the response.
474+
If not specified, defaults to "scope".
475+
type: string
476+
required:
477+
- accessTokenPath
478+
type: object
447479
userInfo:
448480
description: |-
449481
UserInfo contains configuration for fetching user information from the upstream provider.

docs/operator/crd-api.md

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)