package oidc import ( "context" "strings" "sync" "time" "github.com/hashicorp/cap/oidc" "github.com/hashicorp/nomad/nomad/structs" ) // providerConfig returns the OIDC provider configuration for an OIDC // auth-method. func providerConfig(authMethod *structs.ACLAuthMethod) (*oidc.Config, error) { var algs []oidc.Alg if len(authMethod.Config.SigningAlgs) > 0 { for _, alg := range authMethod.Config.SigningAlgs { algs = append(algs, oidc.Alg(alg)) } } else { algs = []oidc.Alg{oidc.RS256} } return oidc.NewConfig( authMethod.Config.OIDCDiscoveryURL, authMethod.Config.OIDCClientID, oidc.ClientSecret(authMethod.Config.OIDCClientSecret), algs, authMethod.Config.AllowedRedirectURIs, oidc.WithAudiences(authMethod.Config.BoundAudiences...), oidc.WithProviderCA(strings.Join(authMethod.Config.DiscoveryCaPem, "\n")), ) } // ProviderCache is a cache for OIDC providers. OIDC providers are something // you don't want to recreate per-request since they make HTTP requests // when they're constructed. // // The ProviderCache purges a provider under two scenarios: (1) the // provider config is updated, and it is different and (2) after a set // amount of time (see cacheExpiry for value) in case the remote provider // configuration changed. type ProviderCache struct { providers map[string]*oidc.Provider mu sync.RWMutex // cancel is used to trigger cancellation of any routines when the cache // has been informed its parent process is exiting. cancel context.CancelFunc } // NewProviderCache should be used to initialize a provider cache. This // will start up background resources to manage the cache. func NewProviderCache() *ProviderCache { // Create a context, so a server that is shutting down can correctly // shut down the cache loop and OIDC provider background processes. ctx, cancel := context.WithCancel(context.Background()) result := &ProviderCache{ providers: map[string]*oidc.Provider{}, cancel: cancel, } // Start the cleanup timer go result.runCleanupLoop(ctx) return result } // Get returns the OIDC provider for the given auth method configuration. // This will initialize the provider if it isn't already in the cache or // if the configuration changed. func (c *ProviderCache) Get(authMethod *structs.ACLAuthMethod) (*oidc.Provider, error) { // No matter what we'll use the config of the arg method since we'll // use it to compare to existing (if exists) or initialize a new provider. oidcCfg, err := providerConfig(authMethod) if err != nil { return nil, err } // Get any current provider for the named auth-method. var ( current *oidc.Provider ok bool ) c.mu.RLock() current, ok = c.providers[authMethod.Name] c.mu.RUnlock() // If we have a current value, we want to compare hashes to detect changes. if ok { currentHash, err := current.ConfigHash() if err != nil { return nil, err } newHash, err := oidcCfg.Hash() if err != nil { return nil, err } // If the hashes match, this is can be classed as a cache hit. if currentHash == newHash { return current, nil } } // If we made it here, the provider isn't in the cache OR the config // changed. We therefore, need to initialize a new provider. newProvider, err := oidc.NewProvider(oidcCfg) if err != nil { return nil, err } c.mu.Lock() defer c.mu.Unlock() // If we have an old provider, clean up resources. if current != nil { current.Done() } c.providers[authMethod.Name] = newProvider return newProvider, nil } // Delete force deletes a single auth method from the cache by name. func (c *ProviderCache) Delete(name string) { c.mu.Lock() defer c.mu.Unlock() p, ok := c.providers[name] if ok { p.Done() delete(c.providers, name) } } // Shutdown stops any long-lived cache process and informs each OIDC provider // that they are done. This should be called whenever the Nomad server is // shutting down. func (c *ProviderCache) Shutdown() { c.cancel() c.clear() } // runCleanupLoop runs an infinite loop that clears the cache every cacheExpiry // duration. This ensures that we force refresh our provider info periodically // in case anything changes. func (c *ProviderCache) runCleanupLoop(ctx context.Context) { ticker := time.NewTicker(cacheExpiry) defer ticker.Stop() for { select { case <-ctx.Done(): return // We could be more clever and do a per-entry expiry but Nomad won't // have more than one ot two auth methods configured, therefore it's // not worth the added complexity. case <-ticker.C: c.clear() } } } // clear is called to delete all the providers in the cache. func (c *ProviderCache) clear() { c.mu.Lock() defer c.mu.Unlock() for _, p := range c.providers { p.Done() } c.providers = map[string]*oidc.Provider{} } // cacheExpiry is the duration after which the provider cache is reset. const cacheExpiry = 6 * time.Hour