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

// Package token provides a handler for the OIDC token endpoint.
package token

import (
	"context"
	"crypto/sha256"
	"errors"
	"fmt"
	"net/http"
	"slices"
	"time"

	"github.com/ory/fosite"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/apiserver/pkg/warning"

	oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
	"go.pinniped.dev/internal/auditevent"
	"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
	"go.pinniped.dev/internal/federationdomain/idtokenlifespan"
	"go.pinniped.dev/internal/federationdomain/oidc"
	"go.pinniped.dev/internal/federationdomain/resolvedprovider"
	"go.pinniped.dev/internal/federationdomain/timeouts"
	"go.pinniped.dev/internal/httputil/httperr"
	"go.pinniped.dev/internal/idtransform"
	"go.pinniped.dev/internal/plog"
	"go.pinniped.dev/internal/psession"
)

func paramsSafeToLog() sets.Set[string] {
	return sets.New(
		// Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants.
		// Redacting code, client_secret, refresh_token, and PKCE code_verifier params.
		"grant_type", "client_id", "redirect_uri", "scope",
		// Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693#section-2.1.
		// Redact subject_token and actor_token.
		// We don't allow all of these, but they should be safe to log.
		// "scope" is already included from the authcode grant.
		"audience", "resource", "requested_token_type", "actor_token_type", "subject_token_type",
	)
}

func NewHandler(
	idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
	oauthHelper fosite.OAuth2Provider,
	overrideAccessTokenLifespan timeouts.OverrideLifespan,
	overrideIDTokenLifespan timeouts.OverrideLifespan,
	auditLogger plog.AuditLogger,
) http.Handler {
	return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
		if err := auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil {
			oauthHelper.WriteAccessError(r.Context(), w, nil, err)
			return nil
		}
		auditLogBasicAuthClientID(r, auditLogger)

		session := psession.NewPinnipedSession()
		accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session)
		if err != nil {
			plog.Info("token request error", oidc.FositeErrorForLog(err)...)
			oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
			return nil
		}

		// Log sessionID for cross-request correlation purposes.
		auditLogger.Audit(auditevent.SessionFound, &plog.AuditParams{
			ReqCtx:  r.Context(),
			Session: accessRequest,
		})

		// Check if we are performing a refresh grant.
		if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) {
			// The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable.
			// The session, requested scopes, and requested audience from the original authorize request was retrieved
			// from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may
			// have already been granted on the accessRequest.
			err = upstreamRefresh(r.Context(), accessRequest, idpLister, auditLogger)
			if err != nil {
				plog.Info("upstream refresh error", oidc.FositeErrorForLog(err)...)
				oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
				return nil
			}
		}

		// When we are in the authorization code flow, check if we have any warnings that previous handlers want us
		// to send to the client to be printed on the CLI.
		if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeAuthorizationCode) {
			storedSession := accessRequest.GetSession().(*psession.PinnipedSession)
			customSessionData := storedSession.Custom
			if customSessionData != nil {
				for _, warningText := range customSessionData.Warnings {
					warning.AddWarning(r.Context(), "", warningText)
				}
			}
		}

		// Lifetimes of the access and refresh tokens are determined by the above call to NewAccessRequest.
		// Depending on the request, sometimes override the default access token lifespan.
		maybeOverrideDefaultAccessTokenLifetime(overrideAccessTokenLifespan, accessRequest)

		// Create the token response.
		// The lifetime of the ID token will be determined inside the call NewAccessResponse.
		// Depending on the request, sometimes override the default ID token lifespan by putting
		// the override value onto the context.
		accessResponse, err := oauthHelper.NewAccessResponse(
			maybeOverrideDefaultIDTokenLifetime(r.Context(), overrideIDTokenLifespan, accessRequest),
			accessRequest)
		if err != nil {
			plog.Info("token response error", oidc.FositeErrorForLog(err)...)
			oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
			return nil
		}

		// Allow cross-referencing the token with the Concierge's audit logs.
		auditLogIDToken(r.Context(), auditLogger, accessRequest, accessResponse)

		oauthHelper.WriteAccessResponse(r.Context(), w, accessRequest, accessResponse)

		return nil
	})
}

func maybeOverrideDefaultAccessTokenLifetime(overrideAccessTokenLifespan timeouts.OverrideLifespan, accessRequest fosite.AccessRequester) {
	if newLifespan, doOverride := overrideAccessTokenLifespan(accessRequest); doOverride {
		accessRequest.GetSession().SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(newLifespan).Round(time.Second))
	}
}

func maybeOverrideDefaultIDTokenLifetime(baseCtx context.Context, overrideIDTokenLifespan timeouts.OverrideLifespan, accessRequest fosite.AccessRequester) context.Context {
	if newLifespan, doOverride := overrideIDTokenLifespan(accessRequest); doOverride {
		return idtokenlifespan.OverrideIDTokenLifespanInContext(baseCtx, newLifespan)
	}
	return baseCtx
}

func errMissingUpstreamSessionInternalError() *fosite.RFC6749Error {
	return &fosite.RFC6749Error{
		ErrorField:       "error",
		DescriptionField: "There was an internal server error.",
		HintField:        "Required upstream data not found in session.",
		CodeField:        http.StatusInternalServerError,
	}
}

func errUpstreamRefreshError() *fosite.RFC6749Error {
	return &fosite.RFC6749Error{
		ErrorField:       "error",
		DescriptionField: "Error during upstream refresh.",
		CodeField:        http.StatusUnauthorized,
	}
}

func upstreamRefresh(
	ctx context.Context,
	accessRequest fosite.AccessRequester,
	idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
	auditLogger plog.AuditLogger,
) error {
	session := accessRequest.GetSession().(*psession.PinnipedSession)

	customSessionData := session.Custom
	if customSessionData == nil {
		return errMissingUpstreamSessionInternalError()
	}
	providerName := customSessionData.ProviderName
	providerType := customSessionData.ProviderType
	providerUID := customSessionData.ProviderUID
	if providerUID == "" || providerName == "" {
		return errMissingUpstreamSessionInternalError()
	}

	skipGroups := !slices.Contains(accessRequest.GetGrantedScopes(), oidcapi.ScopeGroups)

	if session.IDTokenClaims().AuthTime.IsZero() {
		return resolvedprovider.ErrMissingUpstreamSessionInternalError()
	}

	err := validateSessionHasUsername(session)
	if err != nil {
		return err
	}

	var oldTransformedGroups []string
	if !skipGroups {
		// Only validate the groups in the session if the groups scope was granted.
		oldTransformedGroups, err = validateAndGetDownstreamGroupsFromSession(session)
		if err != nil {
			return err
		}
	}

	idp, err := findProviderByNameAndType(providerName, customSessionData.ProviderType, providerUID, idpLister)
	if err != nil {
		return err
	}

	cloneOfIDPSpecificSessionData := idp.CloneIDPSpecificSessionDataFromSession(session.Custom)
	if cloneOfIDPSpecificSessionData == nil {
		return resolvedprovider.ErrMissingUpstreamSessionInternalError()
	}

	oldUntransformedUsername := session.Custom.UpstreamUsername
	oldUntransformedGroups := session.Custom.UpstreamGroups
	oldTransformedUsername := session.Custom.Username

	previousIdentity := &resolvedprovider.Identity{
		UpstreamUsername:       oldUntransformedUsername,
		UpstreamGroups:         oldUntransformedGroups,
		DownstreamSubject:      session.Fosite.Claims.Subject,
		IDPSpecificSessionData: cloneOfIDPSpecificSessionData,
	}

	// Perform the upstream refresh.
	refreshedIdentity, err := idp.UpstreamRefresh(ctx, previousIdentity)
	if err != nil {
		return err
	}

	auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, &plog.AuditParams{
		ReqCtx:  ctx,
		Session: accessRequest,
		PIIKeysAndValues: []any{
			"upstreamUsername", refreshedIdentity.UpstreamUsername,
			"upstreamGroups", refreshedIdentity.UpstreamGroups,
		},
	})

	// If the idp wants to update the session with new information from the refresh, then update it.
	if refreshedIdentity.IDPSpecificSessionData != nil {
		idp.ApplyIDPSpecificSessionDataToSession(session.Custom, refreshedIdentity.IDPSpecificSessionData)
	}

	if refreshedIdentity.UpstreamGroups == nil {
		// If we could not get a new list of groups, then we still need the untransformed groups list to be able to
		// run the transformations again, so fetch the original untransformed groups list from the session.
		// We should also run the transformations on the original groups even when the groups scope was not granted,
		// because a transformation policy may want to reject the authentication based on the group memberships, even
		// though the group memberships will not be shared with the client (in the code below) due to the groups scope
		// not being granted.
		refreshedIdentity.UpstreamGroups = oldUntransformedGroups
	}

	refreshedTransformedUsername, refreshedTransformedGroups, fositeErr := applyIdentityTransformationsDuringRefresh(ctx,
		idp.GetTransforms(),
		refreshedIdentity.UpstreamUsername,
		refreshedIdentity.UpstreamGroups,
		providerName,
		providerType,
	)
	if fositeErr != nil {
		// The HintField is always populated by applyIdentityTransformationsDuringRefresh,
		// and more descriptive than fositeErr.Error() which is just "error".
		auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, &plog.AuditParams{
			ReqCtx:        ctx,
			Session:       accessRequest,
			KeysAndValues: []any{"reason", fositeErr.HintField},
		})
		return fositeErr
	}

	if oldTransformedUsername != refreshedTransformedUsername {
		return errUpstreamRefreshError().WithHintf(
			"Upstream refresh failed.").
			WithTrace(errors.New("username in upstream refresh does not match previous value")).
			WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
	}

	if !skipGroups {
		warnIfGroupsChanged(ctx, oldTransformedGroups, refreshedTransformedGroups, oldTransformedUsername, accessRequest.GetClient().GetID())
		// Replace the old value for the downstream groups in the user's session with the new value.
		session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedTransformedGroups
	}

	auditLogger.Audit(auditevent.SessionRefreshed, &plog.AuditParams{
		ReqCtx:  ctx,
		Session: accessRequest,
		PIIKeysAndValues: []any{
			"username", oldTransformedUsername, // not allowed to change above so must be the same as old
			"groups", refreshedTransformedGroups,
			"subject", previousIdentity.DownstreamSubject},
	})

	return nil
}

// findProviderByNameAndType finds the IDP by its resource name and IDP type,
// and validates that its resource UID matches the expected UID.
func findProviderByNameAndType(
	providerResourceName string,
	providerType psession.ProviderType,
	mustHaveResourceUID types.UID,
	idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
) (resolvedprovider.FederationDomainResolvedIdentityProvider, error) {
	for _, p := range idpLister.GetIdentityProviders() {
		if p.GetSessionProviderType() == providerType && p.GetProvider().GetResourceName() == providerResourceName {
			if p.GetProvider().GetResourceUID() != mustHaveResourceUID {
				return nil, errUpstreamRefreshError().WithHint(
					"Provider from upstream session data has changed its resource UID since authentication.")
			}
			return p, nil
		}
	}
	return nil, errUpstreamRefreshError().
		WithHint("Provider from upstream session data was not found.").
		WithDebugf("provider name: %q, provider type: %q", providerResourceName, providerType)
}

func validateSessionHasUsername(session *psession.PinnipedSession) error {
	downstreamUsername := session.Custom.Username
	if len(downstreamUsername) == 0 {
		return errMissingUpstreamSessionInternalError()
	}
	return nil
}

// applyIdentityTransformationsDuringRefresh is similar to downstreamsession.applyIdentityTransformations
// but with slightly different error messaging.
func applyIdentityTransformationsDuringRefresh(
	ctx context.Context,
	transforms *idtransform.TransformationPipeline,
	upstreamUsername string,
	upstreamGroups []string,
	providerName string,
	providerType psession.ProviderType,
) (string, []string, *fosite.RFC6749Error) {
	transformationResult, err := transforms.Evaluate(ctx, upstreamUsername, upstreamGroups)
	if err != nil {
		return "", nil, errUpstreamRefreshError().WithHintf(
			"Upstream refresh error while applying configured identity transformations.").
			WithTrace(err).
			WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
	}

	if !transformationResult.AuthenticationAllowed {
		return "", nil, errUpstreamRefreshError().WithHintf(
			"Upstream refresh rejected by configured identity policy: %s.", transformationResult.RejectedAuthenticationMessage).
			WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
	}

	return transformationResult.Username, transformationResult.Groups, nil
}

func validateAndGetDownstreamGroupsFromSession(session *psession.PinnipedSession) ([]string, error) {
	extra := session.Fosite.Claims.Extra
	if extra == nil {
		return nil, errMissingUpstreamSessionInternalError()
	}
	downstreamGroupsInterface := extra[oidcapi.IDTokenClaimGroups]
	if downstreamGroupsInterface == nil {
		return nil, errMissingUpstreamSessionInternalError()
	}
	downstreamGroupsInterfaceList, ok := downstreamGroupsInterface.([]any)
	if !ok {
		return nil, errMissingUpstreamSessionInternalError()
	}

	downstreamGroups := make([]string, 0, len(downstreamGroupsInterfaceList))
	for _, downstreamGroupInterface := range downstreamGroupsInterfaceList {
		downstreamGroup, ok := downstreamGroupInterface.(string)
		if !ok || len(downstreamGroup) == 0 {
			return nil, errMissingUpstreamSessionInternalError()
		}
		downstreamGroups = append(downstreamGroups, downstreamGroup)
	}
	return downstreamGroups, nil
}

func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string, clientID string) {
	if clientID != oidcapi.ClientIDPinnipedCLI {
		// Only send these warnings to the CLI client. They are intended for kubectl to print to the screen.
		// A webapp using a dynamic client wouldn't know to look for these special warning headers, and
		// if the dynamic client lacked the username scope, then these warning messages would be leaking
		// the user's username to the client within the text of the warning.
		return
	}

	added, removed := diffSortedGroups(oldGroups, newGroups)

	if len(added) > 0 {
		warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been added to the following groups: %q", username, added))
	}
	if len(removed) > 0 {
		warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been removed from the following groups: %q", username, removed))
	}
}

func diffSortedGroups(oldGroups, newGroups []string) ([]string, []string) {
	oldGroupsAsSet := sets.NewString(oldGroups...)
	newGroupsAsSet := sets.NewString(newGroups...)
	added := newGroupsAsSet.Difference(oldGroupsAsSet)   // groups in newGroups that are not in oldGroups i.e. added
	removed := oldGroupsAsSet.Difference(newGroupsAsSet) // groups in oldGroups that are not in newGroups i.e. removed
	return added.List(), removed.List()
}

func auditLogBasicAuthClientID(r *http.Request, auditLogger plog.AuditLogger) {
	// For dynamic clients, the client ID is from basic auth, not from the request parameters.
	clientIDFromBasicAuth, _, basicAuthUsed := r.BasicAuth()
	if basicAuthUsed {
		auditLogger.Audit(auditevent.HTTPRequestBasicAuthUsed, &plog.AuditParams{
			ReqCtx:        r.Context(),
			KeysAndValues: []any{"clientID", clientIDFromBasicAuth},
		})
	}
}

func auditLogIDToken(
	reqCtx context.Context,
	auditLogger plog.AuditLogger,
	accessRequest fosite.AccessRequester,
	accessResponse fosite.AccessResponder,
) {
	var idToken string

	if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange) {
		// Token exchanges return the ID token in the access token field of the response.
		idToken = accessResponse.GetAccessToken()
	} else {
		// For other grant types, there may not be an access token, e.g. when the openid scope was not granted.
		tok := accessResponse.GetExtra("id_token")
		if tok != nil {
			// This should always be a string. Checking just to be safe.
			tokAsStr, ok := tok.(string)
			if ok {
				idToken = tokAsStr
			}
		}
	}

	if len(idToken) == 0 {
		return
	}

	auditLogger.Audit(auditevent.IDTokenIssued, &plog.AuditParams{
		ReqCtx:  reqCtx,
		Session: accessRequest,
		KeysAndValues: []any{
			"tokenID", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))),
		},
	})
}
