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

// package oidcauthtest exposes tools to assist in writing unit tests of OIDC
// and JWT authentication workflows.
//
// When the package is loaded it will randomly generate an ECDSA signing
// keypair used to sign JWTs both via the Server and the SignJWT method.
package oidcauthtest

import (
	"bytes"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"sync"
	"time"

	"github.com/go-jose/go-jose/v3"
	"github.com/go-jose/go-jose/v3/jwt"
	"github.com/stretchr/testify/require"

	"github.com/hashicorp/consul/internal/go-sso/oidcauth/internal/strutil"
)

// Server is local server the mocks the endpoints used by the OIDC and
// JWKS process.
type Server struct {
	httpServer *httptest.Server
	caCert     string
	returnFunc func()

	jwks                *jose.JSONWebKeySet
	allowedRedirectURIs []string
	replySubject        string
	replyUserinfo       map[string]interface{}

	mu                sync.Mutex
	clientID          string
	clientSecret      string
	expectedAuthCode  string
	expectedAuthNonce string
	customClaims      map[string]interface{}
	customAudience    string
	omitIDToken       bool
	disableUserInfo   bool
}

type TestingT interface {
	require.TestingT
	Helper()
	Cleanup(func())
}

// Start creates a disposable Server. If the port provided is
// zero it will bind to a random free port, otherwise the provided port is
// used.
func Start(t TestingT) *Server {
	t.Helper()
	s := &Server{
		allowedRedirectURIs: []string{
			"https://example.com",
		},
		replySubject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients",
		replyUserinfo: map[string]any{
			"sub":         "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients",
			"color":       "red",
			"temperature": "76",
			"flavor":      "umami",
		},
	}

	jwks, err := newJWKS(ecdsaPublicKey)
	require.NoError(t, err)
	s.jwks = jwks

	s.httpServer = httptest.NewUnstartedServer(s)
	s.httpServer.Config.ErrorLog = log.New(io.Discard, "", 0)
	s.httpServer.StartTLS()
	t.Cleanup(s.httpServer.Close)

	cert := s.httpServer.Certificate()

	var buf bytes.Buffer
	require.NoError(t, pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
	s.caCert = buf.String()

	return s
}

// SetClientCreds is for configuring the client information required for the
// OIDC workflows.
func (s *Server) SetClientCreds(clientID, clientSecret string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.clientID = clientID
	s.clientSecret = clientSecret
}

// SetExpectedAuthCode configures the auth code to return from /auth and the
// allowed auth code for /token.
func (s *Server) SetExpectedAuthCode(code string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.expectedAuthCode = code
}

func (s *Server) SetUserInfo(info map[string]any) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.replyUserinfo = info
}

// SetExpectedAuthNonce configures the nonce value required for /auth.
func (s *Server) SetExpectedAuthNonce(nonce string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.expectedAuthNonce = nonce
}

// SetAllowedRedirectURIs allows you to configure the allowed redirect URIs for
// the OIDC workflow. If not configured a sample of "https://example.com" is
// used.
func (s *Server) SetAllowedRedirectURIs(uris []string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.allowedRedirectURIs = uris
}

// SetCustomClaims lets you set claims to return in the JWT issued by the OIDC
// workflow.
func (s *Server) SetCustomClaims(customClaims map[string]interface{}) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.customClaims = customClaims
}

// SetCustomAudience configures what audience value to embed in the JWT issued
// by the OIDC workflow.
func (s *Server) SetCustomAudience(customAudience string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.customAudience = customAudience
}

// OmitIDTokens forces an error state where the /token endpoint does not return
// id_token.
func (s *Server) OmitIDTokens() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.omitIDToken = true
}

// DisableUserInfo makes the userinfo endpoint return 404 and omits it from the
// discovery config.
func (s *Server) DisableUserInfo() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.disableUserInfo = true
}

// Stop stops the running Server.
func (s *Server) Stop() {
	s.httpServer.Close()
}

// Addr returns the current base URL for the running webserver.
func (s *Server) Addr() string { return s.httpServer.URL }

// CACert returns the pem-encoded CA certificate used by the HTTPS server.
func (s *Server) CACert() string { return s.caCert }

// SigningKeys returns the pem-encoded keys used to sign JWTs.
func (s *Server) SigningKeys() (pub, priv string) {
	return SigningKeys()
}

// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()

	w.Header().Set("Content-Type", "application/json; charset=utf-8")

	switch req.URL.Path {
	case "/.well-known/openid-configuration":
		if req.Method != "GET" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		reply := struct {
			Issuer           string `json:"issuer"`
			AuthEndpoint     string `json:"authorization_endpoint"`
			TokenEndpoint    string `json:"token_endpoint"`
			JWKSURI          string `json:"jwks_uri"`
			UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
		}{
			Issuer:           s.Addr(),
			AuthEndpoint:     s.Addr() + "/auth",
			TokenEndpoint:    s.Addr() + "/token",
			JWKSURI:          s.Addr() + "/certs",
			UserinfoEndpoint: s.Addr() + "/userinfo",
		}
		if s.disableUserInfo {
			reply.UserinfoEndpoint = ""
		}

		if err := writeJSON(w, &reply); err != nil {
			return
		}

	case "/auth":
		if req.Method != "GET" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		qv := req.URL.Query()

		if qv.Get("response_type") != "code" {
			writeAuthErrorResponse(w, req, "unsupported_response_type", "")
			return
		}
		if qv.Get("scope") != "openid" {
			writeAuthErrorResponse(w, req, "invalid_scope", "")
			return
		}

		if s.expectedAuthCode == "" {
			writeAuthErrorResponse(w, req, "access_denied", "")
			return
		}

		nonce := qv.Get("nonce")
		if s.expectedAuthNonce != "" && s.expectedAuthNonce != nonce {
			writeAuthErrorResponse(w, req, "access_denied", "")
			return
		}

		state := qv.Get("state")
		if state == "" {
			writeAuthErrorResponse(w, req, "invalid_request", "missing state parameter")
			return
		}

		redirectURI := qv.Get("redirect_uri")
		if redirectURI == "" {
			writeAuthErrorResponse(w, req, "invalid_request", "missing redirect_uri parameter")
			return
		}

		redirectURI += "?state=" + url.QueryEscape(state) +
			"&code=" + url.QueryEscape(s.expectedAuthCode)

		http.Redirect(w, req, redirectURI, http.StatusFound)

		return

	case "/certs":
		if req.Method != "GET" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		if err := writeJSON(w, s.jwks); err != nil {
			return
		}

	case "/certs_missing":
		w.WriteHeader(http.StatusNotFound)

	case "/certs_invalid":
		w.Write([]byte("It's not a keyset!"))

	case "/token":
		if req.Method != "POST" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		switch {
		case req.FormValue("grant_type") != "authorization_code":
			_ = writeTokenErrorResponse(w, http.StatusBadRequest, "invalid_request", "bad grant_type")
			return
		case !strutil.StrListContains(s.allowedRedirectURIs, req.FormValue("redirect_uri")):
			_ = writeTokenErrorResponse(w, http.StatusBadRequest, "invalid_request", "redirect_uri is not allowed")
			return
		case req.FormValue("code") != s.expectedAuthCode:
			_ = writeTokenErrorResponse(w, http.StatusUnauthorized, "invalid_grant", "unexpected auth code")
			return
		}

		stdClaims := jwt.Claims{
			Subject:   s.replySubject,
			Issuer:    s.Addr(),
			NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)),
			Expiry:    jwt.NewNumericDate(time.Now().Add(5 * time.Second)),
			Audience:  jwt.Audience{s.clientID},
		}
		if s.customAudience != "" {
			stdClaims.Audience = jwt.Audience{s.customAudience}
		}

		jwtData, err := SignJWT("", stdClaims, s.customClaims)
		if err != nil {
			_ = writeTokenErrorResponse(w, http.StatusInternalServerError, "server_error", err.Error())
			return
		}

		reply := struct {
			AccessToken string `json:"access_token"`
			IDToken     string `json:"id_token,omitempty"`
		}{
			AccessToken: jwtData,
			IDToken:     jwtData,
		}
		if s.omitIDToken {
			reply.IDToken = ""
		}
		if err := writeJSON(w, &reply); err != nil {
			return
		}

	case "/userinfo":
		if s.disableUserInfo {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		if req.Method != "GET" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		if err := writeJSON(w, s.replyUserinfo); err != nil {
			return
		}

	default:
		w.WriteHeader(http.StatusNotFound)
	}
}

func writeAuthErrorResponse(w http.ResponseWriter, req *http.Request, errorCode, errorMessage string) {
	qv := req.URL.Query()

	redirectURI := qv.Get("redirect_uri") +
		"?state=" + url.QueryEscape(qv.Get("state")) +
		"&error=" + url.QueryEscape(errorCode)

	if errorMessage != "" {
		redirectURI += "&error_description=" + url.QueryEscape(errorMessage)
	}

	http.Redirect(w, req, redirectURI, http.StatusFound)
}

func writeTokenErrorResponse(w http.ResponseWriter, statusCode int, errorCode, errorMessage string) error {
	body := struct {
		Code string `json:"error"`
		Desc string `json:"error_description,omitempty"`
	}{
		Code: errorCode,
		Desc: errorMessage,
	}

	w.WriteHeader(statusCode)
	return writeJSON(w, &body)
}

// newJWKS converts a pem-encoded public key into JWKS data suitable for a
// verification endpoint response
func newJWKS(pubKey string) (*jose.JSONWebKeySet, error) {
	block, _ := pem.Decode([]byte(pubKey))
	if block == nil {
		return nil, fmt.Errorf("unable to decode public key")
	}
	input := block.Bytes

	pub, err := x509.ParsePKIXPublicKey(input)
	if err != nil {
		return nil, err
	}
	return &jose.JSONWebKeySet{
		Keys: []jose.JSONWebKey{
			{
				Key: pub,
			},
		},
	}, nil
}

func writeJSON(w http.ResponseWriter, out interface{}) error {
	enc := json.NewEncoder(w)
	return enc.Encode(out)
}

// SignJWT will bundle the provided claims into a signed JWT. The provided key
// is assumed to be ECDSA.
//
// If no private key is provided, the default package keys are used. These can
// be retrieved via the SigningKeys() method.
func SignJWT(privKey string, claims jwt.Claims, privateClaims interface{}) (string, error) {
	if privKey == "" {
		privKey = ecdsaPrivateKey
	}
	var key *ecdsa.PrivateKey
	block, _ := pem.Decode([]byte(privKey))
	if block != nil {
		var err error
		key, err = x509.ParseECPrivateKey(block.Bytes)
		if err != nil {
			return "", err
		}
	}

	sig, err := jose.NewSigner(
		jose.SigningKey{Algorithm: jose.ES256, Key: key},
		(&jose.SignerOptions{}).WithType("JWT"),
	)
	if err != nil {
		return "", err
	}

	raw, err := jwt.Signed(sig).
		Claims(claims).
		Claims(privateClaims).
		CompactSerialize()
	if err != nil {
		return "", err
	}

	return raw, nil
}

// httptestNewUnstartedServerWithPort is roughly the same as
// httptest.NewUnstartedServer() but allows the caller to explicitly choose the
// port if desired.
func httptestNewUnstartedServerWithPort(handler http.Handler, port int) *httptest.Server {
	if port == 0 {
		return httptest.NewUnstartedServer(handler)
	}
	addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
	l, err := net.Listen("tcp", addr)
	if err != nil {
		panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err))
	}

	return &httptest.Server{
		Listener: l,
		Config:   &http.Server{Handler: handler},
	}
}

// SigningKeys returns the pem-encoded keys used to sign JWTs by default.
func SigningKeys() (pub, priv string) {
	return ecdsaPublicKey, ecdsaPrivateKey
}

var (
	ecdsaPublicKey  string
	ecdsaPrivateKey string
)

func init() {
	// Each time we run tests we generate a unique set of keys for use in the
	// test.  These are cached between runs but do not persist between restarts
	// of the test binary.
	var err error
	ecdsaPublicKey, ecdsaPrivateKey, err = GenerateKey()
	if err != nil {
		panic(err)
	}
}

func GenerateKey() (pub, priv string, err error) {
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return "", "", fmt.Errorf("error generating private key: %v", err)
	}

	{
		derBytes, err := x509.MarshalECPrivateKey(privateKey)
		if err != nil {
			return "", "", fmt.Errorf("error marshaling private key: %v", err)
		}
		pemBlock := &pem.Block{
			Type:  "EC PRIVATE KEY",
			Bytes: derBytes,
		}
		priv = string(pem.EncodeToMemory(pemBlock))
	}
	{
		derBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public())
		if err != nil {
			return "", "", fmt.Errorf("error marshaling public key: %v", err)
		}
		pemBlock := &pem.Block{
			Type:  "PUBLIC KEY",
			Bytes: derBytes,
		}
		pub = string(pem.EncodeToMemory(pemBlock))
	}

	return pub, priv, nil
}
