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

// Package oidc contains common OIDC functionality needed by Pinniped.
package oidc

import (
	"time"

	coreosoidc "github.com/coreos/go-oidc/v3/oidc"
	"github.com/ory/fosite"
	"github.com/ory/fosite/compose"

	"go.pinniped.dev/internal/oidc/csrftoken"
	"go.pinniped.dev/internal/oidc/jwks"
	"go.pinniped.dev/internal/oidc/provider"
	"go.pinniped.dev/pkg/oidcclient/nonce"
	"go.pinniped.dev/pkg/oidcclient/pkce"
)

const (
	WellKnownEndpointPath     = "/.well-known/openid-configuration"
	AuthorizationEndpointPath = "/oauth2/authorize"
	TokenEndpointPath         = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential
	CallbackEndpointPath      = "/callback"
	JWKSEndpointPath          = "/jwks.json"
	PinnipedIDPsPath          = "/pinniped_identity_providers"
)

const (
	// Just in case we need to make a breaking change to the format of the upstream state param,
	// we are including a format version number. This gives the opportunity for a future version of Pinniped
	// to have the consumer of this format decide to reject versions that it doesn't understand.
	UpstreamStateParamFormatVersion = "1"

	// The `name` passed to the encoder for encoding the upstream state param value. This name is short
	// because it will be encoded into the upstream state param value and we're trying to keep that small.
	UpstreamStateParamEncodingName = "s"

	// CSRFCookieName is the name of the browser cookie which shall hold our CSRF value.
	// The `__Host` prefix has a special meaning. See:
	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Cookie_prefixes.
	CSRFCookieName = "__Host-pinniped-csrf"

	// CSRFCookieEncodingName is the `name` passed to the encoder for encoding and decoding the CSRF
	// cookie contents.
	CSRFCookieEncodingName = "csrf"

	// The name of the issuer claim specified in the OIDC spec.
	IDTokenIssuerClaim = "iss"

	// The name of the subject claim specified in the OIDC spec.
	IDTokenSubjectClaim = "sub"

	// DownstreamUsernameClaim is a custom claim in the downstream ID token
	// whose value is mapped from a claim in the upstream token.
	// By default the value is the same as the downstream subject claim's.
	DownstreamUsernameClaim = "username"

	// DownstreamGroupsClaim is what we will use to encode the groups in the downstream OIDC ID token
	// information.
	DownstreamGroupsClaim = "groups"

	// CSRFCookieLifespan is the length of time that the CSRF cookie is valid. After this time, the
	// Supervisor's authorization endpoint should give the browser a new CSRF cookie. We set it to
	// a week so that it is unlikely to expire during a login.
	CSRFCookieLifespan = time.Hour * 24 * 7
)

// Encoder is the encoding side of the securecookie.Codec interface.
type Encoder interface {
	Encode(name string, value interface{}) (string, error)
}

// Decoder is the decoding side of the securecookie.Codec interface.
type Decoder interface {
	Decode(name, value string, into interface{}) error
}

// Codec is both the encoding and decoding sides of the securecookie.Codec interface. It is
// interface'd here so that we properly wrap the securecookie dependency.
type Codec interface {
	Encoder
	Decoder
}

// UpstreamStateParamData is the format of the state parameter that we use when we communicate to an
// upstream OIDC provider.
//
// Keep the JSON to a minimal size because the upstream provider could impose size limitations on
// the state param.
type UpstreamStateParamData struct {
	AuthParams    string              `json:"p"`
	UpstreamName  string              `json:"u"`
	Nonce         nonce.Nonce         `json:"n"`
	CSRFToken     csrftoken.CSRFToken `json:"c"`
	PKCECode      pkce.Code           `json:"k"`
	FormatVersion string              `json:"v"`
}

func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient {
	return &fosite.DefaultOpenIDConnectClient{
		DefaultClient: &fosite.DefaultClient{
			ID:            "pinniped-cli",
			Public:        true,
			RedirectURIs:  []string{"http://127.0.0.1/callback"},
			ResponseTypes: []string{"code"},
			GrantTypes:    []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"},
			Scopes:        []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience"},
		},
		TokenEndpointAuthMethod: "none",
	}
}

type TimeoutsConfiguration struct {
	// The length of time that our state param that we encrypt and pass to the upstream OIDC IDP should be considered
	// valid. If a state param generated by the authorize endpoint is sent to the callback endpoint after this much
	// time has passed, then the callback endpoint should reject it. This allows us to set a limit on how long
	// the end user has to finish their login with the upstream IDP, including the time that it takes to fumble
	// with password manager and two-factor authenticator apps, and also accounting for taking a coffee break while
	// the browser is sitting at the upstream IDP's login page.
	UpstreamStateParamLifespan time.Duration

	// How long an authcode issued by the callback endpoint is valid. This determines how much time the end user
	// has to come back to exchange the authcode for tokens at the token endpoint.
	AuthorizeCodeLifespan time.Duration

	// The lifetime of an downstream access token issued by the token endpoint. Access tokens should generally
	// be fairly short-lived.
	AccessTokenLifespan time.Duration

	// The lifetime of an downstream ID token issued by the token endpoint. This should generally be the same
	// as the AccessTokenLifespan, or longer if it would be useful for the user's proof of identity to be valid
	// for longer than their proof of authorization.
	IDTokenLifespan time.Duration

	// The lifetime of an downstream refresh token issued by the token endpoint. This should generally be
	// significantly longer than the access token lifetime, so it can be used to refresh the access token
	// multiple times. Once the refresh token expires, the user's session is over and they will need
	// to start a new authorization request, which will require them to log in again with the upstream IDP
	// in their web browser.
	RefreshTokenLifespan time.Duration

	// AuthorizationCodeSessionStorageLifetime is the length of time after which an authcode is allowed to be garbage
	// collected from storage. Authcodes are kept in storage after they are redeemed to allow the system to mark the
	// authcode as already used, so it can reject any future uses of the same authcode with special case handling which
	// include revoking the access and refresh tokens associated with the session. Therefore, this should be
	// significantly longer than the AuthorizeCodeLifespan, and there is probably no reason to make it longer than
	// the sum of the AuthorizeCodeLifespan and the RefreshTokenLifespan.
	AuthorizationCodeSessionStorageLifetime time.Duration

	// PKCESessionStorageLifetime is the length of time after which PKCE data is allowed to be garbage collected from
	// storage. PKCE sessions are closely related to authorization code sessions. After the authcode is successfully
	// redeemed, the PKCE session is explicitly deleted. After the authcode expires, the PKCE session is no longer needed,
	// but it is not explicitly deleted. Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll
	// avoid making it exactly the same as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it
	// while it is being used.
	PKCESessionStorageLifetime time.Duration

	// OIDCSessionStorageLifetime is the length of time after which the OIDC session data related to an authcode
	// is allowed to be garbage collected from storage. Due to a bug in an underlying library, these are not explicitly
	// deleted. Similar to the PKCE session, they are not needed anymore after the corresponding authcode has expired.
	// Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll avoid making it exactly the same
	// as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it while it is being used.
	OIDCSessionStorageLifetime time.Duration

	// AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed
	// to be garbage collected from storage.  These must exist in storage for as long as the refresh token is valid
	// or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan.
	AccessTokenSessionStorageLifetime time.Duration

	// RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed
	// to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid.
	// Therefore, this can be just slightly longer than the RefreshTokenLifespan. We'll avoid making it exactly the same
	// as RefreshTokenLifespan to avoid any chance of the garbage collector deleting it while it is being used.
	// If an expired token is still stored when the user tries to refresh it, then they will get a more specific
	// error message telling them that the token is expired, rather than a more generic error that is returned
	// when the token does not exist. If this is desirable, then the RefreshTokenSessionStorageLifetime can be made
	// to be significantly larger than RefreshTokenLifespan, at the cost of slower cleanup.
	RefreshTokenSessionStorageLifetime time.Duration
}

// Get the defaults for the Supervisor server.
func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration {
	accessTokenLifespan := 2 * time.Minute
	authorizationCodeLifespan := 10 * time.Minute
	refreshTokenLifespan := 9 * time.Hour

	return TimeoutsConfiguration{
		UpstreamStateParamLifespan:              90 * time.Minute,
		AuthorizeCodeLifespan:                   authorizationCodeLifespan,
		AccessTokenLifespan:                     accessTokenLifespan,
		IDTokenLifespan:                         accessTokenLifespan,
		RefreshTokenLifespan:                    refreshTokenLifespan,
		AuthorizationCodeSessionStorageLifetime: authorizationCodeLifespan + refreshTokenLifespan,
		PKCESessionStorageLifetime:              authorizationCodeLifespan + (1 * time.Minute),
		OIDCSessionStorageLifetime:              authorizationCodeLifespan + (1 * time.Minute),
		AccessTokenSessionStorageLifetime:       refreshTokenLifespan + accessTokenLifespan,
		RefreshTokenSessionStorageLifetime:      refreshTokenLifespan + accessTokenLifespan,
	}
}

func FositeOauth2Helper(
	oauthStore interface{},
	issuer string,
	hmacSecretOfLengthAtLeast32Func func() []byte,
	jwksProvider jwks.DynamicJWKSProvider,
	timeoutsConfiguration TimeoutsConfiguration,
) fosite.OAuth2Provider {
	oauthConfig := &compose.Config{
		IDTokenIssuer: issuer,

		AuthorizeCodeLifespan: timeoutsConfiguration.AuthorizeCodeLifespan,
		IDTokenLifespan:       timeoutsConfiguration.IDTokenLifespan,
		AccessTokenLifespan:   timeoutsConfiguration.AccessTokenLifespan,
		RefreshTokenLifespan:  timeoutsConfiguration.RefreshTokenLifespan,

		ScopeStrategy: fosite.ExactScopeStrategy,
		EnforcePKCE:   true,

		// "offline_access" as per https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
		RefreshTokenScopes: []string{coreosoidc.ScopeOfflineAccess},

		// The default is to support all prompt values from the spec.
		// See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
		// We'll make a best effort to support these by passing the value of this prompt param to the upstream IDP
		// and rely on its implementation of this param.
		AllowedPromptValues: nil,

		// Use the fosite default to make it more likely that off the shelf OIDC clients can work with the supervisor.
		MinParameterEntropy: fosite.MinParameterEntropy,
	}

	return compose.Compose(
		oauthConfig,
		oauthStore,
		&compose.CommonStrategy{
			// Note that Fosite requires the HMAC secret to be at least 32 bytes.
			CoreStrategy:               newDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func),
			OpenIDConnectTokenStrategy: newDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider),
		},
		nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets.
		compose.OAuth2AuthorizeExplicitFactory,
		compose.OAuth2RefreshTokenGrantFactory,
		compose.OpenIDConnectExplicitFactory,
		compose.OpenIDConnectRefreshFactory,
		compose.OAuth2PKCEFactory,
		TokenExchangeFactory,
	)
}

// FositeErrorForLog generates a list of information about the provided Fosite error that can be
// passed to a plog function (e.g., plog.Info()).
//
// Sample usage:
//   err := someFositeLibraryFunction()
//   if err != nil {
//     	plog.Info("some error", FositeErrorForLog(err)...)
//      ...
//    }
func FositeErrorForLog(err error) []interface{} {
	rfc6749Error := fosite.ErrorToRFC6749Error(err)
	keysAndValues := make([]interface{}, 0)
	keysAndValues = append(keysAndValues, "name")
	keysAndValues = append(keysAndValues, rfc6749Error.ErrorField)
	keysAndValues = append(keysAndValues, "status")
	keysAndValues = append(keysAndValues, rfc6749Error.Status())
	keysAndValues = append(keysAndValues, "description")
	keysAndValues = append(keysAndValues, rfc6749Error.DescriptionField)
	return keysAndValues
}

type UpstreamOIDCIdentityProvidersLister interface {
	GetOIDCIdentityProviders() []provider.UpstreamOIDCIdentityProviderI
}

type UpstreamLDAPIdentityProvidersLister interface {
	GetLDAPIdentityProviders() []provider.UpstreamLDAPIdentityProviderI
}

type UpstreamIdentityProvidersLister interface {
	UpstreamOIDCIdentityProvidersLister
	UpstreamLDAPIdentityProvidersLister
}

func GrantScopeIfRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) {
	if ScopeWasRequested(authorizeRequester, scopeName) {
		authorizeRequester.GrantScope(scopeName)
	}
}

func ScopeWasRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) bool {
	for _, scope := range authorizeRequester.GetRequestedScopes() {
		if scope == scopeName {
			return true
		}
	}
	return false
}
