// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Package oidcupstreamwatcher implements a controller which watches OIDCIdentityProviders.
package oidcupstreamwatcher

import (
	"context"
	"crypto/x509"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"

	coreosoidc "github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/equality"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/util/cache"
	"k8s.io/apimachinery/pkg/util/sets"
	corev1informers "k8s.io/client-go/informers/core/v1"

	idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
	oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
	supervisorclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
	idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1"
	pinnipedcontroller "go.pinniped.dev/internal/controller"
	"go.pinniped.dev/internal/controller/conditionsutil"
	"go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers"
	"go.pinniped.dev/internal/controller/tlsconfigutil"
	"go.pinniped.dev/internal/controllerlib"
	"go.pinniped.dev/internal/federationdomain/upstreamprovider"
	"go.pinniped.dev/internal/net/phttp"
	"go.pinniped.dev/internal/plog"
	"go.pinniped.dev/internal/upstreamoidc"
)

const (
	// Setup for the name of our controller in logs.
	oidcControllerName = "oidc-upstream-observer"

	// Constants related to the client credentials Secret.
	oidcClientSecretType corev1.SecretType = "secrets.pinniped.dev/oidc-client"

	clientIDDataKey     = "clientID"
	clientSecretDataKey = "clientSecret"

	// Constants related to the OIDC provider discovery cache. These do not affect the cache of JWKS.
	oidcValidatorCacheTTL = 15 * time.Minute

	// Constants related to conditions.
	typeClientCredentialsSecretValid       = "ClientCredentialsSecretValid" //nolint:gosec // this is not a credential
	typeAdditionalAuthorizeParametersValid = "AdditionalAuthorizeParametersValid"
	typeOIDCDiscoverySucceeded             = "OIDCDiscoverySucceeded"

	reasonUnreachable             = "Unreachable"
	reasonInvalidResponse         = "InvalidResponse"
	reasonDisallowedParameterName = "DisallowedParameterName"
	allParamNamesAllowedMsg       = "additionalAuthorizeParameters parameter names are allowed"
)

var (
	disallowedAdditionalAuthorizeParameters = map[string]bool{ //nolint:gochecknoglobals
		// Reject these AdditionalAuthorizeParameters to avoid allowing the user's config to overwrite the parameters
		// that are always used by Pinniped in authcode authorization requests. The OIDC library used would otherwise
		// happily treat the user's config as an override. Users can already set the "client_id" and "scope" params
		// using other settings, and the others never make sense to override. This map should be treated as read-only
		// since it is a global variable.
		"response_type":         true,
		"scope":                 true,
		"client_id":             true,
		"state":                 true,
		"nonce":                 true,
		"code_challenge":        true,
		"code_challenge_method": true,
		"redirect_uri":          true,

		// Reject "hd" for now because it is not safe to use with Google's OIDC provider until Pinniped also
		// performs the corresponding validation on the ID token.
		"hd": true,
	}
)

// UpstreamOIDCIdentityProviderICache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations.
type UpstreamOIDCIdentityProviderICache interface {
	SetOIDCIdentityProviders([]upstreamprovider.UpstreamOIDCIdentityProviderI)
}

// oidcDiscoveryCacheKey is the type of keys in an oidcDiscoveryCache.
type oidcDiscoveryCacheKey struct {
	issuer       string
	caBundleHash tlsconfigutil.CABundleHash
}

// oidcDiscoveryCacheValue is the type of cache entries in an oidcDiscoveryCache.
type oidcDiscoveryCacheValue struct {
	provider *coreosoidc.Provider
	client   *http.Client
}

// oidcDiscoveryCache caches the discovered provider along with the http Client to use for making calls to that provider,
// for a particular combination OIDC issuer and CA bundle for that issuer.
type oidcDiscoveryCache interface {
	getProvider(oidcDiscoveryCacheKey) *oidcDiscoveryCacheValue
	putProvider(oidcDiscoveryCacheKey, *oidcDiscoveryCacheValue)
}

// ttlProviderCache caches the *coreosoidc.Provider associated with a particular issuer/TLS configuration,
// for a limited time (TTL).
type ttlProviderCache struct{ cache *cache.Expiring }

// ttlProviderCache implements the oidcDiscoveryCache interface.
var _ oidcDiscoveryCache = (*ttlProviderCache)(nil)

// getProvider gets an entry from the ttlProviderCache.
func (c *ttlProviderCache) getProvider(key oidcDiscoveryCacheKey) *oidcDiscoveryCacheValue {
	if result, ok := c.cache.Get(key); ok {
		entry := result.(*oidcDiscoveryCacheValue)
		return entry
	}
	return nil
}

// putProvider adds to the ttlProviderCache for a limited period of time.
func (c *ttlProviderCache) putProvider(key oidcDiscoveryCacheKey, value *oidcDiscoveryCacheValue) {
	c.cache.Set(key, value, oidcValidatorCacheTTL)
}

type UserInfoEndpointConfigI interface {
	IgnoreUserInfoEndpoint(issuerURL string) bool
}

type IgnoreUserInfoEndpointForExactIssuerMatches struct {
	Issuers sets.Set[string] // a set of issuer URLs
}

func (i *IgnoreUserInfoEndpointForExactIssuerMatches) IgnoreUserInfoEndpoint(issuerURL string) bool {
	return i.Issuers.Has(issuerURL)
}

type GlobalOIDCConfig struct {
	UserInfoEndpointConfig UserInfoEndpointConfigI
}

type oidcWatcherController struct {
	cache                        UpstreamOIDCIdentityProviderICache
	log                          plog.Logger
	client                       supervisorclientset.Interface
	oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer
	secretInformer               corev1informers.SecretInformer
	configMapInformer            corev1informers.ConfigMapInformer
	validatorCache               oidcDiscoveryCache
	globalOIDCConfig             GlobalOIDCConfig
}

// New instantiates a new controllerlib.Controller which will populate the provided UpstreamOIDCIdentityProviderICache.
func New(
	idpCache UpstreamOIDCIdentityProviderICache,
	client supervisorclientset.Interface,
	oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer,
	secretInformer corev1informers.SecretInformer,
	configMapInformer corev1informers.ConfigMapInformer,
	log plog.Logger,
	withInformer pinnipedcontroller.WithInformerOptionFunc,
	validatorCache *cache.Expiring,
	globalOIDCConfig GlobalOIDCConfig,
) controllerlib.Controller {
	c := oidcWatcherController{
		cache:                        idpCache,
		log:                          log.WithName(oidcControllerName),
		client:                       client,
		oidcIdentityProviderInformer: oidcIdentityProviderInformer,
		secretInformer:               secretInformer,
		configMapInformer:            configMapInformer,
		validatorCache:               &ttlProviderCache{cache: validatorCache},
		globalOIDCConfig:             globalOIDCConfig,
	}
	return controllerlib.New(
		controllerlib.Config{Name: oidcControllerName, Syncer: &c},
		withInformer(
			oidcIdentityProviderInformer,
			pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
			controllerlib.InformerOption{},
		),
		withInformer(
			secretInformer,
			pinnipedcontroller.MatchAnySecretOfTypesFilter(
				[]corev1.SecretType{
					oidcClientSecretType,
					corev1.SecretTypeOpaque,
					corev1.SecretTypeTLS,
				},
				pinnipedcontroller.SingletonQueue()),
			controllerlib.InformerOption{},
		),
		withInformer(
			configMapInformer,
			pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
			controllerlib.InformerOption{},
		),
	)
}

// Sync implements controllerlib.Syncer.
func (c *oidcWatcherController) Sync(ctx controllerlib.Context) error {
	actualUpstreams, err := c.oidcIdentityProviderInformer.Lister().List(labels.Everything())
	if err != nil {
		return fmt.Errorf("failed to list OIDCIdentityProviders: %w", err)
	}

	requeue := false
	validatedUpstreams := make([]upstreamprovider.UpstreamOIDCIdentityProviderI, 0, len(actualUpstreams))
	for _, upstream := range actualUpstreams {
		valid := c.validateUpstream(ctx, upstream)
		if valid == nil {
			requeue = true
		} else {
			validatedUpstreams = append(validatedUpstreams, upstreamprovider.UpstreamOIDCIdentityProviderI(valid))
		}
	}
	c.cache.SetOIDCIdentityProviders(validatedUpstreams)
	if requeue {
		return controllerlib.ErrSyntheticRequeue
	}
	return nil
}

// validateUpstream validates the provided idpv1alpha1.OIDCIdentityProvider and returns the validated configuration as a
// provider.UpstreamOIDCIdentityProvider. As a side effect, it also updates the status of the idpv1alpha1.OIDCIdentityProvider.
func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upstream *idpv1alpha1.OIDCIdentityProvider) *upstreamoidc.ProviderConfig {
	authorizationConfig := upstream.Spec.AuthorizationConfig

	additionalAuthcodeAuthorizeParameters := map[string]string{}
	var rejectedAuthcodeAuthorizeParameters []string
	for _, p := range authorizationConfig.AdditionalAuthorizeParameters {
		if disallowedAdditionalAuthorizeParameters[p.Name] {
			rejectedAuthcodeAuthorizeParameters = append(rejectedAuthcodeAuthorizeParameters, p.Name)
		} else {
			additionalAuthcodeAuthorizeParameters[p.Name] = p.Value
		}
	}

	result := upstreamoidc.ProviderConfig{
		Name: upstream.Name,
		Config: &oauth2.Config{
			Scopes: computeScopes(authorizationConfig.AdditionalScopes),
		},
		UsernameClaim:            upstream.Spec.Claims.Username,
		GroupsClaim:              upstream.Spec.Claims.Groups,
		AllowPasswordGrant:       authorizationConfig.AllowPasswordGrant,
		AdditionalAuthcodeParams: additionalAuthcodeAuthorizeParameters,
		AdditionalClaimMappings:  upstream.Spec.Claims.AdditionalClaimMappings,
		ResourceUID:              upstream.UID,
		IgnoreUserInfoEndpoint:   c.globalOIDCConfig.UserInfoEndpointConfig.IgnoreUserInfoEndpoint(upstream.Spec.Issuer),
	}

	conditions := []*metav1.Condition{
		c.validateSecret(upstream, &result),
	}
	conditions = append(conditions, c.validateIssuer(ctx.Context, upstream, &result)...)

	if len(rejectedAuthcodeAuthorizeParameters) > 0 {
		conditions = append(conditions, &metav1.Condition{
			Type:   typeAdditionalAuthorizeParametersValid,
			Status: metav1.ConditionFalse,
			Reason: reasonDisallowedParameterName,
			Message: fmt.Sprintf("the following additionalAuthorizeParameters are not allowed: %s",
				strings.Join(rejectedAuthcodeAuthorizeParameters, ",")),
		})
	} else {
		conditions = append(conditions, &metav1.Condition{
			Type:    typeAdditionalAuthorizeParametersValid,
			Status:  metav1.ConditionTrue,
			Reason:  conditionsutil.ReasonSuccess,
			Message: allParamNamesAllowedMsg,
		})
	}

	hadErrorCondition := c.updateStatus(ctx.Context, upstream, conditions)

	if hadErrorCondition {
		return nil
	}
	return &result
}

// validateSecret validates the .spec.client.secretName field and returns the appropriate ClientCredentialsSecretValid condition.
func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *metav1.Condition {
	secretName := upstream.Spec.Client.SecretName

	// Fetch the Secret from informer cache.
	secret, err := c.secretInformer.Lister().Secrets(upstream.Namespace).Get(secretName)
	if err != nil {
		return &metav1.Condition{
			Type:    typeClientCredentialsSecretValid,
			Status:  metav1.ConditionFalse,
			Reason:  upstreamwatchers.ReasonNotFound,
			Message: err.Error(),
		}
	}

	// Validate the secret .type field.
	if secret.Type != oidcClientSecretType {
		return &metav1.Condition{
			Type:    typeClientCredentialsSecretValid,
			Status:  metav1.ConditionFalse,
			Reason:  upstreamwatchers.ReasonWrongType,
			Message: fmt.Sprintf("referenced Secret %q has wrong type %q (should be %q)", secretName, secret.Type, oidcClientSecretType),
		}
	}

	// Validate the secret .data field.
	clientID := secret.Data[clientIDDataKey]
	clientSecret := secret.Data[clientSecretDataKey]
	if len(clientID) == 0 || len(clientSecret) == 0 {
		return &metav1.Condition{
			Type:    typeClientCredentialsSecretValid,
			Status:  metav1.ConditionFalse,
			Reason:  upstreamwatchers.ReasonMissingKeys,
			Message: fmt.Sprintf("referenced Secret %q is missing required keys %q", secretName, []string{clientIDDataKey, clientSecretDataKey}),
		}
	}

	// If everything is valid, update the result and set the condition to true.
	result.Config.ClientID = string(clientID)
	result.Config.ClientSecret = string(clientSecret)
	return &metav1.Condition{
		Type:    typeClientCredentialsSecretValid,
		Status:  metav1.ConditionTrue,
		Reason:  conditionsutil.ReasonSuccess,
		Message: "loaded client credentials",
	}
}

// validateIssuer validates the .spec.issuer field, performs OIDC discovery, and returns the appropriate OIDCDiscoverySucceeded condition.
func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *idpv1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) []*metav1.Condition {
	tlsCondition, caBundle := tlsconfigutil.ValidateTLSConfig(
		tlsconfigutil.TLSSpecForSupervisor(upstream.Spec.TLS),
		"spec.tls",
		upstream.Namespace,
		c.secretInformer,
		c.configMapInformer)

	// When the TLS config is invalid, return some error conditions.
	if tlsCondition.Reason != conditionsutil.ReasonSuccess {
		return []*metav1.Condition{
			{
				Type:    typeOIDCDiscoverySucceeded,
				Status:  metav1.ConditionUnknown,
				Reason:  conditionsutil.ReasonUnableToValidate,
				Message: conditionsutil.MessageUnableToValidate,
			},
			tlsCondition,
		}
	}

	var discoveredProvider *coreosoidc.Provider
	var httpClient *http.Client

	// Get the discovered provider and HTTP client from cache, if they are found in the cache.
	cacheKey := oidcDiscoveryCacheKey{
		issuer:       upstream.Spec.Issuer,
		caBundleHash: caBundle.Hash(),
	}
	if cacheEntry := c.validatorCache.getProvider(cacheKey); cacheEntry != nil {
		discoveredProvider = cacheEntry.provider
		httpClient = cacheEntry.client
		c.log.WithValues(
			"namespace", upstream.Namespace,
			"name", upstream.Name,
			"issuer", upstream.Spec.Issuer,
		).Debug("found previous OIDC discovery result in cache")
	}

	// If the provider does not exist in the cache, do a fresh discovery lookup and save to the cache.
	if discoveredProvider == nil {
		httpClient = defaultClientShortTimeout(caBundle.CertPool())

		_, issuerURLCondition := validateHTTPSURL(upstream.Spec.Issuer, "issuer", reasonUnreachable)
		if issuerURLCondition != nil {
			return []*metav1.Condition{issuerURLCondition, tlsCondition}
		}

		var err error
		discoveredProvider, err = coreosoidc.NewProvider(coreosoidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer)
		if err != nil {
			c.log.WithValues(
				"namespace", upstream.Namespace,
				"name", upstream.Name,
				"issuer", upstream.Spec.Issuer,
			).Error("failed to perform OIDC discovery", err)
			return []*metav1.Condition{
				{
					Type:   typeOIDCDiscoverySucceeded,
					Status: metav1.ConditionFalse,
					Reason: reasonUnreachable,
					Message: fmt.Sprintf("failed to perform OIDC discovery against %q:\n%s",
						upstream.Spec.Issuer, pinnipedcontroller.TruncateMostLongErr(err)),
				},
				tlsCondition,
			}
		}

		// Update the cache with the newly discovered value.
		c.validatorCache.putProvider(cacheKey, &oidcDiscoveryCacheValue{provider: discoveredProvider, client: httpClient})
	}

	// Get the revocation endpoint, if there is one. Many providers do not offer a revocation endpoint.
	var additionalDiscoveryClaims struct {
		// "revocation_endpoint" is specified by https://datatracker.ietf.org/doc/html/rfc8414#section-2
		RevocationEndpoint string `json:"revocation_endpoint"`
	}
	if err := discoveredProvider.Claims(&additionalDiscoveryClaims); err != nil {
		// This shouldn't actually happen because the above call to NewProvider() would have already returned this error.
		return []*metav1.Condition{
			{
				Type:    typeOIDCDiscoverySucceeded,
				Status:  metav1.ConditionFalse,
				Reason:  reasonInvalidResponse,
				Message: fmt.Sprintf("failed to unmarshal OIDC discovery response from %q:\n%s", upstream.Spec.Issuer, pinnipedcontroller.TruncateMostLongErr(err)),
			},
			tlsCondition,
		}
	}
	if additionalDiscoveryClaims.RevocationEndpoint != "" {
		// Found a revocation URL. Validate it.
		revocationURL, revocationURLCondition := validateHTTPSURL(
			additionalDiscoveryClaims.RevocationEndpoint,
			"revocation endpoint",
			reasonInvalidResponse,
		)
		if revocationURLCondition != nil {
			return []*metav1.Condition{revocationURLCondition, tlsCondition}
		}
		// Remember the URL for later use.
		result.RevocationURL = revocationURL
	}

	_, authorizeURLCondition := validateHTTPSURL(
		discoveredProvider.Endpoint().AuthURL,
		"authorization endpoint",
		reasonInvalidResponse,
	)
	if authorizeURLCondition != nil {
		return []*metav1.Condition{authorizeURLCondition, tlsCondition}
	}

	_, tokenURLCondition := validateHTTPSURL(
		discoveredProvider.Endpoint().TokenURL,
		"token endpoint",
		reasonInvalidResponse,
	)
	if tokenURLCondition != nil {
		return []*metav1.Condition{tokenURLCondition, tlsCondition}
	}

	// If everything is valid, update the result and set the condition to true.
	result.Config.Endpoint = discoveredProvider.Endpoint()
	result.Provider = discoveredProvider
	result.Client = httpClient
	return []*metav1.Condition{
		{
			Type:    typeOIDCDiscoverySucceeded,
			Status:  metav1.ConditionTrue,
			Reason:  conditionsutil.ReasonSuccess,
			Message: "discovered issuer configuration",
		},
		tlsCondition,
	}
}

func (c *oidcWatcherController) updateStatus(
	ctx context.Context,
	upstream *idpv1alpha1.OIDCIdentityProvider,
	conditions []*metav1.Condition,
) bool {
	log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name)
	updated := upstream.DeepCopy()

	hadErrorCondition := conditionsutil.MergeConditions(
		conditions,
		&updated.Status.Conditions,
		upstream.Generation,
		metav1.Now(),
		log,
	)

	updated.Status.Phase = idpv1alpha1.PhaseReady
	if hadErrorCondition {
		updated.Status.Phase = idpv1alpha1.PhaseError
	}

	if equality.Semantic.DeepEqual(upstream, updated) {
		return hadErrorCondition
	}

	_, err := c.client.
		IDPV1alpha1().
		OIDCIdentityProviders(upstream.Namespace).
		UpdateStatus(ctx, updated, metav1.UpdateOptions{})
	if err != nil {
		log.Error("failed to update status", err)
	}

	return hadErrorCondition
}

func defaultClientShortTimeout(rootCAs *x509.CertPool) *http.Client {
	c := phttp.Default(rootCAs)
	c.Timeout = time.Minute
	return c
}

func computeScopes(additionalScopes []string) []string {
	// If none are set then provide a reasonable default which only tries to use scopes defined in the OIDC spec.
	if len(additionalScopes) == 0 {
		return []string{oidcapi.ScopeOpenID, oidcapi.ScopeOfflineAccess, oidcapi.ScopeEmail, oidcapi.ScopeProfile}
	}

	// Otherwise, first compute the unique set of scopes, including "openid" (de-duplicate).
	set := sets.NewString()
	set.Insert(oidcapi.ScopeOpenID)
	for _, s := range additionalScopes {
		set.Insert(s)
	}

	// Return the set as a sorted list.
	return set.List()
}

func validateHTTPSURL(maybeHTTPSURL, endpointType, reason string) (*url.URL, *metav1.Condition) {
	parsedURL, err := url.Parse(maybeHTTPSURL)
	if err != nil {
		return nil, &metav1.Condition{
			Type:    typeOIDCDiscoverySucceeded,
			Status:  metav1.ConditionFalse,
			Reason:  reason,
			Message: fmt.Sprintf("failed to parse %s URL: %v", endpointType, pinnipedcontroller.TruncateMostLongErr(err)),
		}
	}
	if parsedURL.Scheme != "https" {
		return nil, &metav1.Condition{
			Type:    typeOIDCDiscoverySucceeded,
			Status:  metav1.ConditionFalse,
			Reason:  reason,
			Message: fmt.Sprintf(`%s URL '%s' must have "https" scheme, not %q`, endpointType, maybeHTTPSURL, parsedURL.Scheme),
		}
	}
	if len(parsedURL.Query()) != 0 || parsedURL.Fragment != "" {
		return nil, &metav1.Condition{
			Type:    typeOIDCDiscoverySucceeded,
			Status:  metav1.ConditionFalse,
			Reason:  reason,
			Message: fmt.Sprintf(`%s URL '%s' cannot contain query or fragment component`, endpointType, maybeHTTPSURL),
		}
	}
	return parsedURL, nil
}
