From 64e771a6b0222309c828e6916ecc9b6c45d15cef Mon Sep 17 00:00:00 2001 From: Vasily Tsybenko Date: Tue, 11 Feb 2025 10:47:27 +0200 Subject: [PATCH] Support for standard YAML/JSON unmarshaling in configuration loading * Provide an ability to use regular yaml/json unmarshaling for loading configs --- auth.go | 15 ++- config.go | 300 +++++++++++++++++++++++++++++++------------------ config_test.go | 297 ++++++++++++++++++++++++++++++++++++------------ go.mod | 2 +- go.sum | 4 +- 5 files changed, 428 insertions(+), 190 deletions(-) diff --git a/auth.go b/auth.go index f23e2fe..3df663a 100644 --- a/auth.go +++ b/auth.go @@ -13,6 +13,7 @@ import ( "fmt" "net/http" "os" + "time" "github.com/acronis/go-appkit/httpserver/middleware" "github.com/acronis/go-appkit/log" @@ -38,11 +39,12 @@ func NewJWTParser(cfg *Config, opts ...JWTParserOption) (JWTParser, error) { } // Make caching JWKS client. - jwksCacheUpdateMinInterval := cfg.JWKS.Cache.UpdateMinInterval + jwksCacheUpdateMinInterval := time.Duration(cfg.JWKS.Cache.UpdateMinInterval) if jwksCacheUpdateMinInterval == 0 { jwksCacheUpdateMinInterval = jwks.DefaultCacheUpdateMinInterval } - httpClient := idputil.MakeDefaultHTTPClient(cfg.HTTPClient.RequestTimeout, options.loggerProvider, options.requestIDProvider) + httpClient := idputil.MakeDefaultHTTPClient( + time.Duration(cfg.HTTPClient.RequestTimeout), options.loggerProvider, options.requestIDProvider) jwksClientOpts := jwks.CachingClientOpts{ ClientOpts: jwks.ClientOpts{ LoggerProvider: options.loggerProvider, @@ -183,7 +185,7 @@ func NewTokenIntrospector( return nil, fmt.Errorf("make grpc transport credentials: %w", err) } grpcClientOpts := idptoken.GRPCClientOpts{ - RequestTimeout: cfg.GRPCClient.RequestTimeout, + RequestTimeout: time.Duration(cfg.GRPCClient.RequestTimeout), LoggerProvider: options.loggerProvider, RequestIDProvider: options.requestIDProvider, UserAgent: libinfo.UserAgent(), @@ -195,7 +197,8 @@ func NewTokenIntrospector( } } - httpClient := idputil.MakeDefaultHTTPClient(cfg.HTTPClient.RequestTimeout, options.loggerProvider, options.requestIDProvider) + httpClient := idputil.MakeDefaultHTTPClient( + time.Duration(cfg.HTTPClient.RequestTimeout), options.loggerProvider, options.requestIDProvider) introspectorOpts := idptoken.IntrospectorOpts{ HTTPEndpoint: cfg.Introspection.Endpoint, @@ -210,12 +213,12 @@ func NewTokenIntrospector( ClaimsCache: idptoken.IntrospectorCacheOpts{ Enabled: cfg.Introspection.ClaimsCache.Enabled, MaxEntries: cfg.Introspection.ClaimsCache.MaxEntries, - TTL: cfg.Introspection.ClaimsCache.TTL, + TTL: time.Duration(cfg.Introspection.ClaimsCache.TTL), }, NegativeCache: idptoken.IntrospectorCacheOpts{ Enabled: cfg.Introspection.NegativeCache.Enabled, MaxEntries: cfg.Introspection.NegativeCache.MaxEntries, - TTL: cfg.Introspection.NegativeCache.TTL, + TTL: time.Duration(cfg.Introspection.NegativeCache.TTL), }, RequireAudience: cfg.JWT.RequireAudience, ExpectedAudience: cfg.JWT.ExpectedAudience, diff --git a/config.go b/config.go index 1327b4c..dda9070 100644 --- a/config.go +++ b/config.go @@ -19,109 +19,45 @@ import ( "github.com/acronis/go-authkit/jwt" ) +const cfgDefaultKeyPrefix = "auth" + const ( - cfgKeyHTTPClientRequestTimeout = "auth.httpClient.requestTimeout" - cfgKeyGRPCClientRequestTimeout = "auth.grpcClient.requestTimeout" - cfgKeyJWTTrustedIssuers = "auth.jwt.trustedIssuers" - cfgKeyJWTTrustedIssuerURLs = "auth.jwt.trustedIssuerUrls" - cfgKeyJWTRequireAudience = "auth.jwt.requireAudience" - cfgKeyJWTExceptedAudience = "auth.jwt.expectedAudience" - cfgKeyJWTClaimsCacheEnabled = "auth.jwt.claimsCache.enabled" - cfgKeyJWTClaimsCacheMaxEntries = "auth.jwt.claimsCache.maxEntries" - cfgKeyJWKSCacheUpdateMinInterval = "auth.jwks.cache.updateMinInterval" - cfgKeyIntrospectionEnabled = "auth.introspection.enabled" - cfgKeyIntrospectionEndpoint = "auth.introspection.endpoint" - cfgKeyIntrospectionGRPCEndpoint = "auth.introspection.grpc.endpoint" - cfgKeyIntrospectionGRPCTLSEnabled = "auth.introspection.grpc.tls.enabled" - cfgKeyIntrospectionGRPCTLSCACert = "auth.introspection.grpc.tls.caCert" - cfgKeyIntrospectionGRPCTLSClientCert = "auth.introspection.grpc.tls.clientCert" - cfgKeyIntrospectionGRPCTLSClientKey = "auth.introspection.grpc.tls.clientKey" - cfgKeyIntrospectionAccessTokenScope = "auth.introspection.accessTokenScope" // nolint:gosec // false positive - cfgKeyIntrospectionClaimsCacheEnabled = "auth.introspection.claimsCache.enabled" - cfgKeyIntrospectionClaimsCacheMaxEntries = "auth.introspection.claimsCache.maxEntries" - cfgKeyIntrospectionClaimsCacheTTL = "auth.introspection.claimsCache.ttl" - cfgKeyIntrospectionNegativeCacheEnabled = "auth.introspection.negativeCache.enabled" - cfgKeyIntrospectionNegativeCacheMaxEntries = "auth.introspection.negativeCache.maxEntries" - cfgKeyIntrospectionNegativeCacheTTL = "auth.introspection.negativeCache.ttl" - cfgKeyIntrospectionEndpointDiscoveryCacheEnabled = "auth.introspection.endpointDiscoveryCache.enabled" - cfgKeyIntrospectionEndpointDiscoveryCacheMaxEntries = "auth.introspection.endpointDiscoveryCache.maxEntries" - cfgKeyIntrospectionEndpointDiscoveryCacheTTL = "auth.introspection.endpointDiscoveryCache.ttl" + cfgKeyHTTPClientRequestTimeout = "httpClient.requestTimeout" + cfgKeyGRPCClientRequestTimeout = "grpcClient.requestTimeout" + cfgKeyJWTTrustedIssuers = "jwt.trustedIssuers" + cfgKeyJWTTrustedIssuerURLs = "jwt.trustedIssuerUrls" + cfgKeyJWTRequireAudience = "jwt.requireAudience" + cfgKeyJWTExceptedAudience = "jwt.expectedAudience" + cfgKeyJWTClaimsCacheEnabled = "jwt.claimsCache.enabled" + cfgKeyJWTClaimsCacheMaxEntries = "jwt.claimsCache.maxEntries" + cfgKeyJWKSCacheUpdateMinInterval = "jwks.cache.updateMinInterval" + cfgKeyIntrospectionEnabled = "introspection.enabled" + cfgKeyIntrospectionEndpoint = "introspection.endpoint" + cfgKeyIntrospectionGRPCEndpoint = "introspection.grpc.endpoint" + cfgKeyIntrospectionGRPCTLSEnabled = "introspection.grpc.tls.enabled" + cfgKeyIntrospectionGRPCTLSCACert = "introspection.grpc.tls.caCert" + cfgKeyIntrospectionGRPCTLSClientCert = "introspection.grpc.tls.clientCert" + cfgKeyIntrospectionGRPCTLSClientKey = "introspection.grpc.tls.clientKey" + cfgKeyIntrospectionAccessTokenScope = "introspection.accessTokenScope" // nolint:gosec // false positive + cfgKeyIntrospectionClaimsCacheEnabled = "introspection.claimsCache.enabled" + cfgKeyIntrospectionClaimsCacheMaxEntries = "introspection.claimsCache.maxEntries" + cfgKeyIntrospectionClaimsCacheTTL = "introspection.claimsCache.ttl" + cfgKeyIntrospectionNegativeCacheEnabled = "introspection.negativeCache.enabled" + cfgKeyIntrospectionNegativeCacheMaxEntries = "introspection.negativeCache.maxEntries" + cfgKeyIntrospectionNegativeCacheTTL = "introspection.negativeCache.ttl" + cfgKeyIntrospectionEndpointDiscoveryCacheEnabled = "introspection.endpointDiscoveryCache.enabled" + cfgKeyIntrospectionEndpointDiscoveryCacheMaxEntries = "introspection.endpointDiscoveryCache.maxEntries" + cfgKeyIntrospectionEndpointDiscoveryCacheTTL = "introspection.endpointDiscoveryCache.ttl" ) -// JWTConfig is configuration of how JWT will be verified. -type JWTConfig struct { - TrustedIssuers map[string]string - TrustedIssuerURLs []string - RequireAudience bool - ExpectedAudience []string - ClaimsCache ClaimsCacheConfig -} - -// JWKSConfig is configuration of how JWKS will be used. -type JWKSConfig struct { - Cache struct { - UpdateMinInterval time.Duration - } -} - -// IntrospectionConfig is a configuration of how token introspection will be used. -type IntrospectionConfig struct { - Enabled bool - - Endpoint string - AccessTokenScope []string - - ClaimsCache IntrospectionCacheConfig - NegativeCache IntrospectionCacheConfig - EndpointDiscoveryCache IntrospectionCacheConfig - - GRPC IntrospectionGRPCConfig -} - -// ClaimsCacheConfig is a configuration of how claims cache will be used. -type ClaimsCacheConfig struct { - Enabled bool - MaxEntries int -} - -// IntrospectionCacheConfig is a configuration of how claims cache will be used for introspection. -type IntrospectionCacheConfig struct { - Enabled bool - MaxEntries int - TTL time.Duration -} - -// IntrospectionGRPCConfig is a configuration of how token will be introspected via gRPC. -type IntrospectionGRPCConfig struct { - Endpoint string - RequestTimeout time.Duration - TLS GRPCTLSConfig -} - -// GRPCTLSConfig is a configuration of how gRPC connection will be secured. -type GRPCTLSConfig struct { - Enabled bool - CACert string - ClientCert string - ClientKey string -} - -type HTTPClientConfig struct { - RequestTimeout time.Duration -} - -type GRPCClientConfig struct { - RequestTimeout time.Duration -} - // Config represents a set of configuration parameters for authentication and authorization. type Config struct { - HTTPClient HTTPClientConfig - GRPCClient GRPCClientConfig + HTTPClient HTTPClientConfig `mapstructure:"httpClient" yaml:"httpClient" json:"httpClient"` + GRPCClient GRPCClientConfig `mapstructure:"grpcClient" yaml:"grpcClient"` - JWT JWTConfig - JWKS JWKSConfig - Introspection IntrospectionConfig + JWT JWTConfig `mapstructure:"jwt" yaml:"jwt" json:"jwt"` + JWKS JWKSConfig `mapstructure:"jwks" yaml:"jwks" json:"jwks"` + Introspection IntrospectionConfig `mapstructure:"introspection" yaml:"introspection" json:"introspection"` keyPrefix string } @@ -129,19 +65,89 @@ type Config struct { var _ config.Config = (*Config)(nil) var _ config.KeyPrefixProvider = (*Config)(nil) +// ConfigOption is a type for functional options for the Config. +type ConfigOption func(*configOptions) + +type configOptions struct { + keyPrefix string +} + +// WithKeyPrefix returns a ConfigOption that sets a key prefix for parsing configuration parameters. +// This prefix will be used by config.Loader. +func WithKeyPrefix(keyPrefix string) ConfigOption { + return func(o *configOptions) { + o.keyPrefix = keyPrefix + } +} + // NewConfig creates a new instance of the Config. -func NewConfig() *Config { - return NewConfigWithKeyPrefix("") +func NewConfig(options ...ConfigOption) *Config { + var opts = configOptions{keyPrefix: cfgDefaultKeyPrefix} // cfgDefaultKeyPrefix is used here for backward compatibility + for _, opt := range options { + opt(&opts) + } + return &Config{keyPrefix: opts.keyPrefix} } -// NewConfigWithKeyPrefix creates a new instance of the Config. -// Allows specifying key prefix which will be used for parsing configuration parameters. +// NewConfigWithKeyPrefix creates a new instance of the Config with a key prefix. +// This prefix will be used by config.Loader. +// Deprecated: use NewConfig with WithKeyPrefix instead. func NewConfigWithKeyPrefix(keyPrefix string) *Config { + if keyPrefix != "" { + keyPrefix += "." + } + keyPrefix += cfgDefaultKeyPrefix // cfgDefaultKeyPrefix is added here for backward compatibility return &Config{keyPrefix: keyPrefix} } +// NewDefaultConfig creates a new instance of the Config with default values. +func NewDefaultConfig(options ...ConfigOption) *Config { + opts := configOptions{keyPrefix: cfgDefaultKeyPrefix} + for _, opt := range options { + opt(&opts) + } + return &Config{ + keyPrefix: opts.keyPrefix, + HTTPClient: HTTPClientConfig{ + RequestTimeout: config.TimeDuration(idputil.DefaultHTTPRequestTimeout), + }, + GRPCClient: GRPCClientConfig{ + RequestTimeout: config.TimeDuration(idptoken.DefaultGRPCClientRequestTimeout), + }, + JWT: JWTConfig{ + ClaimsCache: ClaimsCacheConfig{ + MaxEntries: jwt.DefaultClaimsCacheMaxEntries, + }, + }, + JWKS: JWKSConfig{ + Cache: JWKSCacheConfig{ + UpdateMinInterval: config.TimeDuration(jwks.DefaultCacheUpdateMinInterval), + }, + }, + Introspection: IntrospectionConfig{ + ClaimsCache: IntrospectionCacheConfig{ + MaxEntries: idptoken.DefaultIntrospectionClaimsCacheMaxEntries, + TTL: config.TimeDuration(idptoken.DefaultIntrospectionClaimsCacheTTL), + }, + NegativeCache: IntrospectionCacheConfig{ + MaxEntries: idptoken.DefaultIntrospectionNegativeCacheMaxEntries, + TTL: config.TimeDuration(idptoken.DefaultIntrospectionNegativeCacheTTL), + }, + EndpointDiscoveryCache: IntrospectionCacheConfig{ + Enabled: true, + MaxEntries: idptoken.DefaultIntrospectionEndpointDiscoveryCacheMaxEntries, + TTL: config.TimeDuration(idptoken.DefaultIntrospectionEndpointDiscoveryCacheTTL), + }, + }, + } +} + // KeyPrefix returns a key prefix with which all configuration parameters should be presented. +// Implements config.KeyPrefixProvider interface. func (c *Config) KeyPrefix() string { + if c.keyPrefix == "" { + return cfgDefaultKeyPrefix + } return c.keyPrefix } @@ -164,16 +170,87 @@ func (c *Config) SetProviderDefaults(dp config.DataProvider) { dp.SetDefault(cfgKeyIntrospectionEndpointDiscoveryCacheTTL, idptoken.DefaultIntrospectionEndpointDiscoveryCacheTTL.String()) } +// JWTConfig is a configuration of how JWT will be verified. +type JWTConfig struct { + TrustedIssuers map[string]string `mapstructure:"trustedIssuers" yaml:"trustedIssuers" json:"trustedIssuers"` + TrustedIssuerURLs []string `mapstructure:"trustedIssuerUrls" yaml:"trustedIssuerUrls" json:"trustedIssuerUrls"` + RequireAudience bool `mapstructure:"requireAudience" yaml:"requireAudience" json:"requireAudience"` + ExpectedAudience []string `mapstructure:"expectedAudience" yaml:"expectedAudience" json:"expectedAudience"` + ClaimsCache ClaimsCacheConfig `mapstructure:"claimsCache" yaml:"claimsCache" json:"claimsCache"` +} + +// JWKSConfig is a configuration of how JWKS will be used. +type JWKSConfig struct { + Cache JWKSCacheConfig `mapstructure:"cache" yaml:"cache" json:"cache"` +} + +type JWKSCacheConfig struct { + UpdateMinInterval config.TimeDuration `mapstructure:"updateMinInterval" yaml:"updateMinInterval" json:"updateMinInterval"` +} + +// IntrospectionConfig is a configuration of how token introspection will be used. +type IntrospectionConfig struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` + + Endpoint string `mapstructure:"endpoint" yaml:"endpoint" json:"endpoint"` + AccessTokenScope []string `mapstructure:"accessTokenScope" yaml:"accessTokenScope" json:"accessTokenScope"` + + ClaimsCache IntrospectionCacheConfig `mapstructure:"claimsCache" yaml:"claimsCache" json:"claimsCache"` + NegativeCache IntrospectionCacheConfig `mapstructure:"negativeCache" yaml:"negativeCache" json:"negativeCache"` + EndpointDiscoveryCache IntrospectionCacheConfig `mapstructure:"endpointDiscoveryCache" yaml:"endpointDiscoveryCache" json:"endpointDiscoveryCache"` // nolint:lll + + GRPC IntrospectionGRPCConfig `mapstructure:"grpc" yaml:"grpc" json:"grpc"` +} + +// ClaimsCacheConfig is a configuration of how claims cache will be used. +type ClaimsCacheConfig struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` + MaxEntries int `mapstructure:"maxEntries" yaml:"maxEntries" json:"maxEntries"` +} + +// IntrospectionCacheConfig is a configuration of how claims cache will be used for introspection. +type IntrospectionCacheConfig struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` + MaxEntries int `mapstructure:"maxEntries" yaml:"maxEntries" json:"maxEntries"` + TTL config.TimeDuration `mapstructure:"ttl" yaml:"ttl" json:"ttl"` +} + +// IntrospectionGRPCConfig is a configuration of how token will be introspected via gRPC. +type IntrospectionGRPCConfig struct { + Endpoint string `mapstructure:"endpoint" yaml:"endpoint" json:"endpoint"` + RequestTimeout config.TimeDuration `mapstructure:"requestTimeout" yaml:"requestTimeout" json:"requestTimeout"` + TLS GRPCTLSConfig `mapstructure:"tls" yaml:"tls" json:"tls"` +} + +// GRPCTLSConfig is a configuration of how gRPC connection will be secured. +type GRPCTLSConfig struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` + CACert string `mapstructure:"caCert" yaml:"caCert" json:"caCert"` + ClientCert string `mapstructure:"clientCert" yaml:"clientCert" json:"clientCert"` + ClientKey string `mapstructure:"clientKey" yaml:"clientKey" json:"clientKey"` +} + +type HTTPClientConfig struct { + RequestTimeout config.TimeDuration `mapstructure:"requestTimeout" yaml:"requestTimeout" json:"requestTimeout"` +} + +type GRPCClientConfig struct { + RequestTimeout config.TimeDuration `mapstructure:"requestTimeout" yaml:"requestTimeout" json:"requestTimeout"` +} + // Set sets auth configuration values from config.DataProvider. func (c *Config) Set(dp config.DataProvider) error { var err error - if c.HTTPClient.RequestTimeout, err = dp.GetDuration(cfgKeyHTTPClientRequestTimeout); err != nil { + var reqDuration time.Duration + if reqDuration, err = dp.GetDuration(cfgKeyHTTPClientRequestTimeout); err != nil { return err } - if c.GRPCClient.RequestTimeout, err = dp.GetDuration(cfgKeyGRPCClientRequestTimeout); err != nil { + c.HTTPClient.RequestTimeout = config.TimeDuration(reqDuration) + if reqDuration, err = dp.GetDuration(cfgKeyGRPCClientRequestTimeout); err != nil { return err } + c.GRPCClient.RequestTimeout = config.TimeDuration(reqDuration) if err = c.setJWTConfig(dp); err != nil { return err } @@ -221,10 +298,11 @@ func (c *Config) setJWTConfig(dp config.DataProvider) error { } func (c *Config) setJWKSConfig(dp config.DataProvider) error { - var err error - if c.JWKS.Cache.UpdateMinInterval, err = dp.GetDuration(cfgKeyJWKSCacheUpdateMinInterval); err != nil { + updateMinInterval, err := dp.GetDuration(cfgKeyJWKSCacheUpdateMinInterval) + if err != nil { return err } + c.JWKS.Cache.UpdateMinInterval = config.TimeDuration(updateMinInterval) return nil } @@ -272,9 +350,11 @@ func (c *Config) setIntrospectionConfig(dp config.DataProvider) error { if c.Introspection.ClaimsCache.MaxEntries < 0 { return dp.WrapKeyErr(cfgKeyIntrospectionClaimsCacheMaxEntries, fmt.Errorf("max entries should be non-negative")) } - if c.Introspection.ClaimsCache.TTL, err = dp.GetDuration(cfgKeyIntrospectionClaimsCacheTTL); err != nil { + var cacheTTL time.Duration + if cacheTTL, err = dp.GetDuration(cfgKeyIntrospectionClaimsCacheTTL); err != nil { return err } + c.Introspection.ClaimsCache.TTL = config.TimeDuration(cacheTTL) // Negative cache if c.Introspection.NegativeCache.Enabled, err = dp.GetBool(cfgKeyIntrospectionNegativeCacheEnabled); err != nil { @@ -286,9 +366,10 @@ func (c *Config) setIntrospectionConfig(dp config.DataProvider) error { if c.Introspection.NegativeCache.MaxEntries < 0 { return dp.WrapKeyErr(cfgKeyIntrospectionNegativeCacheMaxEntries, fmt.Errorf("max entries should be non-negative")) } - if c.Introspection.NegativeCache.TTL, err = dp.GetDuration(cfgKeyIntrospectionNegativeCacheTTL); err != nil { + if cacheTTL, err = dp.GetDuration(cfgKeyIntrospectionNegativeCacheTTL); err != nil { return err } + c.Introspection.NegativeCache.TTL = config.TimeDuration(cacheTTL) // OpenID configuration cache if c.Introspection.EndpointDiscoveryCache.Enabled, err = dp.GetBool( @@ -304,11 +385,10 @@ func (c *Config) setIntrospectionConfig(dp config.DataProvider) error { if c.Introspection.EndpointDiscoveryCache.MaxEntries < 0 { return dp.WrapKeyErr(cfgKeyIntrospectionEndpointDiscoveryCacheMaxEntries, fmt.Errorf("max entries should be non-negative")) } - if c.Introspection.EndpointDiscoveryCache.TTL, err = dp.GetDuration( - cfgKeyIntrospectionEndpointDiscoveryCacheTTL, - ); err != nil { + if cacheTTL, err = dp.GetDuration(cfgKeyIntrospectionEndpointDiscoveryCacheTTL); err != nil { return err } + c.Introspection.EndpointDiscoveryCache.TTL = config.TimeDuration(cacheTTL) return nil } diff --git a/config_test.go b/config_test.go index cb82be3..fe1ed11 100644 --- a/config_test.go +++ b/config_test.go @@ -8,19 +8,68 @@ package authkit import ( "bytes" + "encoding/json" "strings" "testing" "time" "github.com/acronis/go-appkit/config" + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" "github.com/stretchr/testify/require" - - "github.com/acronis/go-authkit/jwt" + "gopkg.in/yaml.v3" ) -func TestConfig_Set(t *testing.T) { - t.Run("ok", func(t *testing.T) { - cfgData := bytes.NewBufferString(` +type AppConfig struct { + Auth *Config `mapstructure:"auth" json:"auth" yaml:"auth"` +} + +func TestConfig(t *testing.T) { + expectedCfg := NewDefaultConfig() + expectedCfg.HTTPClient.RequestTimeout = config.TimeDuration(time.Minute * 1) + expectedCfg.GRPCClient.RequestTimeout = config.TimeDuration(time.Minute * 2) + expectedCfg.JWT.TrustedIssuers = map[string]string{ + "my-issuer1": "https://my-issuer1.com/idp", + "my-issuer2": "https://my-issuer2.com/idp", + } + expectedCfg.JWT.TrustedIssuerURLs = []string{ + "https://*.my-company1.com/idp", + "https://*.my-company2.com/idp", + } + expectedCfg.JWT.RequireAudience = true + expectedCfg.JWT.ExpectedAudience = []string{ + "https://*.my-company1.com", + "https://*.my-company2.com", + } + expectedCfg.JWKS.Cache.UpdateMinInterval = config.TimeDuration(time.Minute * 5) + expectedCfg.Introspection.Enabled = true + expectedCfg.Introspection.Endpoint = "https://my-idp.com/introspect" + expectedCfg.Introspection.ClaimsCache.Enabled = true + expectedCfg.Introspection.ClaimsCache.MaxEntries = 42000 + expectedCfg.Introspection.ClaimsCache.TTL = config.TimeDuration(time.Second * 42) + expectedCfg.Introspection.NegativeCache.Enabled = true + expectedCfg.Introspection.NegativeCache.MaxEntries = 777 + expectedCfg.Introspection.NegativeCache.TTL = config.TimeDuration(time.Minute * 77) + expectedCfg.Introspection.EndpointDiscoveryCache.Enabled = true + expectedCfg.Introspection.EndpointDiscoveryCache.MaxEntries = 73 + expectedCfg.Introspection.EndpointDiscoveryCache.TTL = config.TimeDuration(time.Hour * 7) + expectedCfg.Introspection.AccessTokenScope = []string{"token_introspector"} + expectedCfg.Introspection.GRPC.Endpoint = "127.0.0.1:1234" + expectedCfg.Introspection.GRPC.TLS.Enabled = true + expectedCfg.Introspection.GRPC.TLS.CACert = "ca-cert.pem" + expectedCfg.Introspection.GRPC.TLS.ClientCert = "client-cert.pem" + expectedCfg.Introspection.GRPC.TLS.ClientKey = "client-key.pem" + + tests := []struct { + name string + cfgDataType config.DataType + cfgData string + expectedCfg *Config + }{ + { + name: "yaml config", + cfgDataType: config.DataTypeYAML, + cfgData: ` auth: httpClient: requestTimeout: 1m @@ -66,64 +115,171 @@ auth: caCert: ca-cert.pem clientCert: client-cert.pem clientKey: client-key.pem -`) - cfg := Config{} - err := config.NewDefaultLoader("").LoadFromReader(cfgData, config.DataTypeYAML, &cfg) - require.NoError(t, err) - require.Equal(t, time.Minute*1, cfg.HTTPClient.RequestTimeout) - require.Equal(t, time.Minute*2, cfg.GRPCClient.RequestTimeout) - require.Equal(t, cfg.JWT, JWTConfig{ - TrustedIssuers: map[string]string{ - "my-issuer1": "https://my-issuer1.com/idp", - "my-issuer2": "https://my-issuer2.com/idp", - }, - TrustedIssuerURLs: []string{ - "https://*.my-company1.com/idp", - "https://*.my-company2.com/idp", - }, - RequireAudience: true, - ExpectedAudience: []string{ - "https://*.my-company1.com", - "https://*.my-company2.com", - }, - ClaimsCache: ClaimsCacheConfig{ - MaxEntries: jwt.DefaultClaimsCacheMaxEntries, - }, - }) - require.Equal(t, time.Minute*5, cfg.JWKS.Cache.UpdateMinInterval) - require.Equal(t, cfg.Introspection, IntrospectionConfig{ - Enabled: true, - Endpoint: "https://my-idp.com/introspect", - ClaimsCache: IntrospectionCacheConfig{ - Enabled: true, - MaxEntries: 42000, - TTL: time.Second * 42, - }, - NegativeCache: IntrospectionCacheConfig{ - Enabled: true, - MaxEntries: 777, - TTL: time.Minute * 77, - }, - EndpointDiscoveryCache: IntrospectionCacheConfig{ - Enabled: true, - MaxEntries: 73, - TTL: time.Hour * 7, - }, - AccessTokenScope: []string{"token_introspector"}, - GRPC: IntrospectionGRPCConfig{ - Endpoint: "127.0.0.1:1234", - TLS: GRPCTLSConfig{ - Enabled: true, - CACert: "ca-cert.pem", - ClientCert: "client-cert.pem", - ClientKey: "client-key.pem", - }, - }, +`, + expectedCfg: expectedCfg, + }, + { + name: "json config", + cfgDataType: config.DataTypeJSON, + cfgData: ` +{ + "auth": { + "httpClient": { + "requestTimeout": "1m" + }, + "grpcClient": { + "requestTimeout": "2m" + }, + "jwt": { + "trustedIssuers": { + "my-issuer1": "https://my-issuer1.com/idp", + "my-issuer2": "https://my-issuer2.com/idp" + }, + "trustedIssuerUrls": [ + "https://*.my-company1.com/idp", + "https://*.my-company2.com/idp" + ], + "requireAudience": true, + "expectedAudience": [ + "https://*.my-company1.com", + "https://*.my-company2.com" + ] + }, + "jwks": { + "cache": { + "updateMinInterval": "5m" + } + }, + "introspection": { + "enabled": true, + "endpoint": "https://my-idp.com/introspect", + "claimsCache": { + "enabled": true, + "maxEntries": 42000, + "ttl": "42s" + }, + "negativeCache": { + "enabled": true, + "maxEntries": 777, + "ttl": "77m" + }, + "endpointDiscoveryCache": { + "enabled": true, + "maxEntries": 73, + "ttl": "7h" + }, + "accessTokenScope": [ + "token_introspector" + ], + "grpc": { + "endpoint": "127.0.0.1:1234", + "tls": { + "enabled": true, + "caCert": "ca-cert.pem", + "clientCert": "client-cert.pem", + "clientKey": "client-key.pem" + } + } + } + } +} +`, + expectedCfg: expectedCfg, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Load config using config.Loader. + appCfg := AppConfig{Auth: NewDefaultConfig()} + expectedAppCfg := AppConfig{Auth: tt.expectedCfg} + cfgLoader := config.NewLoader(config.NewViperAdapter()) + err := cfgLoader.LoadFromReader(bytes.NewBuffer([]byte(tt.cfgData)), tt.cfgDataType, appCfg.Auth) + require.NoError(t, err) + require.Equal(t, expectedAppCfg, appCfg) + + // Load config using viper unmarshal. + appCfg = AppConfig{Auth: NewDefaultConfig()} + expectedAppCfg = AppConfig{Auth: tt.expectedCfg} + vpr := viper.New() + vpr.SetConfigType(string(tt.cfgDataType)) + require.NoError(t, vpr.ReadConfig(bytes.NewBuffer([]byte(tt.cfgData)))) + require.NoError(t, vpr.Unmarshal(&appCfg, func(c *mapstructure.DecoderConfig) { + c.DecodeHook = mapstructure.TextUnmarshallerHookFunc() + })) + require.Equal(t, expectedAppCfg, appCfg) + + // Load config using yaml/json unmarshal. + appCfg = AppConfig{Auth: NewDefaultConfig()} + expectedAppCfg = AppConfig{Auth: tt.expectedCfg} + switch tt.cfgDataType { + case config.DataTypeYAML: + require.NoError(t, yaml.Unmarshal([]byte(tt.cfgData), &appCfg)) + require.Equal(t, expectedAppCfg, appCfg) + case config.DataTypeJSON: + require.NoError(t, json.Unmarshal([]byte(tt.cfgData), &appCfg)) + require.Equal(t, expectedAppCfg, appCfg) + default: + t.Fatalf("unsupported config data type: %s", tt.cfgDataType) + } }) + } +} + +func TestNewDefaultConfig(t *testing.T) { + var cfg *Config + + // Empty config, all defaults for the data provider should be used + cfg = NewConfig() + require.NoError(t, config.NewDefaultLoader("").LoadFromReader(bytes.NewBuffer(nil), config.DataTypeYAML, cfg)) + require.Empty(t, cfg.JWT.TrustedIssuers) + cfg.JWT.TrustedIssuers = nil // map[string]string{} is not equal to nil + require.Equal(t, NewDefaultConfig(), cfg) + + // viper.Unmarshal + cfg = NewDefaultConfig() + vpr := viper.New() + vpr.SetConfigType("yaml") + require.NoError(t, vpr.Unmarshal(&cfg)) + require.Equal(t, NewDefaultConfig(), cfg) + + // yaml.Unmarshal + cfg = NewDefaultConfig() + require.NoError(t, yaml.Unmarshal([]byte(""), &cfg)) + require.Equal(t, NewDefaultConfig(), cfg) + + // json.Unmarshal + cfg = NewDefaultConfig() + require.NoError(t, json.Unmarshal([]byte("{}"), &cfg)) + require.Equal(t, NewDefaultConfig(), cfg) +} + +func TestConfigWithKeyPrefix(t *testing.T) { + t.Run("custom key prefix", func(t *testing.T) { + cfgData := ` +customAuth: + httpClient: + requestTimeout: 2m +` + cfg := NewConfig(WithKeyPrefix("customAuth")) + err := config.NewDefaultLoader("").LoadFromReader(bytes.NewBuffer([]byte(cfgData)), config.DataTypeYAML, cfg) + require.NoError(t, err) + require.Equal(t, config.TimeDuration(time.Minute*2), cfg.HTTPClient.RequestTimeout) + }) + + t.Run("default key prefix, empty struct initialization", func(t *testing.T) { + cfgData := ` +auth: + httpClient: + requestTimeout: 2m +` + cfg := &Config{} + err := config.NewDefaultLoader("").LoadFromReader(bytes.NewBuffer([]byte(cfgData)), config.DataTypeYAML, cfg) + require.NoError(t, err) + require.Equal(t, config.TimeDuration(time.Minute*2), cfg.HTTPClient.RequestTimeout) }) } -func TestConfig_SetErrors(t *testing.T) { +func TestConfigValidationErrors(t *testing.T) { tests := []struct { name string cfgData string @@ -138,7 +294,7 @@ auth: trustedIssuerURLs: - ://invalid-url `, - errKey: cfgKeyJWTTrustedIssuerURLs, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyJWTTrustedIssuerURLs, errMsg: "missing protocol scheme", }, { @@ -149,7 +305,7 @@ auth: claimsCache: maxEntries: -1 `, - errKey: cfgKeyJWTClaimsCacheMaxEntries, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyJWTClaimsCacheMaxEntries, errMsg: "max entries should be non-negative", }, { @@ -159,7 +315,7 @@ auth: httpClient: requestTimeout: invalid `, - errKey: cfgKeyHTTPClientRequestTimeout, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyHTTPClientRequestTimeout, errMsg: "invalid duration", }, { @@ -169,7 +325,7 @@ auth: grpcClient: requestTimeout: invalid `, - errKey: cfgKeyGRPCClientRequestTimeout, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyGRPCClientRequestTimeout, errMsg: "invalid duration", }, { @@ -180,7 +336,7 @@ auth: cache: updateMinInterval: invalid `, - errKey: cfgKeyJWKSCacheUpdateMinInterval, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyJWKSCacheUpdateMinInterval, errMsg: "invalid duration", }, { @@ -190,7 +346,7 @@ auth: introspection: endpoint: ://invalid-url `, - errKey: cfgKeyIntrospectionEndpoint, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionEndpoint, errMsg: "missing protocol scheme", }, { @@ -201,7 +357,7 @@ auth: claimsCache: maxEntries: -1 `, - errKey: cfgKeyIntrospectionClaimsCacheMaxEntries, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionClaimsCacheMaxEntries, errMsg: "max entries should be non-negative", }, { @@ -212,7 +368,7 @@ auth: negativeCache: maxEntries: -1 `, - errKey: cfgKeyIntrospectionNegativeCacheMaxEntries, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionNegativeCacheMaxEntries, errMsg: "max entries should be non-negative", }, { @@ -223,7 +379,7 @@ auth: claimsCache: ttl: invalid `, - errKey: cfgKeyIntrospectionClaimsCacheTTL, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionClaimsCacheTTL, errMsg: "invalid duration", }, { @@ -234,7 +390,7 @@ auth: negativeCache: ttl: invalid `, - errKey: cfgKeyIntrospectionNegativeCacheTTL, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionNegativeCacheTTL, errMsg: "invalid duration", }, { @@ -244,11 +400,10 @@ auth: introspection: accessTokenScope: {} `, - errKey: cfgKeyIntrospectionAccessTokenScope, + errKey: cfgDefaultKeyPrefix + "." + cfgKeyIntrospectionAccessTokenScope, errMsg: " unable to cast", }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfgData := bytes.NewBufferString(tt.cfgData) diff --git a/go.mod b/go.mod index f94c124..6b58af9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/acronis/go-authkit go 1.20 require ( - github.com/acronis/go-appkit v1.10.0 + github.com/acronis/go-appkit v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 diff --git a/go.sum b/go.sum index 8cf9fa6..0a5e69f 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ code.cloudfoundry.org/bytefmt v0.0.0-20240808182453-a379845013d9 h1:8KlrGCtoaWaa code.cloudfoundry.org/bytefmt v0.0.0-20240808182453-a379845013d9/go.mod h1:eF2ZbltNI7Pv+8Cuyeksu9up5FN5konuH0trDJBuscw= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= -github.com/acronis/go-appkit v1.10.0 h1:OkRX48SaAQY9bz6mr2ZljFHZorxkEj9XcujUwqVIOw4= -github.com/acronis/go-appkit v1.10.0/go.mod h1:bDNkQ2ENdEz6vGHId22sE1NhZcScS61HY6GmVOkaS1s= +github.com/acronis/go-appkit v1.11.0 h1:pK8VSwCnUP5AaebvYoMD9NaJIGCxS3xG/OWoctqTph8= +github.com/acronis/go-appkit v1.11.0/go.mod h1:SCI3UtvrqJDk4QmCKGpYY5lE34TNY5vHamJ7THy0+6M= 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/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=