// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package iamauth

import (
	"context"
	"encoding/xml"
	"fmt"
	"io/ioutil"
	"net/http"
	"regexp"
	"strings"
	"time"

	"github.com/hashicorp/consul-awsauth/internal/glob"
	"github.com/hashicorp/consul-awsauth/internal/stringslice"
	"github.com/hashicorp/consul-awsauth/responses"
	"github.com/hashicorp/go-cleanhttp"
	"github.com/hashicorp/go-hclog"
	"github.com/hashicorp/go-retryablehttp"
)

const (
	// Retry configuration
	retryWaitMin = 500 * time.Millisecond
	retryWaitMax = 30 * time.Second
)

type Authenticator struct {
	config *Config
	logger hclog.Logger
}

type IdentityDetails struct {
	EntityName string
	EntityId   string
	AccountId  string

	EntityPath string
	EntityTags map[string]string
}

func NewAuthenticator(config *Config, logger hclog.Logger) (*Authenticator, error) {
	if err := config.Validate(); err != nil {
		return nil, err
	}
	return &Authenticator{
		config: config,
		logger: logger,
	}, nil
}

// ValidateLogin determines if the identity in the loginToken is permitted to login.
// If so, it returns details about the identity. Otherwise, an error is returned.
func (a *Authenticator) ValidateLogin(ctx context.Context, loginToken string) (*IdentityDetails, error) {
	token, err := NewBearerToken(loginToken, a.config)
	if err != nil {
		return nil, err
	}

	req, err := token.GetCallerIdentityRequest()
	if err != nil {
		return nil, err
	}

	if a.config.ServerIDHeaderValue != "" {
		err := validateHeaderValueWithRequest(req, req.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue)
		if err != nil {
			return nil, err
		}
	}

	callerIdentity, err := a.submitCallerIdentityRequest(ctx, req)
	if err != nil {
		return nil, err
	}
	a.logger.Debug("iamauth login attempt", "arn", callerIdentity.Arn)

	entity, err := responses.ParseArn(callerIdentity.Arn)
	if err != nil {
		return nil, err
	}

	identityDetails := &IdentityDetails{
		EntityName: entity.FriendlyName,
		// This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID"
		// (in the case of an IAM user).
		EntityId:  strings.Split(callerIdentity.UserId, ":")[0],
		AccountId: callerIdentity.Account,
	}
	clientArn := entity.CanonicalArn()

	// Fetch the IAM Role or IAM User, if configured.
	// This requires the token to contain a signed iam:GetRole or iam:GetUser request.
	if a.config.EnableIAMEntityDetails {
		iamReq, err := token.GetEntityRequest()
		if err != nil {
			return nil, err
		}

		if a.config.ServerIDHeaderValue != "" {
			err := validateHeaderValueWithRequest(iamReq, iamReq.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue)
			if err != nil {
				return nil, err
			}
		}

		iamEntityDetails, err := a.submitGetIAMEntityRequest(ctx, iamReq, token.entityRequestType)
		if err != nil {
			return nil, err
		}

		// Only the CallerIdentity response is a guarantee of the client's identity.
		// The role/user details must have a unique id match to the CallerIdentity before use.
		if iamEntityDetails.EntityId() != identityDetails.EntityId {
			return nil, fmt.Errorf("unique id mismatch in login token")
		}

		// Use the full ARN with path from the Role/User details
		clientArn = iamEntityDetails.EntityArn()
		identityDetails.EntityPath = iamEntityDetails.EntityPath()
		identityDetails.EntityTags = iamEntityDetails.EntityTags()
	}

	if err := a.validateIdentity(clientArn); err != nil {
		return nil, err
	}
	return identityDetails, nil
}

// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1321-L1361
func (a *Authenticator) validateIdentity(clientArn string) error {
	if stringslice.Contains(a.config.BoundIAMPrincipalARNs, clientArn) {
		// Matches one of BoundIAMPrincipalARNs, so it is trusted
		return nil
	}
	if a.config.EnableIAMEntityDetails {
		for _, principalArn := range a.config.BoundIAMPrincipalARNs {
			if strings.HasSuffix(principalArn, "*") && glob.GlobbedStringsMatch(principalArn, clientArn) {
				// Wildcard match, so it is trusted
				return nil
			}
		}
	}
	return fmt.Errorf("IAM principal %s is not trusted", clientArn)
}

func (a *Authenticator) submitCallerIdentityRequest(ctx context.Context, req *http.Request) (*responses.GetCallerIdentityResult, error) {
	responseBody, err := a.submitRequest(ctx, req)
	if err != nil {
		return nil, err
	}
	callerIdentityResponse, err := parseGetCallerIdentityResponse(responseBody)
	if err != nil {
		return nil, fmt.Errorf("error parsing STS response")
	}

	if n := len(callerIdentityResponse.GetCallerIdentityResult); n != 1 {
		return nil, fmt.Errorf("received %d identities in STS response but expected 1", n)
	}
	return &callerIdentityResponse.GetCallerIdentityResult[0], nil
}

func (a *Authenticator) submitGetIAMEntityRequest(ctx context.Context, req *http.Request, reqType string) (responses.IAMEntity, error) {
	responseBody, err := a.submitRequest(ctx, req)
	if err != nil {
		return nil, err
	}
	iamResponse, err := parseGetIAMEntityResponse(responseBody, reqType)
	if err != nil {
		return nil, fmt.Errorf("error parsing IAM response: %s", err)
	}
	return iamResponse, nil

}

// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1636
func (a *Authenticator) submitRequest(ctx context.Context, req *http.Request) (string, error) {
	retryableReq, err := retryablehttp.FromRequest(req)
	if err != nil {
		return "", err
	}
	retryableReq = retryableReq.WithContext(ctx)
	client := cleanhttp.DefaultClient()
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	retryingClient := &retryablehttp.Client{
		HTTPClient:   client,
		RetryWaitMin: retryWaitMin,
		RetryWaitMax: retryWaitMax,
		RetryMax:     a.config.MaxRetries,
		CheckRetry:   retryablehttp.DefaultRetryPolicy,
		Backoff:      retryablehttp.DefaultBackoff,
	}

	response, err := retryingClient.Do(retryableReq)
	if err != nil {
		return "", fmt.Errorf("error making request: %w", err)
	}
	if response != nil {
		defer response.Body.Close()
	}
	// Validate that the response type is XML
	if ct := response.Header.Get("Content-Type"); ct != "text/xml" {
		return "", fmt.Errorf("response body is invalid")
	}

	// we check for status code afterwards to also print out response body
	responseBody, err := ioutil.ReadAll(response.Body)
	if err != nil {
		return "", err
	}
	if response.StatusCode != 200 {
		return "", fmt.Errorf("received error code %d: %s", response.StatusCode, string(responseBody))
	}
	return string(responseBody), nil

}

// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1625-L1634
func parseGetCallerIdentityResponse(response string) (responses.GetCallerIdentityResponse, error) {
	result := responses.GetCallerIdentityResponse{}
	response = strings.TrimSpace(response)
	if !strings.HasPrefix(response, "<GetCallerIdentityResponse") && !strings.HasPrefix(response, "<?xml") {
		return result, fmt.Errorf("body of GetCallerIdentity is invalid")
	}
	decoder := xml.NewDecoder(strings.NewReader(response))
	err := decoder.Decode(&result)
	return result, err
}

func parseGetIAMEntityResponse(response string, reqType string) (responses.IAMEntity, error) {
	if !strings.HasPrefix(response, "<GetRoleResponse") &&
		!strings.HasPrefix(response, "<GetUserResponse") &&
		!strings.HasPrefix(response, "<?xml") {
		return nil, fmt.Errorf("body of GetRole or GetUser is invalid")
	}

	decoder := xml.NewDecoder(strings.NewReader(response))

	switch reqType {
	case "GetRole":
		result := &responses.GetRoleResponse{}
		err := decoder.Decode(&result)
		if err != nil {
			return nil, err
		}
		if n := len(result.GetRoleResult); n != 1 {
			return nil, fmt.Errorf("received %d identities in GetRole response but expected 1", n)
		}
		return &result.GetRoleResult[0].Role, nil
	case "GetUser":
		result := &responses.GetUserResponse{}
		err := decoder.Decode(&result)
		if err != nil {
			return nil, err
		}
		if n := len(result.GetUserResult); n != 1 {
			return nil, fmt.Errorf("received %d identities in GetUser response but expected 1", n)
		}
		return &result.GetUserResult[0].User, nil
	}
	return nil, fmt.Errorf("invalid %s request: %s", reqType, response)
}

// validateHeaderValueWithRequest validates that a header has the expected value and is properly signed.
// It checks both Authorization headers and URL query parameters for authorization information to prevent bypasses.
func validateHeaderValueWithRequest(req *http.Request, headers http.Header, headerName string, requiredHeaderValue string) error {
	providedValue := ""
	for k, v := range headers {
		if strings.EqualFold(headerName, k) {
			providedValue = strings.Join(v, ",")
			break
		}
	}
	if providedValue == "" {
		return fmt.Errorf("missing header %q", headerName)
	}

	// NOT doing a constant time compare here since the value is NOT intended to be secret
	if providedValue != requiredHeaderValue {
		return fmt.Errorf("expected %q but got %q", requiredHeaderValue, providedValue)
	}

	// Check for authorization in headers first
	if authzHeaders, ok := headers["Authorization"]; ok {
		// authzHeader looks like AWS4-HMAC-SHA256 Credential=AKI..., SignedHeaders=host;x-amz-date;x-vault-awsiam-id, Signature=...
		// We need to extract out the SignedHeaders
		re := regexp.MustCompile(".*SignedHeaders=([^,]+)")
		authzHeader := strings.Join(authzHeaders, ",")
		matches := re.FindSubmatch([]byte(authzHeader))
		if len(matches) < 1 {
			return fmt.Errorf("server id header wasn't signed")
		}
		if len(matches) > 2 {
			return fmt.Errorf("found multiple SignedHeaders components")
		}
		signedHeaders := string(matches[1])
		return ensureHeaderIsSigned(signedHeaders, headerName)
	}

	// Reject any URL query parameters for security reasons
	// This prevents parameter injection attacks where an attacker could override validation
	// NOTE: If we support GET requests in the future, we would need to parse the X-Amz-SignedHeaders
	// argument out of the query string, but for security we currently reject all query parameters.
	if req != nil && req.URL != nil && req.URL.RawQuery != "" {
		return fmt.Errorf("URL query parameters are not allowed for header validation: found %q", req.URL.RawQuery)
	}

	return fmt.Errorf("missing Authorization header")
}

func ensureHeaderIsSigned(signedHeaders, headerToSign string) error {
	// Not doing a constant time compare here, the values aren't secret
	for _, header := range strings.Split(signedHeaders, ";") {
		if header == strings.ToLower(headerToSign) {
			return nil
		}
	}
	return fmt.Errorf("header wasn't signed")
}
