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

package oidcauth

import (
	"strings"
	"testing"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest"
	"github.com/stretchr/testify/require"
)

func TestConfigValidate(t *testing.T) {
	type testcase struct {
		config         Config
		expectAuthType int
		expectErr      string
	}

	srv := oidcauthtest.Start(t)

	oidcCases := map[string]testcase{
		"all required": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectAuthType: authOIDCFlow,
		},
		"missing required OIDCDiscoveryURL": {
			config: Config{
				Type: TypeOIDC,
				// OIDCDiscoveryURL:    srv.Addr(),
				// OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectErr: "must be set for type",
		},
		"missing required OIDCClientID": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				// OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectErr: "must be set for type",
		},
		"missing required OIDCClientSecret": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				// OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectErr: "must be set for type",
		},
		"missing required AllowedRedirectURIs": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{},
			},
			expectErr: "must be set for type",
		},
		"incompatible with JWKSURL": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				JWKSURL:             srv.Addr() + "/certs",
			},
			expectErr: "must not be set for type",
		},
		"incompatible with JWKSCACert": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				JWKSCACert:          srv.CACert(),
			},
			expectErr: "must not be set for type",
		},
		"incompatible with JWTValidationPubKeys": {
			config: Config{
				Type:                 TypeOIDC,
				OIDCDiscoveryURL:     srv.Addr(),
				OIDCDiscoveryCACert:  srv.CACert(),
				OIDCClientID:         "abc",
				OIDCClientSecret:     "def",
				AllowedRedirectURIs:  []string{"http://foo.test"},
				JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectErr: "must not be set for type",
		},
		"incompatible with BoundIssuer": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				BoundIssuer:         "foo",
			},
			expectErr: "must not be set for type",
		},
		"incompatible with ExpirationLeeway": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				ExpirationLeeway:    1 * time.Second,
			},
			expectErr: "must not be set for type",
		},
		"incompatible with NotBeforeLeeway": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				NotBeforeLeeway:     1 * time.Second,
			},
			expectErr: "must not be set for type",
		},
		"incompatible with ClockSkewLeeway": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				ClockSkewLeeway:     1 * time.Second,
			},
			expectErr: "must not be set for type",
		},
		"bad discovery cert": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: oidcBadCACerts,
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectErr: "certificate signed by unknown authority",
		},
		"garbage discovery cert": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: garbageCACert,
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectErr: "could not parse CA PEM value successfully",
		},
		"good discovery cert": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
			},
			expectAuthType: authOIDCFlow,
		},
		"valid redirect uris": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{
					"http://foo.test",
					"https://example.com",
					"https://evilcorp.com:8443",
				},
			},
			expectAuthType: authOIDCFlow,
		},
		"invalid redirect uris": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{
					"%%%%",
					"http://foo.test",
					"https://example.com",
					"https://evilcorp.com:8443",
				},
			},
			expectErr: "Invalid AllowedRedirectURIs provided: [%%%%]",
		},
		"valid algorithm": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				JWTSupportedAlgs: []string{
					oidc.RS256, oidc.RS384, oidc.RS512,
					oidc.ES256, oidc.ES384, oidc.ES512,
					oidc.PS256, oidc.PS384, oidc.PS512,
				},
			},
			expectAuthType: authOIDCFlow,
		},
		"invalid algorithm": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				JWTSupportedAlgs: []string{
					oidc.RS256, oidc.RS384, oidc.RS512,
					oidc.ES256, oidc.ES384, oidc.ES512,
					oidc.PS256, oidc.PS384, oidc.PS512,
					"foo",
				},
			},
			expectErr: "Invalid supported algorithm",
		},
		"valid claim mappings": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectAuthType: authOIDCFlow,
		},
		"invalid repeated value claim mappings": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"bling":        "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectErr: "ClaimMappings contains multiple mappings for key",
		},
		"invalid repeated list claim mappings": {
			config: Config{
				Type:                TypeOIDC,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				OIDCClientID:        "abc",
				OIDCClientSecret:    "def",
				AllowedRedirectURIs: []string{"http://foo.test"},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"bling":        "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectErr: "ListClaimMappings contains multiple mappings for key",
		},
	}

	jwtCases := map[string]testcase{
		"all required for oidc discovery": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
			},
			expectAuthType: authOIDCDiscovery,
		},
		"all required for jwks": {
			config: Config{
				Type:       TypeJWT,
				JWKSURL:    srv.Addr() + "/certs",
				JWKSCACert: srv.CACert(), // needed to avoid self signed cert issue
			},
			expectAuthType: authJWKS,
		},
		"all required for public keys": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectAuthType: authStaticKeys,
		},
		"incompatible with OIDCClientID": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				OIDCClientID:         "abc",
			},
			expectErr: "must not be set for type",
		},
		"incompatible with OIDCClientSecret": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				OIDCClientSecret:     "abc",
			},
			expectErr: "must not be set for type",
		},
		"incompatible with OIDCScopes": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				OIDCScopes:           []string{"blah"},
			},
			expectErr: "must not be set for type",
		},
		"incompatible with OIDCACRValues": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				OIDCACRValues:        []string{"acr1"},
			},
			expectErr: "must not be set for type",
		},
		"incompatible with AllowedRedirectURIs": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				AllowedRedirectURIs:  []string{"http://foo.test"},
			},
			expectErr: "must not be set for type",
		},
		"incompatible with VerboseOIDCLogging": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				VerboseOIDCLogging:   true,
			},
			expectErr: "must not be set for type",
		},
		"too many methods (discovery + jwks)": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				JWKSURL:             srv.Addr() + "/certs",
				JWKSCACert:          srv.CACert(),
				// JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectErr: "exactly one of",
		},
		"too many methods (discovery + pubkeys)": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				// JWKSURL:          srv.Addr() + "/certs",
				// JWKSCACert:       srv.CACert(),
				JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectErr: "exactly one of",
		},
		"too many methods (jwks + pubkeys)": {
			config: Config{
				Type: TypeJWT,
				// OIDCDiscoveryURL:     srv.Addr(),
				// OIDCDiscoveryCACert:  srv.CACert(),
				JWKSURL:              srv.Addr() + "/certs",
				JWKSCACert:           srv.CACert(),
				JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectErr: "exactly one of",
		},
		"too many methods (discovery + jwks + pubkeys)": {
			config: Config{
				Type:                 TypeJWT,
				OIDCDiscoveryURL:     srv.Addr(),
				OIDCDiscoveryCACert:  srv.CACert(),
				JWKSURL:              srv.Addr() + "/certs",
				JWKSCACert:           srv.CACert(),
				JWTValidationPubKeys: []string{testJWTPubKey},
			},
			expectErr: "exactly one of",
		},
		"incompatible with JWKSCACert": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
				JWKSCACert:          srv.CACert(),
			},
			expectErr: "should not be set unless",
		},
		"invalid pubkey": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKeyBad},
			},
			expectErr: "error parsing public key",
		},
		"incompatible with OIDCDiscoveryCACert": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				OIDCDiscoveryCACert:  srv.CACert(),
			},
			expectErr: "should not be set unless",
		},
		"bad discovery cert": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: oidcBadCACerts,
			},
			expectErr: "certificate signed by unknown authority",
		},
		"good discovery cert": {
			config: Config{
				Type:                TypeJWT,
				OIDCDiscoveryURL:    srv.Addr(),
				OIDCDiscoveryCACert: srv.CACert(),
			},
			expectAuthType: authOIDCDiscovery,
		},
		"jwks invalid 404": {
			config: Config{
				Type:       TypeJWT,
				JWKSURL:    srv.Addr() + "/certs_missing",
				JWKSCACert: srv.CACert(),
			},
			expectErr: "get keys failed",
		},
		"jwks mismatched certs": {
			config: Config{
				Type:       TypeJWT,
				JWKSURL:    srv.Addr() + "/certs_invalid",
				JWKSCACert: srv.CACert(),
			},
			expectErr: "failed to decode keys",
		},
		"jwks bad certs": {
			config: Config{
				Type:       TypeJWT,
				JWKSURL:    srv.Addr() + "/certs_invalid",
				JWKSCACert: garbageCACert,
			},
			expectErr: "could not parse CA PEM value successfully",
		},
		"valid algorithm": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				JWTSupportedAlgs: []string{
					oidc.RS256, oidc.RS384, oidc.RS512,
					oidc.ES256, oidc.ES384, oidc.ES512,
					oidc.PS256, oidc.PS384, oidc.PS512,
				},
			},
			expectAuthType: authStaticKeys,
		},
		"invalid algorithm": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				JWTSupportedAlgs: []string{
					oidc.RS256, oidc.RS384, oidc.RS512,
					oidc.ES256, oidc.ES384, oidc.ES512,
					oidc.PS256, oidc.PS384, oidc.PS512,
					"foo",
				},
			},
			expectErr: "Invalid supported algorithm",
		},
		"valid claim mappings": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectAuthType: authStaticKeys,
		},
		"invalid repeated value claim mappings": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"bling":        "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectErr: "ClaimMappings contains multiple mappings for key",
		},
		"invalid repeated list claim mappings": {
			config: Config{
				Type:                 TypeJWT,
				JWTValidationPubKeys: []string{testJWTPubKey},
				ClaimMappings: map[string]string{
					"foo":          "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
				ListClaimMappings: map[string]string{
					"foo":          "bar",
					"bling":        "bar",
					"peanutbutter": "jelly",
					"wd40":         "ducttape",
				},
			},
			expectErr: "ListClaimMappings contains multiple mappings for key",
		},
	}

	cases := map[string]testcase{
		"bad type": {
			config:    Config{Type: "invalid"},
			expectErr: "authenticator type should be",
		},
	}

	for k, v := range oidcCases {
		cases["type=oidc/"+k] = v

		v2 := v
		v2.config.Type = ""
		cases["type=inferred_oidc/"+k] = v2
	}
	for k, v := range jwtCases {
		cases["type=jwt/"+k] = v
	}

	for name, tc := range cases {
		tc := tc
		t.Run(name, func(t *testing.T) {
			err := tc.config.Validate()
			if tc.expectErr != "" {
				require.Error(t, err)
				requireErrorContains(t, err, tc.expectErr)
			} else {
				require.NoError(t, err)
				require.Equal(t, tc.expectAuthType, tc.config.authType())
			}
		})
	}
}

func requireErrorContains(t *testing.T, err error, expectedErrorMessage string) {
	t.Helper()
	if err == nil {
		t.Fatal("An error is expected but got nil.")
	}
	if !strings.Contains(err.Error(), expectedErrorMessage) {
		t.Fatalf("unexpected error: %v", err)
	}
}

const (
	testJWTPubKey = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9
q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg==
-----END PUBLIC KEY-----`

	testJWTPubKeyBad = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIrollingyourricksEVs/o5+uQbTjL3chynL4wXgUg2R9
q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg==
-----END PUBLIC KEY-----`

	garbageCACert = `this is not a key`

	oidcBadCACerts = `-----BEGIN CERTIFICATE-----
MIIDYDCCAkigAwIBAgIJAK8uAVsPxWKGMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTgwNzA5MTgwODI5WhcNMjgwNzA2MTgwODI5WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEA1eaEmIHKQqDlSadCtg6YY332qIMoeSb2iZTRhBRYBXRhMIKF3HoLXlI8
/3veheMnBQM7zxIeLwtJ4VuZVZcpJlqHdsXQVj6A8+8MlAzNh3+Xnv0tjZ83QLwZ
D6FWvMEzihxATD9uTCu2qRgeKnMYQFq4EG72AGb5094zfsXTAiwCfiRPVumiNbs4
Mr75vf+2DEhqZuyP7GR2n3BKzrWo62yAmgLQQ07zfd1u1buv8R72HCYXYpFul5qx
slZHU3yR+tLiBKOYB+C/VuB7hJZfVx25InIL1HTpIwWvmdk3QzpSpAGIAxWMXSzS
oRmBYGnsgR6WTymfXuokD4ZhHOpFZQIDAQABo1MwUTAdBgNVHQ4EFgQURh/QFJBn
hMXcgB1bWbGiU9B2VBQwHwYDVR0jBBgwFoAURh/QFJBnhMXcgB1bWbGiU9B2VBQw
DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAr8CZLA3MQjMDWweS
ax9S1fRb8ifxZ4RqDcLj3dw5KZqnjEo8ggczR66T7vVXet/2TFBKYJAM0np26Z4A
WjZfrDT7/bHXseWQAUhw/k2d39o+Um4aXkGpg1Paky9D+ddMdbx1hFkYxDq6kYGd
PlBYSEiYQvVxDx7s7H0Yj9FWKO8WIO6BRUEvLlG7k/Xpp1OI6dV3nqwJ9CbcbqKt
ff4hAtoAmN0/x6yFclFFWX8s7bRGqmnoj39/r98kzeGFb/lPKgQjSVcBJuE7UO4k
8HP6vsnr/ruSlzUMv6XvHtT68kGC1qO3MfqiPhdSa4nxf9g/1xyBmAw/Uf90BJrm
sj9DpQ==
-----END CERTIFICATE-----`
)
