@@ -26,6 +26,7 @@ import (
2626 "github.com/stacklok/toolhive-core/env"
2727 "github.com/stacklok/toolhive/pkg/auth/oauth"
2828 "github.com/stacklok/toolhive/pkg/auth/upstreamtoken"
29+ "github.com/stacklok/toolhive/pkg/authserver/server/keys"
2930 "github.com/stacklok/toolhive/pkg/networking"
3031 oauthproto "github.com/stacklok/toolhive/pkg/oauth"
3132)
@@ -372,6 +373,12 @@ type TokenValidator struct {
372373 // nil means no enrichment (no embedded auth server).
373374 upstreamTokenReader upstreamtoken.TokenReader
374375
376+ // keyProvider provides in-process JWKS key lookups from the embedded auth
377+ // server's key provider. When set, getKeyFromJWKS resolves keys locally
378+ // before falling back to HTTP. Eliminates self-referential HTTP calls.
379+ // nil when no embedded auth server is configured.
380+ keyProvider keys.PublicKeyProvider
381+
375382 // Lazy JWKS registration
376383 jwksRegistered bool
377384 jwksRegistrationMu sync.Mutex
@@ -547,6 +554,7 @@ func registerIntrospectionProviders(config TokenValidatorConfig, clientSecret st
547554type tokenValidatorOptions struct {
548555 envReader env.Reader
549556 upstreamTokenReader upstreamtoken.TokenReader
557+ keyProvider keys.PublicKeyProvider
550558}
551559
552560// TokenValidatorOption is a functional option for NewTokenValidator.
@@ -570,6 +578,31 @@ func WithUpstreamTokenReader(reader upstreamtoken.TokenReader) TokenValidatorOpt
570578 }
571579}
572580
581+ // WithKeyProvider configures the token validator to use an in-process key
582+ // provider for JWKS lookups instead of fetching keys over HTTP. This is used
583+ // when the embedded auth server's key provider is available in the same process,
584+ // eliminating self-referential HTTP calls and the need for insecureAllowHTTP
585+ // and jwksAllowPrivateIP flags.
586+ //
587+ // Only PublicKeyProvider is required — the validator never signs tokens.
588+ func WithKeyProvider (provider keys.PublicKeyProvider ) TokenValidatorOption {
589+ return func (o * tokenValidatorOptions ) {
590+ o .keyProvider = provider
591+ }
592+ }
593+
594+ // resolveClientSecret returns the client secret from the config, falling back
595+ // to the TOOLHIVE_OIDC_CLIENT_SECRET environment variable if not set.
596+ func resolveClientSecret (configSecret string , envReader env.Reader ) string {
597+ if configSecret != "" {
598+ return configSecret
599+ }
600+ if envSecret := envReader .Getenv ("TOOLHIVE_OIDC_CLIENT_SECRET" ); envSecret != "" {
601+ return envSecret
602+ }
603+ return ""
604+ }
605+
573606// NewTokenValidator creates a new token validator.
574607func NewTokenValidator (ctx context.Context , config TokenValidatorConfig , opts ... TokenValidatorOption ) (* TokenValidator , error ) {
575608 // Apply functional options
@@ -611,8 +644,9 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig, opts ..
611644 slog .Debug ("OIDC discovery deferred - will discover on first validation request" , "issuer" , config .Issuer )
612645 }
613646
614- // Ensure we have either an explicit JWKS URL or an issuer to discover from
615- if jwksURL == "" && config .Issuer == "" {
647+ // Ensure we have either an explicit JWKS URL, an issuer to discover from,
648+ // or a local key provider (embedded auth server).
649+ if jwksURL == "" && config .Issuer == "" && o .keyProvider == nil {
616650 return nil , ErrMissingIssuerAndJWKSURL
617651 }
618652
@@ -638,14 +672,8 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig, opts ..
638672
639673 // Skip synchronous JWKS registration - will be done lazily on first use
640674
641- // Load client secret from environment variable if not provided in config
642- // This allows secrets to be injected via Kubernetes Secret references
643- clientSecret := config .ClientSecret
644- if clientSecret == "" {
645- if envSecret := o .envReader .Getenv ("TOOLHIVE_OIDC_CLIENT_SECRET" ); envSecret != "" {
646- clientSecret = envSecret
647- }
648- }
675+ // Resolve client secret from config or environment variable
676+ clientSecret := resolveClientSecret (config .ClientSecret , o .envReader )
649677
650678 // Register introspection providers
651679 registry , err := registerIntrospectionProviders (config , clientSecret )
@@ -667,6 +695,7 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig, opts ..
667695 registry : registry ,
668696 insecureAllowHTTP : config .InsecureAllowHTTP ,
669697 upstreamTokenReader : o .upstreamTokenReader ,
698+ keyProvider : o .keyProvider ,
670699 }
671700
672701 return validator , nil
@@ -802,8 +831,67 @@ func (v *TokenValidator) ensureOIDCDiscovered(ctx context.Context) error {
802831 return nil
803832}
804833
834+ // getKeyFromLocalProvider attempts to find a verification key from the local
835+ // key provider (embedded auth server). Returns (key, nil) on success,
836+ // (nil, nil) to signal fallback to HTTP, or (nil, error) for hard failures.
837+ // validateTokenHeader checks the signing method is supported (RSA or ECDSA) and
838+ // extracts the key ID from the token header. Returns an error for unsupported
839+ // methods or a missing kid claim.
840+ func validateTokenHeader (token * jwt.Token ) (string , error ) {
841+ switch token .Method .(type ) {
842+ case * jwt.SigningMethodRSA , * jwt.SigningMethodECDSA :
843+ // Supported signing methods
844+ default :
845+ return "" , fmt .Errorf ("unexpected signing method: %v" , token .Header ["alg" ])
846+ }
847+
848+ kid , ok := token .Header ["kid" ].(string )
849+ if ! ok {
850+ return "" , fmt .Errorf ("token header missing kid" )
851+ }
852+ return kid , nil
853+ }
854+
855+ func (v * TokenValidator ) getKeyFromLocalProvider (ctx context.Context , token * jwt.Token ) (interface {}, error ) {
856+ if v .keyProvider == nil {
857+ return nil , nil
858+ }
859+
860+ kid , err := validateTokenHeader (token )
861+ if err != nil {
862+ return nil , err
863+ }
864+
865+ pubKeys , err := v .keyProvider .PublicKeys (ctx )
866+ if err != nil {
867+ slog .Debug ("local JWKS provider failed, falling back to HTTP" , "error" , err )
868+ return nil , nil
869+ }
870+
871+ for _ , k := range pubKeys {
872+ if k .KeyID == kid {
873+ slog .Debug ("resolved JWKS key from embedded auth server" , "kid" , kid )
874+ return k .PublicKey , nil
875+ }
876+ }
877+
878+ // Key not found locally — fall back to HTTP JWKS
879+ slog .Debug ("key not found in local JWKS provider, falling back to HTTP" , "kid" , kid )
880+ return nil , nil
881+ }
882+
805883// getKeyFromJWKS gets the key from the JWKS.
806884func (v * TokenValidator ) getKeyFromJWKS (ctx context.Context , token * jwt.Token ) (interface {}, error ) {
885+ // Try local key provider first (embedded auth server in-process keys).
886+ // This avoids self-referential HTTP calls when the auth server and
887+ // token validator run in the same process.
888+ if key , err := v .getKeyFromLocalProvider (ctx , token ); err != nil {
889+ return nil , err
890+ } else if key != nil {
891+ return key , nil
892+ }
893+
894+ // Fall through to HTTP-based JWKS lookup.
807895 // Defensive check: JWKS URL must be set before calling this function.
808896 // This invariant is normally guaranteed by ValidateToken calling ensureOIDCDiscovered first.
809897 if v .jwksURL == "" {
@@ -815,18 +903,9 @@ func (v *TokenValidator) getKeyFromJWKS(ctx context.Context, token *jwt.Token) (
815903 return nil , fmt .Errorf ("JWKS registration failed: %w" , err )
816904 }
817905
818- // Validate the signing method
819- switch token .Method .(type ) {
820- case * jwt.SigningMethodRSA , * jwt.SigningMethodECDSA :
821- // Supported RSA signing methods
822- default :
823- return nil , fmt .Errorf ("unexpected signing method: %v" , token .Header ["alg" ])
824- }
825-
826- // Get the key ID from the token header
827- kid , ok := token .Header ["kid" ].(string )
828- if ! ok {
829- return nil , fmt .Errorf ("token header missing kid" )
906+ kid , err := validateTokenHeader (token )
907+ if err != nil {
908+ return nil , err
830909 }
831910
832911 // Get the key set from the JWKS
0 commit comments