Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 50 additions & 4 deletions pkg/authserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"time"

oauthserver "github.com/stacklok/toolhive/pkg/authserver/server"
servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto"
"github.com/stacklok/toolhive/pkg/authserver/server/handlers"
"github.com/stacklok/toolhive/pkg/authserver/server/keys"
Expand Down Expand Up @@ -63,6 +64,11 @@ type RunConfig struct {
// If nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m).
TokenLifespans *TokenLifespanRunConfig `json:"token_lifespans,omitempty" yaml:"token_lifespans,omitempty"`

// DelegationTokenLifespan is the maximum lifetime for delegated tokens issued
// via RFC 8693 token exchange. Specified as a Go duration string (e.g., "15m").
// If empty, defaults to 15 minutes.
DelegationTokenLifespan string `json:"delegation_token_lifespan,omitempty" yaml:"delegation_token_lifespan,omitempty"`

// Upstreams configures connections to upstream Identity Providers.
// At least one upstream is required - the server delegates authentication to these providers.
// Multiple upstreams are supported for sequential authorization chains.
Expand Down Expand Up @@ -616,6 +622,11 @@ type Config struct {
// If zero, defaults to 10 minutes.
AuthCodeLifespan time.Duration

// DelegationTokenLifespan is the maximum lifetime for delegated tokens issued
// via RFC 8693 token exchange. The actual lifetime is the minimum of this value
// and the subject token's remaining lifetime. If zero, defaults to 15 minutes.
DelegationTokenLifespan time.Duration

// Upstreams contains configurations for connecting to upstream IDPs.
// At least one upstream is required - the server delegates authentication to the upstream IDP.
// Multiple upstreams form a sequential authorization chain.
Expand Down Expand Up @@ -730,11 +741,12 @@ func (c *Config) Validate() error {
}
}

if c.CIMDEnabled && c.CIMDCacheMaxSize < 1 {
return fmt.Errorf("cimd.cache_max_size must be >= 1 when CIMD is enabled")
if err := c.validateCIMDBounds(); err != nil {
return err
}
if c.CIMDEnabled && c.CIMDCacheFallbackTTL < 0 {
return fmt.Errorf("cimd.cache_fallback_ttl must be non-negative when CIMD is enabled")

if err := c.validateDelegationTokenLifespan(); err != nil {
return err
}

slog.Debug("authserver config validation passed",
Expand All @@ -744,6 +756,36 @@ func (c *Config) Validate() error {
return nil
}

// validateCIMDBounds rejects invalid CIMD cache bounds when CIMD is enabled.
// When CIMD is disabled the cache fields are ignored.
func (c *Config) validateCIMDBounds() error {
if !c.CIMDEnabled {
return nil
}
if c.CIMDCacheMaxSize < 1 {
return fmt.Errorf("cimd.cache_max_size must be >= 1 when CIMD is enabled")
}
if c.CIMDCacheFallbackTTL < 0 {
return fmt.Errorf("cimd.cache_fallback_ttl must be non-negative when CIMD is enabled")
}
return nil
}

// validateDelegationTokenLifespan rejects negative or excessively long delegation
// token lifespans. Capped at oauthserver.MaxAccessTokenLifespan (the same ceiling
// the token-exchange Factory enforces) so validation and construction agree on a
// single source of truth — delegated tokens should be short-lived. Zero is
// accepted; applyDefaults substitutes the default.
func (c *Config) validateDelegationTokenLifespan() error {
if c.DelegationTokenLifespan < 0 {
return fmt.Errorf("delegation token lifespan must not be negative")
}
if c.DelegationTokenLifespan > oauthserver.MaxAccessTokenLifespan {
return fmt.Errorf("delegation token lifespan must not exceed %v", oauthserver.MaxAccessTokenLifespan)
}
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
Expand Down Expand Up @@ -961,6 +1003,10 @@ func (c *Config) applyDefaults() error {
c.AuthCodeLifespan = 10 * time.Minute
slog.Debug("applied default auth code lifespan", "duration", c.AuthCodeLifespan)
}
if c.DelegationTokenLifespan == 0 {
c.DelegationTokenLifespan = 15 * time.Minute
slog.Debug("applied default delegation token lifespan", "duration", c.DelegationTokenLifespan)
}
if c.HMACSecrets == nil {
secret := make([]byte, servercrypto.MinSecretLength)
if _, err := rand.Read(secret); err != nil {
Expand Down
76 changes: 76 additions & 0 deletions pkg/authserver/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,3 +669,79 @@ func TestConfigApplyDefaults_CIMD(t *testing.T) {
})
}
}

// TestConfigValidate_DelegationTokenLifespan covers the RFC 8693 delegation
// token lifespan bounds added to Config.Validate: zero is accepted (it is
// defaulted later by applyDefaults), values in (0, 24h] are accepted, and
// negative or over-24h values are rejected.
func TestConfigValidate_DelegationTokenLifespan(t *testing.T) {
t.Parallel()

// base returns a minimally-valid Config so each case isolates the
// DelegationTokenLifespan check from unrelated validation failures.
base := func() Config {
return Config{
Issuer: "https://example.com",
KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm),
HMACSecrets: &servercrypto.HMACSecrets{Current: make([]byte, 32)},
Upstreams: []UpstreamConfig{{
Name: "default",
Type: UpstreamProviderTypeOAuth2,
OAuth2Config: &upstream.OAuth2Config{
CommonOAuthConfig: upstream.CommonOAuthConfig{ClientID: "c", RedirectURI: "https://example.com/cb"},
AuthorizationEndpoint: "https://idp.example.com/authorize",
TokenEndpoint: "https://idp.example.com/token",
},
}},
AllowedAudiences: []string{"https://mcp.example.com"},
}
}

tests := []struct {
name string
lifespan time.Duration
wantErr bool
errMsg string
}{
{name: "zero accepted (defaulted later)", lifespan: 0},
{name: "valid 15m", lifespan: 15 * time.Minute},
{name: "valid 1h", lifespan: time.Hour},
{name: "valid 24h boundary", lifespan: 24 * time.Hour},
{name: "negative rejected", lifespan: -time.Second, wantErr: true, errMsg: "delegation token lifespan must not be negative"},
{name: "over 24h rejected", lifespan: 24*time.Hour + time.Second, wantErr: true, errMsg: "delegation token lifespan must not exceed 24h"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := base()
cfg.DelegationTokenLifespan = tt.lifespan
assertError(t, cfg.Validate(), tt.wantErr, tt.errMsg)
})
}
}

// TestConfigApplyDefaults_DelegationTokenLifespan verifies that applyDefaults
// fills a zero DelegationTokenLifespan with the 15-minute default and preserves
// a caller-supplied value.
func TestConfigApplyDefaults_DelegationTokenLifespan(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input time.Duration
want time.Duration
}{
{name: "zero gets 15m default", input: 0, want: 15 * time.Minute},
{name: "custom value preserved", input: 5 * time.Minute, want: 5 * time.Minute},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := Config{Issuer: "https://example.com", DelegationTokenLifespan: tt.input}
require.NoError(t, cfg.applyDefaults())
require.Equal(t, tt.want, cfg.DelegationTokenLifespan)
})
}
}
Loading
Loading