// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package oidcauth

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
)

const (
	// TypeOIDC is the config type to specify if the OIDC authorization code
	// workflow is desired. The Authenticator methods GetAuthCodeURL and
	// ClaimsFromAuthCode are activated with the type.
	TypeOIDC = "oidc"

	// TypeJWT is the config type to specify if simple JWT decoding (via static
	// keys, JWKS, and OIDC discovery) is desired.  The Authenticator method
	// ClaimsFromJWT is activated with this type.
	TypeJWT = "jwt"
)

// Config is the collection of all settings that pertain to doing OIDC-based
// authentication and direct JWT-based authentication processes.
type Config struct {
	// Type defines which kind of authentication will be happening, OIDC-based
	// or JWT-based.  Allowed values are either 'oidc' or 'jwt'.
	//
	// Defaults to 'oidc' if unset.
	Type string

	// -------
	// common for type=oidc and type=jwt
	// -------

	// JWTSupportedAlgs is a list of supported signing algorithms. Defaults to
	// RS256.
	JWTSupportedAlgs []string

	// Comma-separated list of 'aud' claims that are valid for login; any match
	// is sufficient
	BoundAudiences []string

	// Mappings of claims (key) that will be copied to a metadata field
	// (value). Use this if the claim you are capturing is singular (such as an
	// attribute).
	//
	// When mapped, the values can be any of a number, string, or boolean and
	// will all be stringified when returned.
	ClaimMappings map[string]string

	// Mappings of claims (key) that will be copied to a metadata field
	// (value). Use this if the claim you are capturing is list-like (such as
	// groups).
	//
	// When mapped, the values in each list can be any of a number, string, or
	// boolean and will all be stringified when returned.
	ListClaimMappings map[string]string

	// OIDCDiscoveryURL is the OIDC Discovery URL, without any .well-known
	// component (base path).  Cannot be used with "JWKSURL" or
	// "JWTValidationPubKeys".
	OIDCDiscoveryURL string

	// OIDCDiscoveryCACert is the CA certificate or chain of certificates, in
	// PEM format, to use to validate connections to the OIDC Discovery URL. If
	// not set, system certificates are used.
	OIDCDiscoveryCACert string

	// -------
	// just for type=oidc
	// -------

	// OIDCClientID is the OAuth Client ID configured with your OIDC provider.
	//
	// Valid only if Type=oidc
	OIDCClientID string

	// The OAuth Client Secret configured with your OIDC provider.
	//
	// Valid only if Type=oidc
	OIDCClientSecret string

	// Comma-separated list of OIDC scopes
	//
	// Valid only if Type=oidc
	OIDCScopes []string

	// Space-separated list of OIDC Authorization Context Class Reference values
	//
	// Valid only if Type=oidc
	OIDCACRValues []string

	// Comma-separated list of allowed values for redirect_uri
	//
	// Valid only if Type=oidc
	AllowedRedirectURIs []string

	// Log received OIDC tokens and claims when debug-level logging is active.
	// Not recommended in production since sensitive information may be present
	// in OIDC responses.
	//
	// Valid only if Type=oidc
	VerboseOIDCLogging bool

	// -------
	// just for type=jwt
	// -------

	// JWKSURL is the JWKS URL to use to authenticate signatures. Cannot be
	// used with "OIDCDiscoveryURL" or "JWTValidationPubKeys".
	//
	// Valid only if Type=jwt
	JWKSURL string

	// JWKSCACert is the CA certificate or chain of certificates, in PEM
	// format, to use to validate connections to the JWKS URL. If not set,
	// system certificates are used.
	//
	// Valid only if Type=jwt
	JWKSCACert string

	// JWTValidationPubKeys is a list of PEM-encoded public keys to use to
	// authenticate signatures locally. Cannot be used with "JWKSURL" or
	// "OIDCDiscoveryURL".
	//
	// Valid only if Type=jwt
	JWTValidationPubKeys []string

	// BoundIssuer is the value against which to match the 'iss' claim in a
	// JWT.  Optional.
	//
	// Valid only if Type=jwt
	BoundIssuer string

	// Duration in seconds of leeway when validating expiration of
	// a token to account for clock skew.
	//
	// Defaults to 150 (2.5 minutes) if set to 0 and can be disabled if set to -1.`,
	//
	// Valid only if Type=jwt
	ExpirationLeeway time.Duration

	// Duration in seconds of leeway when validating not before values of a
	// token to account for clock skew.
	//
	// Defaults to 150 (2.5 minutes) if set to 0 and can be disabled if set to
	// -1.`,
	//
	// Valid only if Type=jwt
	NotBeforeLeeway time.Duration

	// Duration in seconds of leeway when validating all claims to account for
	// clock skew.
	//
	// Defaults to 60 (1 minute) if set to 0 and can be disabled if set to
	// -1.`,
	//
	// Valid only if Type=jwt
	ClockSkewLeeway time.Duration
}

// Validate returns an error if the config is not valid.
func (c *Config) Validate() error {
	validateCtx, validateCtxCancel := context.WithCancel(context.Background())
	defer validateCtxCancel()

	switch c.Type {
	case TypeOIDC, "":
		// required
		switch {
		case c.OIDCDiscoveryURL == "":
			return fmt.Errorf("'OIDCDiscoveryURL' must be set for type %q", c.Type)
		case c.OIDCClientID == "":
			return fmt.Errorf("'OIDCClientID' must be set for type %q", c.Type)
		case c.OIDCClientSecret == "":
			return fmt.Errorf("'OIDCClientSecret' must be set for type %q", c.Type)
		case len(c.AllowedRedirectURIs) == 0:
			return fmt.Errorf("'AllowedRedirectURIs' must be set for type %q", c.Type)
		}

		// not allowed
		switch {
		case c.JWKSURL != "":
			return fmt.Errorf("'JWKSURL' must not be set for type %q", c.Type)
		case c.JWKSCACert != "":
			return fmt.Errorf("'JWKSCACert' must not be set for type %q", c.Type)
		case len(c.JWTValidationPubKeys) != 0:
			return fmt.Errorf("'JWTValidationPubKeys' must not be set for type %q", c.Type)
		case c.BoundIssuer != "":
			return fmt.Errorf("'BoundIssuer' must not be set for type %q", c.Type)
		case c.ExpirationLeeway != 0:
			return fmt.Errorf("'ExpirationLeeway' must not be set for type %q", c.Type)
		case c.NotBeforeLeeway != 0:
			return fmt.Errorf("'NotBeforeLeeway' must not be set for type %q", c.Type)
		case c.ClockSkewLeeway != 0:
			return fmt.Errorf("'ClockSkewLeeway' must not be set for type %q", c.Type)
		}

		var bad []string
		for _, allowed := range c.AllowedRedirectURIs {
			if _, err := url.Parse(allowed); err != nil {
				bad = append(bad, allowed)
			}
		}
		if len(bad) > 0 {
			return fmt.Errorf("Invalid AllowedRedirectURIs provided: %v", bad)
		}

	case TypeJWT:
		// not allowed
		switch {
		case c.OIDCClientID != "":
			return fmt.Errorf("'OIDCClientID' must not be set for type %q", c.Type)
		case c.OIDCClientSecret != "":
			return fmt.Errorf("'OIDCClientSecret' must not be set for type %q", c.Type)
		case len(c.OIDCScopes) != 0:
			return fmt.Errorf("'OIDCScopes' must not be set for type %q", c.Type)
		case len(c.OIDCACRValues) != 0:
			return fmt.Errorf("'OIDCACRValues' must not be set for type %q", c.Type)
		case len(c.AllowedRedirectURIs) != 0:
			return fmt.Errorf("'AllowedRedirectURIs' must not be set for type %q", c.Type)
		case c.VerboseOIDCLogging:
			return fmt.Errorf("'VerboseOIDCLogging' must not be set for type %q", c.Type)
		}

		methodCount := 0
		if c.OIDCDiscoveryURL != "" {
			methodCount++
		}
		if len(c.JWTValidationPubKeys) != 0 {
			methodCount++
		}
		if c.JWKSURL != "" {
			methodCount++
		}

		if methodCount != 1 {
			return fmt.Errorf("exactly one of 'JWTValidationPubKeys', 'JWKSURL', or 'OIDCDiscoveryURL' must be set for type %q", c.Type)
		}

		if c.JWKSURL != "" {
			httpClient, err := createHTTPClient(c.JWKSCACert)
			if err != nil {
				return fmt.Errorf("error checking JWKSCACert: %v", err)
			}

			ctx := contextWithHttpClient(validateCtx, httpClient)
			keyset := oidc.NewRemoteKeySet(ctx, c.JWKSURL)

			// Try to verify a correctly formatted JWT. The signature will fail
			// to match, but other errors with fetching the remote keyset
			// should be reported.
			_, err = keyset.VerifySignature(ctx, testJWT)
			if err == nil {
				err = errors.New("unexpected verification of JWT")
			}

			if !strings.Contains(err.Error(), "failed to verify id token signature") {
				return fmt.Errorf("error checking JWKSURL: %v", err)
			}
		} else if c.JWKSCACert != "" {
			return fmt.Errorf("'JWKSCACert' should not be set unless 'JWKSURL' is set")
		}

		if len(c.JWTValidationPubKeys) != 0 {
			for i, v := range c.JWTValidationPubKeys {
				if _, err := parsePublicKeyPEM([]byte(v)); err != nil {
					return fmt.Errorf("error parsing public key JWTValidationPubKeys[%d]: %v", i, err)
				}
			}
		}

	default:
		return fmt.Errorf("authenticator type should be %q or %q", TypeOIDC, TypeJWT)
	}

	if c.OIDCDiscoveryURL != "" {
		httpClient, err := createHTTPClient(c.OIDCDiscoveryCACert)
		if err != nil {
			return fmt.Errorf("error checking OIDCDiscoveryCACert: %v", err)
		}

		ctx := contextWithHttpClient(validateCtx, httpClient)
		if _, err := oidc.NewProvider(ctx, c.OIDCDiscoveryURL); err != nil {
			return fmt.Errorf("error checking OIDCDiscoveryURL: %v", err)
		}
	} else if c.OIDCDiscoveryCACert != "" {
		return fmt.Errorf("'OIDCDiscoveryCACert' should not be set unless 'OIDCDiscoveryURL' is set")
	}

	for _, a := range c.JWTSupportedAlgs {
		switch a {
		case oidc.RS256, oidc.RS384, oidc.RS512,
			oidc.ES256, oidc.ES384, oidc.ES512,
			oidc.PS256, oidc.PS384, oidc.PS512:
		default:
			return fmt.Errorf("Invalid supported algorithm: %s", a)
		}
	}

	if len(c.ClaimMappings) > 0 {
		targets := make(map[string]bool)
		for _, mappedKey := range c.ClaimMappings {
			if targets[mappedKey] {
				return fmt.Errorf("ClaimMappings contains multiple mappings for key %q", mappedKey)
			}
			targets[mappedKey] = true
		}
	}

	if len(c.ListClaimMappings) > 0 {
		targets := make(map[string]bool)
		for _, mappedKey := range c.ListClaimMappings {
			if targets[mappedKey] {
				return fmt.Errorf("ListClaimMappings contains multiple mappings for key %q", mappedKey)
			}
			targets[mappedKey] = true
		}
	}

	return nil
}

const (
	authUnconfigured = iota
	authStaticKeys
	authJWKS
	authOIDCDiscovery
	authOIDCFlow
)

// authType classifies the authorization type/flow based on config parameters.
// It is only valid to invoke if Validate() returns a nil error.
func (c *Config) authType() int {
	switch {
	case len(c.JWTValidationPubKeys) > 0:
		return authStaticKeys
	case c.JWKSURL != "":
		return authJWKS
	case c.OIDCDiscoveryURL != "":
		if c.OIDCClientID != "" && c.OIDCClientSecret != "" {
			return authOIDCFlow
		}
		return authOIDCDiscovery
	default:
		return authUnconfigured
	}
}

const testJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Hf3E3iCHzqC5QIQ0nCqS1kw78IiQTRVzsLTuKoDIpdk"
