Ctrl K
ComunidadGuías…

coderd/externalauth/externalauth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package externalauth

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/dustin/go-humanize"
	"github.com/google/go-github/v43/github"
	"github.com/sqlc-dev/pqtype"
	"golang.org/x/oauth2"
	xgithub "golang.org/x/oauth2/github"
	"golang.org/x/sync/singleflight"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
	"github.com/coder/coder/v2/coderd/promoauth"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/coderd/util/xhttp"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/retry"
)

const (
	// failureReasonLimit is the maximum text length of an error to be cached to the
	// database for a failed refresh token. In rare cases, the error could be a large
	// HTML payload.
	failureReasonLimit = 400

	// tokenRevocationTimeout timeout for requests to external oauth provider.
	tokenRevocationTimeout = 10 * time.Second

	// defaultRefreshRetryInitialBackoff is the starting wait between transient
	// refresh retry attempts when the IDP returns a temporary failure (5xx,
	// 429, network error, ...).
	defaultRefreshRetryInitialBackoff = 250 * time.Millisecond

	// defaultRefreshRetryMaxBackoff caps the exponential backoff between
	// transient refresh retry attempts.
	defaultRefreshRetryMaxBackoff = 2 * time.Second

	// defaultRefreshRetryTimeout bounds the total time spent retrying a
	// transient refresh failure across all attempts.
	defaultRefreshRetryTimeout = 10 * time.Second

	// defaultRefreshLeaseInitialBackoff is the starting wait between polls to
	// check whether another replica has finished refreshing.
	defaultRefreshLeaseInitialBackoff = 50 * time.Millisecond

	// defaultRefreshLeaseMaxBackoff is the maximum wait between polls to check
	// whether another replica has finished refreshing.
	defaultRefreshLeaseMaxBackoff = 500 * time.Millisecond

	// externalAuthLinkActiveLeaseConstraint indicates the lease could not be
	// acquired because something else has an active lease.
	externalAuthLinkActiveLeaseConstraint database.CheckConstraint = "external_auth_link_active_lease"
)

// SingleflightGroup exposes a subset of singleflight.Group for easier testing.
// singleflight.Group should be used instead of implementing this in production.
type SingleflightGroup interface {
	DoChan(key string, fn func() (any, error)) <-chan singleflight.Result
}

// Config is used for authentication for Git operations.
type Config struct {
	promoauth.InstrumentedOAuth2Config
	// Logs rate-limited validation warnings. Zero value discards output.
	Logger slog.Logger
	// rateLimitLogThrottle throttles rate-limited validation warnings.
	rateLimitLogThrottle logThrottle
	// ID is a unique identifier for the authenticator.
	ID string
	// Type is the type of provider.
	Type string

	ClientID     string
	ClientSecret string
	// DeviceAuth is set if the provider uses the device flow.
	DeviceAuth *DeviceAuth
	// DisplayName is the name of the provider to display to the user.
	DisplayName string
	// DisplayIcon is the path to an image that will be displayed to the user.
	DisplayIcon string

	// ExtraTokenKeys is a list of extra properties to
	// store in the database returned from the token endpoint.
	//
	// e.g. Slack returns `authed_user` in the token which is
	// a payload that contains information about the authenticated
	// user.
	ExtraTokenKeys []string

	// NoRefresh stops Coder from using the refresh token
	// to renew the access token.
	//
	// Some organizations have security policies that require
	// re-authentication for every token.
	NoRefresh bool
	// ValidateURL ensures an access token is valid before
	// returning it to the user. If omitted, tokens will
	// not be validated before being returned.
	ValidateURL string

	RevokeURL     string
	RevokeTimeout time.Duration

	// Regex is a Regexp matched against URLs for
	// a Git clone. e.g. "Username for 'https://github.com':"
	// The regex would be `github\.com`..
	Regex *regexp.Regexp
	// APIBaseURL is the base URL for provider REST API calls
	// (e.g., "https://api.github.com" for GitHub). Derived from
	// defaults when not explicitly configured.
	APIBaseURL string
	// If nil, http.DefaultClient is used. The value is read once at
	// the first successful Git() call; later assignments have no
	// effect because the provider is memoized.
	HTTPClient *http.Client
	// AppInstallURL is for GitHub App's (and hopefully others eventually)
	// to provide a link to install the app. There's installation
	// of the application, and user authentication. It's possible
	// for the user to authenticate but the application to not.
	AppInstallURL string
	// AppInstallationsURL is an API endpoint that returns a list of
	// installations for the user. This is used for GitHub Apps.
	AppInstallationsURL string
	// Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release.
	//
	// MCPURL is the endpoint that clients must use to communicate with the associated
	// MCP server.
	MCPURL string
	// Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release.
	//
	// MCPToolAllowRegex is a [regexp.Regexp] to match tools which are explicitly allowed to be
	// injected into Coder AI Bridge upstream requests.
	// In the case of conflicts, [MCPToolDenylistPattern] overrides items evaluated by this list.
	// This field can be nil if unspecified in the config.
	MCPToolAllowRegex *regexp.Regexp
	// Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release.
	//
	// MCPToolDenyRegex is a [regexp.Regexp] to match tools which are explicitly NOT allowed to be
	// injected into Coder AI Bridge upstream requests.
	// In the case of conflicts, items evaluated by this list override [MCPToolAllowRegex].
	// This field can be nil if unspecified in the config.
	MCPToolDenyRegex              *regexp.Regexp
	CodeChallengeMethodsSupported []promoauth.Oauth2PKCEChallengeMethod

	// RefreshRetryInitialBackoff overrides the initial wait between transient
	// refresh retry attempts. A zero value applies
	// defaultRefreshRetryInitialBackoff.
	RefreshRetryInitialBackoff time.Duration
	// RefreshRetryMaxBackoff overrides the maximum wait between transient
	// refresh retry attempts. A zero value applies
	// defaultRefreshRetryMaxBackoff.
	RefreshRetryMaxBackoff time.Duration
	// RefreshRetryTimeout overrides the total budget for retrying a transient
	// refresh failure across all attempts. A zero value applies
	// defaultRefreshRetryTimeout. A negative value disables transient-failure
	// retries entirely, so exactly one refresh attempt is made.
	RefreshRetryTimeout time.Duration

	// RefreshGroup deduplicates concurrent requests.
	RefreshGroup SingleflightGroup

	// RefreshLeaseInitialBackoff is the starting wait between polls to check
	// whether another replica has finished refreshing.
	RefreshLeaseInitialBackoff time.Duration

	// RefreshLeaseMaxBackoff is the maximum wait between polls to check whether
	// another replica has finished refreshing.
	RefreshLeaseMaxBackoff time.Duration

	gitProviderMu sync.Mutex
	// gitProvider memoizes the provider so the GitHub ETag response
	// cache survives across Git calls.
	gitProvider gitprovider.Provider
}

// Git returns a Provider for this config. It returns (nil, nil) when
// this config's type has no provider implementation, which covers both
// non-git types (e.g. Slack, JFrog) and git types that are not
// implemented yet (bitbucket-*, azure-devops*, gitea). Callers cannot
// distinguish the two cases from the return values. Returns a non-nil
// error if provider construction fails.
//
// The provider is built on the first successful call and cached for
// the lifetime of the Config, so its in-memory response cache
// survives across calls. The provider uses c.HTTPClient for API
// requests; if c.HTTPClient is nil, http.DefaultClient is used.
func (c *Config) Git() (gitprovider.Provider, error) {
	norm := strings.ToLower(c.Type)
	if !codersdk.EnhancedExternalAuthProvider(norm).Git() {
		return nil, nil //nolint:nilnil // nil provider means non-git type, not an error
	}
	c.gitProviderMu.Lock()
	defer c.gitProviderMu.Unlock()
	if c.gitProvider != nil {
		return c.gitProvider, nil
	}
	p, err := gitprovider.New(norm, c.APIBaseURL, c.HTTPClient, gitprovider.WithLogger(c.Logger.Named("gitprovider")))
	if err != nil {
		return nil, err
	}
	c.gitProvider = p
	return c.gitProvider, nil
}

// GenerateTokenExtra generates the extra token data to store in the database.
func (c *Config) GenerateTokenExtra(token *oauth2.Token) (pqtype.NullRawMessage, error) {
	if len(c.ExtraTokenKeys) == 0 {
		return pqtype.NullRawMessage{}, nil
	}
	extraMap := map[string]any{}
	for _, key := range c.ExtraTokenKeys {
		extraMap[key] = token.Extra(key)
	}
	data, err := json.Marshal(extraMap)
	if err != nil {
		return pqtype.NullRawMessage{}, err
	}
	return pqtype.NullRawMessage{
		RawMessage: data,
		Valid:      true,
	}, nil
}

// InvalidTokenError is a case where the "RefreshToken" failed to complete
// as a result of invalid credentials. Error contains the reason of the failure.
type InvalidTokenError string

func (e InvalidTokenError) Error() string {
	return string(e)
}

func IsInvalidTokenError(err error) bool {
	var invalidTokenError InvalidTokenError
	return xerrors.As(err, &invalidTokenError)
}

// RefreshToken automatically refreshes the token if expired and permitted.
// Tokens are then validated, whether or not they were refreshed.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) {
	// If the token is expired and refresh is disabled, prompt the user to
	// authenticate manually again.
	token := externalAuthLink.OAuthToken()
	if c.NoRefresh && !token.Valid() {
		return externalAuthLink, InvalidTokenError("token expired and refreshing is disabled")
	}

	// Prevent parallel refreshes by waiting for the result of any already
	// in-flight refresh.  Otherwise, the parallel calls will fail with a bad
	// refresh token error as they can only be used once.
	key := c.ID + ":" + externalAuthLink.UserID.String()
	ch := c.RefreshGroup.DoChan(key, func() (any, error) {
		// Use a detached context so if a request is canceled or times out it does
		// not cancel all the other requests as well.  The deadline is arbitrary but
		// we give at least enough time for the refresh timeout then another 10
		// seconds for updating the database and validating the link.
		timeout := 10 * time.Second
		if c.RefreshRetryTimeout > 0 {
			timeout += c.RefreshRetryTimeout
		}
		ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
		defer cancel()

		// Although TokenSource().Token() will also check if the token is expired,
		// do so ahead of time here to avoid the overhead of acquiring the lease
		// when no refresh is required.  Also validate the token under this lock,
		// since we already have it anyway.
		if !token.Valid() {
			return c.refreshAndValidateWithLease(ctx, db, externalAuthLink, timeout)
		}

		// Validate the token even if we did not refresh.  This is done within the
		// group but outside the lease, meaning multiple instances may validate at
		// the same time, but the lock overhead seems greater than the overhead of
		// occasional concurrent validation, and is not strictly necessary like it
		// is with refreshing, so avoid the lock when not refreshing.
		_, err := c.validateWithRetry(ctx, token)
		return externalAuthLink, err
	})
	select {
	case results := <-ch:
		if newlink, ok := results.Val.(database.ExternalAuthLink); ok {
			return newlink, results.Err
		} else if results.Err == nil {
			return externalAuthLink, xerrors.Errorf("got invalid type from token refresh: %T", results.Val)
		}
		return externalAuthLink, results.Err
	case <-ctx.Done():
		return externalAuthLink, ctx.Err()
	}
}

// refreshAndValidateWithLease wraps the refresh and subsequent validation with
// concurrency protection between multiple instances by using a lease column on
// the link's row.
func (c *Config) refreshAndValidateWithLease(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink, timeout time.Duration) (newLink database.ExternalAuthLink, refreshErr error) {
	// There may be other replicas also wanting to refresh; try to get a lease
	// on the row.  This also ensures we have the latest link.
	var leasedLink database.ExternalAuthLink
	initial := defaultRefreshLeaseInitialBackoff
	if c.RefreshLeaseInitialBackoff > 0 {
		initial = c.RefreshLeaseInitialBackoff
	}
	maximum := defaultRefreshLeaseMaxBackoff
	if c.RefreshLeaseMaxBackoff > 0 {
		maximum = c.RefreshLeaseMaxBackoff
	}
	r := retry.New(initial, maximum)
	// Make sure to release the lease if we manage to get one before returning.
	defer func() {
		if leasedLink.RefreshLeaseExpiresAt.Valid {
			refreshErr = errors.Join(refreshErr, db.ReleaseExternalAuthLinkRefreshLease(ctx, database.ReleaseExternalAuthLinkRefreshLeaseParams{
				ProviderID: externalAuthLink.ProviderID,
				UserID:     externalAuthLink.UserID,
				// The row will only update if we hold the current lease.  This is
				// somewhat redundant since if we lost the lease our context would be
				// expired anyway, so it is not actually possible to get the sql.ErrNoRows
				// that would result from this.
				RefreshLeaseExpiresAt: leasedLink.RefreshLeaseExpiresAt,
			}))
		}
	}()
	for !leasedLink.RefreshLeaseExpiresAt.Valid {
		// Acquiring the lease also returns the link, so we can get the expiry date
		// that the database sets and check to see if it was refreshed in the
		// meantime.
		var err error
		leasedLink, err = db.AcquireExternalAuthLinkRefreshLease(ctx, database.AcquireExternalAuthLinkRefreshLeaseParams{
			ProviderID: externalAuthLink.ProviderID,
			UserID:     externalAuthLink.UserID,
			TimeoutMs:  timeout.Milliseconds(),
		})
		switch {
		// Something still holds the lock; keep waiting.
		case database.IsCheckViolation(err, externalAuthLinkActiveLeaseConstraint):
			if !r.Wait(ctx) {
				return externalAuthLink, ctx.Err()
			}
		// Some kind of DB error or the row does not exist.
		case err != nil:
			return externalAuthLink, err
		// Something else refreshed either while we were waiting or before we got a
		// hold of the lease but after we initially fetched the link.
		case leasedLink.OAuthRefreshToken != externalAuthLink.OAuthRefreshToken:
			if leasedLink.OauthRefreshFailureReason != "" {
				return externalAuthLink, refreshError(leasedLink, leasedLink.OauthRefreshFailureReason)
			}
			return leasedLink, nil
		}
	}

	// Otherwise the token has still not been updated; refresh it now.
	newLink, refreshErr = c.refreshAndValidateToken(ctx, db, leasedLink)
	return newLink, refreshErr
}

// refreshError converts a failure reason to an error.
func refreshError(link database.ExternalAuthLink, reason string) error {
	return InvalidTokenError(fmt.Sprintf("token expired and refreshing failed %s with: %s",
		// Do not return the exact time, because then we have to know what timezone
		// the user is in. This approximate time is good enough.
		humanize.Time(link.UpdatedAt),
		reason,
	))
}

// refreshAndValidateToken does the actual token refresh, persists the result to
// the database, then validates the token.  The provided link must be up to date
// with the currently held lease.
func (c *Config) refreshAndValidateToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) {
	existingToken := externalAuthLink.OAuthToken()

	// This is additional defensive programming. Because TokenSource is an
	// interface, we cannot be sure that the implementation will treat an 'IsZero'
	// time as "not-expired". The default implementation does, but a custom
	// implementation might not. Removing the refresh token will guarantee a
	// refresh will fail.
	if c.NoRefresh {
		existingToken.RefreshToken = ""
	}

	// NOTE: TokenSource(...).Token() will short-circuit if the token:
	// - is not expired (returns original token)
	// - is expired and has no refresh token (returns error)
	// This means we will avoid making useless HTTP requests, and only get errors
	// when an actual refresh attempt is made.
	//
	// External providers (GitHub in particular) intermittently fail token
	// refreshes with transient errors such as 5xx responses, network timeouts,
	// and rate-limited 429s. Retry with exponential backoff before surfacing
	// the failure so a brief upstream blip does not force users to
	// re-authenticate. Errors classified as permanent by isFailedRefresh
	// (e.g. revoked or rotated refresh tokens) are not retried since those
	// will never succeed and retrying wastes the refresh quota.
	token, err := c.refreshTokenWithRetry(ctx, existingToken)
	if err != nil {
		// A refresh attempt can fail for numerous reasons. If it fails because of a
		// bad refresh token, then the refresh token is invalid, and we should get
		// rid of it. Keeping it around will cause additional refresh attempts that
		// will fail and cost us api rate limits.  Also save the error message for
		// debugging purposes.
		if isFailedRefresh(existingToken, err) {
			reason := err.Error()
			if len(reason) > failureReasonLimit {
				// Limit the length of the error message to prevent
				// spamming the database with long error messages.
				reason = reason[:failureReasonLimit]
			}
			_, updateErr := db.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
				ProviderID: externalAuthLink.ProviderID,
				UserID:     externalAuthLink.UserID,
				UpdatedAt:  dbtime.Now(),
				// Remove the invalid refresh token so it is never used again.
				OAuthRefreshToken:      "",
				OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
				// The cached reason can be used to know why the token was zeroed out.
				OauthRefreshFailureReason: reason,
				// Preserve the access token, expiry, and extra info as they are.
				OAuthAccessToken:      externalAuthLink.OAuthAccessToken,
				OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
				OAuthExpiry:           externalAuthLink.OAuthExpiry,
				OAuthExtra:            externalAuthLink.OAuthExtra,
				// The row will only update if we hold the current lease.  This is
				// somewhat redundant since if we lost the lease our context would be
				// expired anyway, so it is not actually possible to get the
				// sql.ErrNoRows that would result from this.
				RefreshLeaseExpiresAt: externalAuthLink.RefreshLeaseExpiresAt,
			})
			if updateErr != nil {
				// This error should be rare.
				return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token failed: %q, then removing refresh token failed: %q", err.Error(), updateErr.Error()))
			}
			// The refresh token was cleared
			externalAuthLink.OAuthRefreshToken = ""
			externalAuthLink.UpdatedAt = dbtime.Now()
		}

		// Unfortunately have to match exactly on the error message string.
		// Improve the error message to account refresh tokens are deleted if
		// invalid on our end.
		//
		// This error messages comes from the oauth2 package on our client side.
		// So this check is not against a server generated error message.
		// Error source: https://github.com/golang/oauth2/blob/master/oauth2.go#L277
		if err.Error() == "oauth2: token expired and refresh token is not set" {
			if externalAuthLink.OauthRefreshFailureReason != "" {
				// A cached refresh failure error exists. So the refresh token was set, but was invalid, and zeroed out.
				// Return this cached error for the original refresh attempt.
				return externalAuthLink, refreshError(externalAuthLink, externalAuthLink.OauthRefreshFailureReason)
			}

			return externalAuthLink, InvalidTokenError("token expired, refreshing failed and will not be retried")
		}

		return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error()))
	}

	extra, err := c.GenerateTokenExtra(token)
	if err != nil {
		return externalAuthLink, xerrors.Errorf("generate token extra: %w", err)
	}

	// Persist the refreshed token to the DB before validation. GitHub rotates
	// refresh tokens on every use, so the old refresh token is already invalid on
	// the IDP side. If we validated first and the validation endpoint was
	// unavailable (e.g. rate-limited 403), the new token would be silently lost
	// and the user would be forced to re-authenticate manually.
	updatedAuthLink, err := db.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
		ProviderID:             externalAuthLink.ProviderID,
		UserID:                 externalAuthLink.UserID,
		UpdatedAt:              dbtime.Now(),
		OAuthAccessToken:       token.AccessToken,
		OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will update as required
		OAuthRefreshToken:      token.RefreshToken,
		OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
		OAuthExpiry:            token.Expiry,
		OAuthExtra:             extra,
		// If there was any failure before, we can clear it now.
		OauthRefreshFailureReason: "",
		// The row will only update if we hold the current lease.  This is somewhat
		// redundant since if we lost the lease our context would be expired anyway,
		// so it is not actually possible to get the sql.ErrNoRows that would result
		// from this.
		RefreshLeaseExpiresAt: externalAuthLink.RefreshLeaseExpiresAt,
	})
	if err != nil {
		return updatedAuthLink, xerrors.Errorf("persist refreshed token: %w", err)
	}

	user, err := c.validateWithRetry(ctx, token)
	if err != nil {
		return updatedAuthLink, err
	}

	// Update the associated user's github.com user ID if the token is for
	// github.com and validation returned user info.
	if IsGithubDotComURL(c.AuthCodeURL("")) && user != nil {
		err = db.UpdateUserGithubComUserID(ctx, database.UpdateUserGithubComUserIDParams{
			ID: updatedAuthLink.UserID,
			GithubComUserID: sql.NullInt64{
				Int64: user.ID,
				Valid: true,
			},
		})
		if err != nil {
			return updatedAuthLink, xerrors.Errorf("update user github com user id: %w", err)
		}
	}
	return updatedAuthLink, nil
}

// refreshTokenWithRetry exchanges the refresh token for a new access token,
// retrying with exponential backoff on transient failures. Permanent
// failures (as classified by isFailedRefresh), the no-op case where no
// refresh token is set, and a negative RefreshRetryTimeout all bypass the
// retry loop so a doomed or unwanted refresh is not repeatedly attempted.
func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth2.Token) (*oauth2.Token, error) {
	// Without a refresh token the oauth2 library short-circuits with
	// "token expired and refresh token is not set". No retry can recover
	// from that, so make a single attempt and return.
	if existingToken.RefreshToken == "" {
		return c.TokenSource(ctx, existingToken).Token()
	}

	// A negative RefreshRetryTimeout disables retries entirely, so make a
	// single attempt and return.
	if c.RefreshRetryTimeout < 0 {
		return c.TokenSource(ctx, existingToken).Token()
	}

	initial := c.RefreshRetryInitialBackoff
	if initial <= 0 {
		initial = defaultRefreshRetryInitialBackoff
	}
	maximum := c.RefreshRetryMaxBackoff
	if maximum <= 0 {
		maximum = defaultRefreshRetryMaxBackoff
	}
	total := c.RefreshRetryTimeout
	if total == 0 {
		total = defaultRefreshRetryTimeout
	}

	retryCtx, retryCancel := context.WithTimeout(ctx, total)
	defer retryCancel()
	backoff := retry.New(initial, maximum)

	for {
		token, err := c.TokenSource(ctx, existingToken).Token()
		if err == nil || isFailedRefresh(existingToken, err) {
			return token, err
		}
		// Bail out before waiting if the retry budget is already gone.
		// retry.Wait selects between time.After(delay) and ctx.Done(); when
		// delay is zero and the context is already canceled the two cases
		// race nondeterministically, which would cause an unwanted extra
		// refresh attempt with a near-zero budget.
		if retryCtx.Err() != nil {
			return token, err
		}
		if !backoff.Wait(retryCtx) {
			return token, err
		}
	}
}

// validateWithRetry validates the provided link, retrying on failure.  On
// success return the user info.
func (c *Config) validateWithRetry(ctx context.Context, token *oauth2.Token) (*codersdk.ExternalAuthUser, error) {
	r := retry.New(50*time.Millisecond, 200*time.Millisecond)
	// See the comment below why the retry and cancel is required.
	retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second)
	defer retryCtxCancel()

validate:
	valid, user, err := c.ValidateToken(ctx, token)
	if err != nil {
		return nil, xerrors.Errorf("validate external auth token: %w", err)
	}
	if !valid {
		// A customer using GitHub in Australia reported that validating immediately
		// after refreshing the token would intermittently fail with a 401. Waiting
		// a few milliseconds with the exact same token on the exact same request
		// would resolve the issue. It seems likely that the write is not propagating
		// to the read replica in time.
		//
		// We do an exponential backoff here to give the write time to propagate.
		if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) && r.Wait(retryCtx) {
			goto validate
		}
		// The token is no longer valid!
		return nil, InvalidTokenError("token failed to validate")
	}

	return user, nil
}

// ValidateToken checks if the Git token provided is valid.
// The user is optionally returned if the provider supports it.
// Returns valid=true when: the provider confirmed the token,
// no ValidateURL is configured, or the validation endpoint
// returned a rate-limited response (403 with rate-limit headers
// or 429).
func (c *Config) ValidateToken(ctx context.Context, token *oauth2.Token) (bool, *codersdk.ExternalAuthUser, error) {
	if token == nil {
		return false, nil, xerrors.New("validate external auth token: token is nil")
	}
	if !token.Valid() {
		return false, nil, nil
	}

	if c.ValidateURL == "" {
		// Default that the token is valid if no validation URL is provided.
		return true, nil, nil
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ValidateURL, nil)
	if err != nil {
		return false, nil, err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
	res, err := c.Do(ctx, promoauth.SourceValidateToken, req)
	if err != nil {
		return false, nil, err
	}
	defer res.Body.Close()
	switch res.StatusCode {
	case http.StatusUnauthorized:
		// The token is no longer valid!
		return false, nil, nil

	case http.StatusForbidden:
		// Some providers (notably GitHub) use 403 for both "token
		// revoked" and "rate limit exceeded." If standard rate-limit
		// headers are present, the token may still be valid and the
		// validation endpoint is rejecting for a transient reason.
		// Treat it as optimistically valid rather than discarding
		// the token.
		if xhttp.IsRateLimited(res) {
			c.logRateLimitedValidation(ctx, http.StatusForbidden, "rate_limit_headers")
			return true, nil, nil
		}
		// No rate-limit headers: genuine token revocation or
		// permission error.
		return false, nil, nil

	case http.StatusTooManyRequests:
		// GitHub can return either 403 or 429 for rate limits.
		// Treat 429 the same as a rate-limited 403: optimistically
		// valid. The token was likely just issued by the IDP; the
		// validation endpoint is transiently overloaded.
		c.logRateLimitedValidation(ctx, http.StatusTooManyRequests, "status_code")
		return true, nil, nil

	case http.StatusOK:
		// Success, handled below.

	default:
		data, _ := io.ReadAll(res.Body)
		return false, nil, xerrors.Errorf("status %d: body: %s", res.StatusCode, data)
	}

	var user *codersdk.ExternalAuthUser
	if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
		var ghUser github.User
		err = json.NewDecoder(res.Body).Decode(&ghUser)
		if err == nil {
			user = &codersdk.ExternalAuthUser{
				ID:         ghUser.GetID(),
				Login:      ghUser.GetLogin(),
				AvatarURL:  ghUser.GetAvatarURL(),
				ProfileURL: ghUser.GetHTMLURL(),
				Name:       ghUser.GetName(),
			}
		}
	}

	return true, user, nil
}

// rateLimitLogInterval is the minimum time between rate-limited validation
// warnings emitted per Config.
const rateLimitLogInterval = time.Minute

// logRateLimitedValidation warns that a token was kept valid without
// provider confirmation due to a rate-limited response. At most one
// warning is emitted per Config per rateLimitLogInterval; the line
// carries the number of occurrences suppressed since the previous one.
func (c *Config) logRateLimitedValidation(ctx context.Context, statusCode int, reason string) {
	suppressed, ok := c.rateLimitLogThrottle.shouldLog(time.Now(), rateLimitLogInterval)
	if !ok {
		return
	}
	c.Logger.Warn(ctx, "external auth validation endpoint rate-limited; keeping token without provider confirmation",
		slog.F("status_code", statusCode),
		slog.F("reason", reason),
		slog.F("suppressed", suppressed),
	)
}

// logThrottle allows one event per interval and counts the events
// suppressed in between. Safe for concurrent use; the zero value is
// ready for use.
type logThrottle struct {
	mu         sync.Mutex
	lastLog    time.Time
	suppressed int64
}

// shouldLog reports whether an event occurring at now may be logged,
// allowing at most one event per interval. When it returns true, it also
// returns the number of events suppressed since the last allowed one;
// if two or more intervals have elapsed, the stale count is discarded
// and zero is returned.
func (t *logThrottle) shouldLog(now time.Time, interval time.Duration) (int64, bool) {
	t.mu.Lock()
	defer t.mu.Unlock()
	sinceLast := now.Sub(t.lastLog)
	if sinceLast < interval {
		t.suppressed++
		return 0, false
	}
	n := t.suppressed
	if sinceLast >= 2*interval {
		n = 0
	}
	t.suppressed = 0
	t.lastLog = now
	return n, true
}

type AppInstallation struct {
	ID int
	// Login is the username of the installation.
	Login string
	// URL is a link to configure the app install.
	URL string
}

// AppInstallations returns a list of app installations for the given token.
// If the provider does not support app installations, it returns nil.
func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.ExternalAuthAppInstallation, bool, error) {
	if c.AppInstallationsURL == "" {
		return nil, false, nil
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
	if err != nil {
		return nil, false, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	res, err := c.Do(ctx, promoauth.SourceAppInstallations, req)
	if err != nil {
		return nil, false, err
	}
	defer res.Body.Close()
	// It's possible the installation URL is misconfigured, so we don't
	// want to return an error here.
	if res.StatusCode != http.StatusOK {
		return nil, false, nil
	}
	installs := []codersdk.ExternalAuthAppInstallation{}
	if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
		var ghInstalls struct {
			Installations []*github.Installation `json:"installations"`
		}
		err = json.NewDecoder(res.Body).Decode(&ghInstalls)
		if err != nil {
			return nil, false, err
		}
		for _, installation := range ghInstalls.Installations {
			account := installation.GetAccount()
			if account == nil {
				continue
			}
			installs = append(installs, codersdk.ExternalAuthAppInstallation{
				ID:           int(installation.GetID()),
				ConfigureURL: installation.GetHTMLURL(),
				Account: codersdk.ExternalAuthUser{
					ID:         account.GetID(),
					Login:      account.GetLogin(),
					AvatarURL:  account.GetAvatarURL(),
					ProfileURL: account.GetHTMLURL(),
					Name:       account.GetName(),
				},
			})
		}
	}
	return installs, true, nil
}

func (c *Config) RevokeToken(ctx context.Context, link database.ExternalAuthLink) (bool, error) {
	if c.RevokeURL == "" {
		return false, nil
	}

	reqCtx, cancel := context.WithTimeout(ctx, c.RevokeTimeout)
	defer cancel()
	req, err := c.TokenRevocationRequest(reqCtx, link)
	if err != nil {
		return false, err
	}

	res, err := c.Do(ctx, promoauth.SourceRevoke, req)
	if err != nil {
		return false, err
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		return false, err
	}

	if c.TokenRevocationResponseOk(res) {
		return true, nil
	}
	return false, xerrors.Errorf("failed to revoke token: %d %s", res.StatusCode, string(body))
}

func (c *Config) TokenRevocationRequest(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
	if c.Type == codersdk.EnhancedExternalAuthProviderGitHub.String() {
		return c.TokenRevocationRequestGitHub(ctx, link)
	}
	return c.TokenRevocationRequestRFC7009(ctx, link)
}

func (c *Config) TokenRevocationRequestRFC7009(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
	p := url.Values{}
	p.Add("client_id", c.ClientID)
	p.Add("client_secret", c.ClientSecret)
	if link.OAuthRefreshToken != "" {
		p.Add("token_type_hint", "refresh_token")
		p.Add("token", link.OAuthRefreshToken)
	} else {
		p.Add("token_type_hint", "access_token")
		p.Add("token", link.OAuthAccessToken)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.RevokeURL, strings.NewReader(p.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", link.OAuthAccessToken))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return req, nil
}

func (c *Config) TokenRevocationRequestGitHub(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
	// GitHub doesn't follow RFC spec
	// https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-authorization
	body := fmt.Sprintf("{\"access_token\":%q}", link.OAuthAccessToken)
	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.RevokeURL, strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Add("Accept", "application/vnd.github+json")
	req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
	req.SetBasicAuth(c.ClientID, c.ClientSecret)
	return req, nil
}

func (c *Config) TokenRevocationResponseOk(res *http.Response) bool {
	// RFC spec on successful revocation returns 200, GitHub 204
	if c.Type == codersdk.EnhancedExternalAuthProviderGitHub.String() {
		return res.StatusCode == http.StatusNoContent
	}
	return res.StatusCode == http.StatusOK
}

type DeviceAuth struct {
	// Config is provided for the http client method.
	Config   promoauth.InstrumentedOAuth2Config
	ClientID string
	TokenURL string
	Scopes   []string
	CodeURL  string
}

// AuthorizeDevice begins the device authorization flow.
// See: https://tools.ietf.org/html/rfc8628#section-3.1
func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
	if c.CodeURL == "" {
		return nil, xerrors.New("oauth2: device code URL not set")
	}
	codeURL, err := c.formatDeviceCodeURL()
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json")

	do := http.DefaultClient.Do
	if c.Config != nil {
		// The cfg can be nil in unit tests.
		do = func(req *http.Request) (*http.Response, error) {
			return c.Config.Do(ctx, promoauth.SourceAuthorizeDevice, req)
		}
	}

	resp, err := do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var r struct {
		codersdk.ExternalAuthDevice
		ErrorDescription string `json:"error_description"`
	}
	err = json.NewDecoder(resp.Body).Decode(&r)
	if err != nil {
		mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
		if err != nil {
			mediaType = "unknown"
		}

		// If the json fails to decode, do a best effort to return a better error.
		switch {
		case resp.StatusCode == http.StatusTooManyRequests:
			retryIn := "please try again later"
			resetIn := resp.Header.Get("x-ratelimit-reset")
			if resetIn != "" {
				// Best effort to tell the user exactly how long they need
				// to wait for.
				unix, err := strconv.ParseInt(resetIn, 10, 64)
				if err == nil {
					waitFor := time.Unix(unix, 0).Sub(time.Now().Truncate(time.Second))
					retryIn = fmt.Sprintf(" retry after %s", waitFor.Truncate(time.Second))
				}
			}
			// 429 returns a plaintext payload with a message.
			return nil, xerrors.New(fmt.Sprintf("rate limit hit, unable to authorize device. %s", retryIn))
		case mediaType == "application/x-www-form-urlencoded":
			return nil, xerrors.Errorf("status_code=%d, payload response is form-url encoded, expected a json payload", resp.StatusCode)
		default:
			return nil, xerrors.Errorf("status_code=%d, mediaType=%s: %w", resp.StatusCode, mediaType, err)
		}
	}
	if r.ErrorDescription != "" {
		return nil, xerrors.New(r.ErrorDescription)
	}
	return &r.ExternalAuthDevice, nil
}

type ExchangeDeviceCodeResponse struct {
	AccessToken      string `json:"access_token"`
	RefreshToken     string `json:"refresh_token"`
	ExpiresIn        int    `json:"expires_in"`
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description"`
}

// ExchangeDeviceCode exchanges a device code for an access token.
// The boolean returned indicates whether the device code is still pending
// and the caller should try again.
func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
	if c.TokenURL == "" {
		return nil, xerrors.New("oauth2: token URL not set")
	}
	tokenURL, err := c.formatDeviceTokenURL(deviceCode)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, codersdk.ReadBodyAsError(resp)
	}
	var body ExchangeDeviceCodeResponse
	err = json.NewDecoder(resp.Body).Decode(&body)
	if err != nil {
		return nil, err
	}
	if body.Error != "" {
		return nil, xerrors.New(body.Error)
	}
	// If expiresIn is 0, then the token never expires.
	expires := dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second)
	if body.ExpiresIn == 0 {
		expires = time.Time{}
	}
	return &oauth2.Token{
		AccessToken:  body.AccessToken,
		RefreshToken: body.RefreshToken,
		Expiry:       expires,
	}, nil
}

func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) {
	tok, err := url.Parse(c.TokenURL)
	if err != nil {
		return "", err
	}
	tok.RawQuery = url.Values{
		"client_id":   {c.ClientID},
		"device_code": {deviceCode},
		"grant_type":  {"urn:ietf:params:oauth:grant-type:device_code"},
	}.Encode()
	return tok.String(), nil
}

func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {
	cod, err := url.Parse(c.CodeURL)
	if err != nil {
		return "", err
	}
	cod.RawQuery = url.Values{
		"client_id": {c.ClientID},
		"scope":     c.Scopes,
	}.Encode()
	return cod.String(), nil
}

// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
// If httpClient is nil, http.DefaultClient is used.
func ConvertConfig(ctx context.Context, logger slog.Logger, instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL, httpClient *http.Client) ([]*Config, error) {
	ids := map[string]struct{}{}
	configs := []*Config{}
	for _, entry := range entries {
		// Applies defaults to the config entry.
		// This allows users to very simply state that they type is "GitHub",
		// apply their client secret and ID, and have the UI appear nicely.
		applyDefaultsToConfig(&entry)

		logger := logger.Named("externalauth").With(slog.F("provider_id", entry.ID), slog.F("provider_type", entry.Type))

		valid := codersdk.NameValid(entry.ID)
		if valid != nil {
			return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid)
		}
		if entry.ClientID == "" {
			return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID)
		}

		_, exists := ids[entry.ID]
		if exists {
			if entry.ID == entry.Type {
				return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", entry.Type)
			}
			return nil, xerrors.Errorf("multiple external auth providers exist with the id %q. specify a unique id for each", entry.ID)
		}
		ids[entry.ID] = struct{}{}

		baseRedirectURL := accessURL
		if entry.RedirectURL != "" {
			var err error
			baseRedirectURL, err = url.Parse(entry.RedirectURL)
			if err != nil {
				return nil, xerrors.Errorf("parse redirect url override for external auth provider %q: %w", entry.ID, err)
			}
			logger.Warn(ctx, "custom redirect URL used instead of 'access_url', ensure this matches the value configured in your provider")
		}
		authRedirect, err := baseRedirectURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID))
		if err != nil {
			return nil, xerrors.Errorf("parse callback url for external auth provider %q: %w", entry.ID, err)
		}

		var regex *regexp.Regexp
		if entry.Regex != "" {
			regex, err = regexp.Compile(entry.Regex)
			if err != nil {
				return nil, xerrors.Errorf("compile regex for external auth provider %q: %w", entry.ID, entry.Regex)
			}
		}

		oc := &oauth2.Config{
			ClientID:     entry.ClientID,
			ClientSecret: entry.ClientSecret,
			Endpoint: oauth2.Endpoint{
				AuthURL:  entry.AuthURL,
				TokenURL: entry.TokenURL,
			},
			RedirectURL: authRedirect.String(),
			Scopes:      entry.Scopes,
		}

		var oauthConfig promoauth.OAuth2Config = oc
		// Azure DevOps uses JWT token authentication!
		if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
			oauthConfig = &jwtConfig{oc}
		}
		if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevopsEntra) {
			oauthConfig = &entraV1Oauth{oc}
		}
		if entry.Type == string(codersdk.EnhancedExternalAuthProviderJFrog) {
			oauthConfig = &exchangeWithClientSecret{oc}
		}

		instrumented := instrument.New(entry.ID, oauthConfig)
		if strings.EqualFold(entry.Type, string(codersdk.EnhancedExternalAuthProviderGitHub)) {
			instrumented = instrument.NewGithub(entry.ID, oauthConfig)
		}

		var mcpToolAllow *regexp.Regexp
		var mcpToolDeny *regexp.Regexp
		if entry.MCPToolAllowRegex != "" {
			mcpToolAllow, err = regexp.Compile(entry.MCPToolAllowRegex)
			if err != nil {
				return nil, xerrors.Errorf("compile MCP tool allow regex for external auth provider %q: %w", entry.ID, entry.MCPToolAllowRegex)
			}
		}
		if entry.MCPToolDenyRegex != "" {
			mcpToolDeny, err = regexp.Compile(entry.MCPToolDenyRegex)
			if err != nil {
				return nil, xerrors.Errorf("compile MCP tool deny regex for external auth provider %q: %w", entry.ID, entry.MCPToolDenyRegex)
			}
		}

		cfg := &Config{
			InstrumentedOAuth2Config:      instrumented,
			Logger:                        logger,
			ID:                            entry.ID,
			ClientID:                      entry.ClientID,
			ClientSecret:                  entry.ClientSecret,
			Regex:                         regex,
			APIBaseURL:                    entry.APIBaseURL,
			HTTPClient:                    httpClient,
			Type:                          entry.Type,
			NoRefresh:                     entry.NoRefresh,
			ValidateURL:                   entry.ValidateURL,
			RevokeURL:                     entry.RevokeURL,
			RevokeTimeout:                 tokenRevocationTimeout,
			AppInstallationsURL:           entry.AppInstallationsURL,
			AppInstallURL:                 entry.AppInstallURL,
			DisplayName:                   entry.DisplayName,
			DisplayIcon:                   entry.DisplayIcon,
			ExtraTokenKeys:                entry.ExtraTokenKeys,
			MCPURL:                        entry.MCPURL,
			MCPToolAllowRegex:             mcpToolAllow,
			MCPToolDenyRegex:              mcpToolDeny,
			CodeChallengeMethodsSupported: slice.StringEnums[promoauth.Oauth2PKCEChallengeMethod](entry.CodeChallengeMethodsSupported),
			RefreshGroup:                  new(singleflight.Group),
		}

		if entry.DeviceFlow {
			if entry.DeviceCodeURL == "" {
				return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID)
			}
			cfg.DeviceAuth = &DeviceAuth{
				Config:   cfg,
				ClientID: entry.ClientID,
				TokenURL: oc.Endpoint.TokenURL,
				Scopes:   entry.Scopes,
				CodeURL:  entry.DeviceCodeURL,
			}
		}

		configs = append(configs, cfg)
	}
	return configs, nil
}

// applyDefaultsToConfig applies defaults to the config entry.
func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
	configType := codersdk.EnhancedExternalAuthProvider(strings.ToLower(config.Type))
	if configType == "bitbucket" {
		// For backwards compatibility, we need to support the "bitbucket" string.
		configType = codersdk.EnhancedExternalAuthProviderBitBucketCloud
		defer func() {
			// The config type determines the config ID (if unset). So change the legacy
			// type to the correct new type after the defaults have been configured.
			config.Type = string(codersdk.EnhancedExternalAuthProviderBitBucketCloud)
		}()
	}
	// If static defaults exist, apply them.
	if defaults, ok := staticDefaults[configType]; ok {
		copyDefaultSettings(config, defaults)
		return
	}

	// Dynamic defaults
	switch configType {
	case codersdk.EnhancedExternalAuthProviderGitHub:
		copyDefaultSettings(config, gitHubDefaults(config))
		return
	case codersdk.EnhancedExternalAuthProviderGitLab:
		copyDefaultSettings(config, gitlabDefaults(config))
		return
	case codersdk.EnhancedExternalAuthProviderBitBucketServer:
		copyDefaultSettings(config, bitbucketServerDefaults(config))
		return
	case codersdk.EnhancedExternalAuthProviderJFrog:
		copyDefaultSettings(config, jfrogArtifactoryDefaults(config))
		return
	case codersdk.EnhancedExternalAuthProviderGitea:
		copyDefaultSettings(config, giteaDefaults(config))
		return
	case codersdk.EnhancedExternalAuthProviderAzureDevopsEntra:
		copyDefaultSettings(config, azureDevopsEntraDefaults(config))
		return
	default:
		// Global defaults are specified at the end of the `copyDefaultSettings` function.
		copyDefaultSettings(config, codersdk.ExternalAuthConfig{})
		return
	}
}

func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.ExternalAuthConfig) {
	if config.AuthURL == "" {
		config.AuthURL = defaults.AuthURL
	}
	if config.TokenURL == "" {
		config.TokenURL = defaults.TokenURL
	}
	if config.ValidateURL == "" {
		config.ValidateURL = defaults.ValidateURL
	}
	if config.RedirectURL == "" {
		config.RedirectURL = defaults.RedirectURL
	}
	if config.RevokeURL == "" {
		config.RevokeURL = defaults.RevokeURL
	}
	if config.AppInstallURL == "" {
		config.AppInstallURL = defaults.AppInstallURL
	}
	if config.AppInstallationsURL == "" {
		config.AppInstallationsURL = defaults.AppInstallationsURL
	}
	if config.Regex == "" {
		config.Regex = defaults.Regex
	}
	if len(config.Scopes) == 0 {
		config.Scopes = defaults.Scopes
	}
	if config.DeviceCodeURL == "" {
		config.DeviceCodeURL = defaults.DeviceCodeURL
	}
	if config.DisplayName == "" {
		config.DisplayName = defaults.DisplayName
	}
	if config.DisplayIcon == "" {
		config.DisplayIcon = defaults.DisplayIcon
	}
	if len(config.ExtraTokenKeys) == 0 {
		config.ExtraTokenKeys = defaults.ExtraTokenKeys
	}
	if config.CodeChallengeMethodsSupported == nil {
		config.CodeChallengeMethodsSupported = defaults.CodeChallengeMethodsSupported
	}

	// Apply defaults if it's still empty...
	if config.ID == "" {
		config.ID = config.Type
	}
	if config.DisplayName == "" {
		config.DisplayName = config.Type
	}
	if config.DisplayIcon == "" {
		// This is a key emoji.
		config.DisplayIcon = "/emojis/1f511.png"
	}
	if config.CodeChallengeMethodsSupported == nil {
		config.CodeChallengeMethodsSupported = []string{string(promoauth.PKCEChallengeMethodSha256)}
	}

	// Set default API base URL for providers that need one.
	if config.APIBaseURL == "" {
		normType := strings.ToLower(config.Type)
		switch codersdk.EnhancedExternalAuthProvider(normType) {
		case codersdk.EnhancedExternalAuthProviderGitHub:
			config.APIBaseURL = "https://api.github.com"
		case codersdk.EnhancedExternalAuthProviderGitLab:
			config.APIBaseURL = "https://gitlab.com/api/v4"
			if config.AuthURL != "" {
				if au, err := url.Parse(config.AuthURL); err == nil && !strings.EqualFold(au.Host, "gitlab.com") {
					config.APIBaseURL = au.Scheme + "://" + au.Host + "/api/v4"
				}
			}
		case codersdk.EnhancedExternalAuthProviderGitea:
			config.APIBaseURL = "https://gitea.com/api/v1"
		}
	}
}

// gitHubDefaults returns default config values for GitHub.
// The only dynamic value is the revocation URL which depends on client ID.
func gitHubDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	defaults := codersdk.ExternalAuthConfig{
		AuthURL:     xgithub.Endpoint.AuthURL,
		TokenURL:    xgithub.Endpoint.TokenURL,
		ValidateURL: "https://api.github.com/user",
		DisplayName: "GitHub",
		DisplayIcon: "/icon/github.svg",
		Regex:       `^(https?://)?github\.com(/.*)?$`,
		// "workflow" is required for managing GitHub Actions in a repository.
		Scopes:                        []string{"repo", "workflow"},
		DeviceCodeURL:                 "https://github.com/login/device/code",
		AppInstallationsURL:           "https://api.github.com/user/installations",
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
	}

	if config.RevokeURL == "" && config.ClientID != "" {
		defaults.RevokeURL = fmt.Sprintf("https://api.github.com/applications/%s/grant", config.ClientID)
	}

	return defaults
}

func bitbucketServerDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	defaults := codersdk.ExternalAuthConfig{
		DisplayName: "Bitbucket Server",
		Scopes:      []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
		DisplayIcon: "/icon/bitbucket.svg",
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	}
	// Bitbucket servers will have some base url, e.g. https://bitbucket.coder.com.
	// We will grab this from the Auth URL. This choice is a bit arbitrary,
	// but we need to require at least 1 field to be populated.
	if config.AuthURL == "" {
		// No auth url, means we cannot guess the urls.
		return defaults
	}

	auth, err := url.Parse(config.AuthURL)
	if err != nil {
		// We need a valid URL to continue with.
		return defaults
	}

	// Populate Regex, ValidateURL, and TokenURL.
	// Default regex should be anything using the same host as the auth url.
	defaults.Regex = fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(auth.Host, ".", `\.`))

	tokenURL := auth.ResolveReference(&url.URL{Path: "/rest/oauth2/latest/token"})
	defaults.TokenURL = tokenURL.String()

	// validate needs to return a 200 when logged in and a 401 when unauthenticated.
	// This endpoint returns the count of the number of PR's in the authenticated
	// user's inbox. Which will work perfectly for our use case.
	validate := auth.ResolveReference(&url.URL{Path: "/rest/api/latest/inbox/pull-requests/count"})
	defaults.ValidateURL = validate.String()

	return defaults
}

// gitlabDefaults returns a static config if using the gitlab cloud offering.
// The values are dynamic if using a self-hosted gitlab.
// When the decision is not obvious, just defer to the cloud defaults.
// Any user specific fields will override this if provided.
func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	cloud := codersdk.ExternalAuthConfig{
		AuthURL:                       "https://gitlab.com/oauth/authorize",
		TokenURL:                      "https://gitlab.com/oauth/token",
		ValidateURL:                   "https://gitlab.com/oauth/token/info",
		RevokeURL:                     "https://gitlab.com/oauth/revoke",
		DisplayName:                   "GitLab",
		DisplayIcon:                   "/icon/gitlab.svg",
		Regex:                         `^(https?://)?gitlab\.com(/.*)?$`,
		Scopes:                        []string{"write_repository", "read_api"},
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
	}

	if config.AuthURL == "" || config.AuthURL == cloud.AuthURL {
		return cloud
	}

	au, err := url.Parse(config.AuthURL)
	if err != nil || au.Host == "gitlab.com" {
		// If the AuthURL is not a valid URL or is using the cloud,
		// use the cloud static defaults.
		return cloud
	}

	// At this point, assume it is self-hosted and use the AuthURL
	return codersdk.ExternalAuthConfig{
		DisplayName:                   cloud.DisplayName,
		Scopes:                        cloud.Scopes,
		DisplayIcon:                   cloud.DisplayIcon,
		AuthURL:                       au.ResolveReference(&url.URL{Path: "/oauth/authorize"}).String(),
		TokenURL:                      au.ResolveReference(&url.URL{Path: "/oauth/token"}).String(),
		ValidateURL:                   au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
		RevokeURL:                     au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(),
		Regex:                         fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)),
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
	}
}

func jfrogArtifactoryDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	defaults := codersdk.ExternalAuthConfig{
		DisplayName: "JFrog Artifactory",
		Scopes:      []string{"applied-permissions/user"},
		DisplayIcon: "/icon/jfrog.svg",
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	}
	// Artifactory servers will have some base url, e.g. https://jfrog.coder.com.
	// We will grab this from the Auth URL. This choice is not arbitrary. It is a
	// static string for all integrations on the same artifactory.
	if config.AuthURL == "" {
		// No auth url, means we cannot guess the urls.
		return defaults
	}

	auth, err := url.Parse(config.AuthURL)
	if err != nil {
		// We need a valid URL to continue with.
		return defaults
	}

	if config.ClientID == "" {
		return defaults
	}

	tokenURL := auth.ResolveReference(&url.URL{Path: fmt.Sprintf("/access/api/v1/integrations/%s/token", config.ClientID)})
	defaults.TokenURL = tokenURL.String()

	// validate needs to return a 200 when logged in and a 401 when unauthenticated.
	validate := auth.ResolveReference(&url.URL{Path: "/access/api/v1/system/ping"})
	defaults.ValidateURL = validate.String()

	// Some options omitted:
	// - Regex: Artifactory can span pretty much all domains (git, docker, etc).
	//          I do not think we can intelligently guess this as a default.

	return defaults
}

func giteaDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	defaults := codersdk.ExternalAuthConfig{
		DisplayName:                   "Gitea",
		Scopes:                        []string{"read:repository", " write:repository", "read:user"},
		DisplayIcon:                   "/icon/gitea.svg",
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
	}
	// Gitea's servers will have some base url, e.g: https://gitea.coder.com.
	// If an auth url is not set, we will assume they are using the default
	// public Gitea.
	if config.AuthURL == "" {
		config.AuthURL = "https://gitea.com/login/oauth/authorize"
	}

	auth, err := url.Parse(config.AuthURL)
	if err != nil {
		// We need a valid URL to continue with.
		return defaults
	}

	// Default regex should be anything using the same host as the auth url.
	defaults.Regex = fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(auth.Host, ".", `\.`))

	tokenURL := auth.ResolveReference(&url.URL{Path: "/login/oauth/access_token"})
	defaults.TokenURL = tokenURL.String()

	validate := auth.ResolveReference(&url.URL{Path: "/login/oauth/userinfo"})
	defaults.ValidateURL = validate.String()

	return defaults
}

func azureDevopsEntraDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
	defaults := codersdk.ExternalAuthConfig{
		DisplayName: "Azure DevOps (Entra)",
		DisplayIcon: "/icon/azure-devops.svg",
		Regex:       `^(https?://)?dev\.azure\.com(/.*)?$`,
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	}
	// The tenant ID is required for urls and is in the auth url.
	if config.AuthURL == "" {
		// No auth url, means we cannot guess the urls.
		return defaults
	}

	auth, err := url.Parse(config.AuthURL)
	if err != nil {
		// We need a valid URL to continue with.
		return defaults
	}

	// Only extract the tenant ID if the path is what we expect.
	// The path should be /{tenantId}/oauth2/authorize.
	parts := strings.Split(auth.Path, "/")
	if len(parts) < 4 && parts[2] != "oauth2" || parts[3] != "authorize" {
		// Not sure what this path is, abort.
		return defaults
	}
	tenantID := parts[1]

	tokenURL := auth.ResolveReference(&url.URL{Path: fmt.Sprintf("/%s/oauth2/token", tenantID)})
	defaults.TokenURL = tokenURL.String()

	// TODO: Discover a validate url for Azure DevOps.

	return defaults
}

var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthConfig{
	codersdk.EnhancedExternalAuthProviderAzureDevops: {
		AuthURL:     "https://app.vssps.visualstudio.com/oauth2/authorize",
		TokenURL:    "https://app.vssps.visualstudio.com/oauth2/token",
		DisplayName: "Azure DevOps",
		DisplayIcon: "/icon/azure-devops.svg",
		Regex:       `^(https?://)?dev\.azure\.com(/.*)?$`,
		Scopes:      []string{"vso.code_write"},
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	},
	codersdk.EnhancedExternalAuthProviderBitBucketCloud: {
		AuthURL:     "https://bitbucket.org/site/oauth2/authorize",
		TokenURL:    "https://bitbucket.org/site/oauth2/access_token",
		ValidateURL: "https://api.bitbucket.org/2.0/user",
		DisplayName: "BitBucket",
		DisplayIcon: "/icon/bitbucket.svg",
		Regex:       `^(https?://)?bitbucket\.org(/.*)?$`,
		Scopes:      []string{"account", "repository:write"},
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	},
	codersdk.EnhancedExternalAuthProviderSlack: {
		AuthURL:     "https://slack.com/oauth/v2/authorize",
		TokenURL:    "https://slack.com/api/oauth.v2.access",
		RevokeURL:   "https://slack.com/api/auth.revoke",
		DisplayName: "Slack",
		DisplayIcon: "/icon/slack.svg",
		// See: https://api.slack.com/authentication/oauth-v2#exchanging
		ExtraTokenKeys: []string{"authed_user"},
		// TODO: Investigate if 'S256' is accepted and PKCE is supported
		CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
	},
}

// jwtConfig is a new OAuth2 config that uses a custom
// assertion method that works with Azure Devops. See:
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops
type jwtConfig struct {
	*oauth2.Config
}

func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
	return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...)
}

func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
	v := url.Values{
		"client_assertion_type": {},
		"client_assertion":      {c.ClientSecret},
		"assertion":             {code},
		"grant_type":            {},
	}
	if c.RedirectURL != "" {
		v.Set("redirect_uri", c.RedirectURL)
	}
	return c.Config.Exchange(ctx, code,
		append(opts,
			oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
			oauth2.SetAuthURLParam("client_assertion", c.ClientSecret),
			oauth2.SetAuthURLParam("assertion", code),
			oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
			oauth2.SetAuthURLParam("code", ""),
		)...,
	)
}

// The Entra wrapper accounts for two things:
//
//  1. When authenticating via Entra ID ADO only supports v1 tokens which
//     require 'resource'.
//
//  2. When refreshing, Entra ID requires the original scopes or it will switch
//     to using the default scopes.
//
//     This struct and its functions might be removable once ADO gets support for
//     Entra ID V2.
type entraV1Oauth struct {
	*oauth2.Config
}

const azureDevOpsAppID = "499b84ac-1321-427f-aa17-267ca6975798"

func (c *entraV1Oauth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
	return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("resource", azureDevOpsAppID))...)
}

func (c *entraV1Oauth) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
	return c.Config.Exchange(ctx, code,
		append(opts,
			oauth2.SetAuthURLParam("resource", azureDevOpsAppID),
		)...,
	)
}

func (c *entraV1Oauth) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
	return oauth2.ReuseTokenSource(token, &entraV1TokenSource{
		ctx:   ctx,
		cfg:   c,
		token: token,
	})
}

type entraV1TokenSource struct {
	ctx   context.Context
	cfg   *entraV1Oauth
	token *oauth2.Token
}

func (s *entraV1TokenSource) Token() (*oauth2.Token, error) {
	var refreshToken string
	if s.token != nil {
		refreshToken = s.token.RefreshToken
	}
	if refreshToken == "" {
		return s.cfg.Config.TokenSource(s.ctx, s.token).Token()
	}

	refreshOpts := []oauth2.AuthCodeOption{
		oauth2.SetAuthURLParam("grant_type", "refresh_token"),
		oauth2.SetAuthURLParam("refresh_token", refreshToken),
	}
	if len(s.cfg.Scopes) > 0 {
		refreshOpts = append(refreshOpts, oauth2.SetAuthURLParam("scope", strings.Join(s.cfg.Scopes, " ")))
	}

	token, err := s.cfg.Exchange(s.ctx, "", refreshOpts...)
	if err != nil {
		return nil, err
	}
	if token.RefreshToken == "" {
		token.RefreshToken = refreshToken
	}
	return token, nil
}

// exchangeWithClientSecret wraps an OAuth config and adds the client secret
// to the Exchange request as a Bearer header. This is used by JFrog Artifactory.
type exchangeWithClientSecret struct {
	*oauth2.Config
}

func (e *exchangeWithClientSecret) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
	httpClient, ok := ctx.Value(oauth2.HTTPClient).(*http.Client)
	if httpClient == nil || !ok {
		httpClient = http.DefaultClient
	}
	oldTransport := httpClient.Transport
	if oldTransport == nil {
		oldTransport = http.DefaultTransport
	}
	httpClient.Transport = roundTripper(func(req *http.Request) (*http.Response, error) {
		req.Header.Set("Authorization", "Bearer "+e.ClientSecret)
		return oldTransport.RoundTrip(req)
	})
	return e.Config.Exchange(context.WithValue(ctx, oauth2.HTTPClient, httpClient), code, opts...)
}

type roundTripper func(req *http.Request) (*http.Response, error)

func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	return r(req)
}

// IsGithubDotComURL returns true if the given URL is a github.com URL.
func IsGithubDotComURL(str string) bool {
	str = strings.ToLower(str)
	ghURL, err := url.Parse(str)
	if err != nil {
		return false
	}
	return ghURL.Host == "github.com"
}

// isFailedRefresh returns true if the error returned by the refresh attempt
// is due to a failed refresh. The failure being the refresh token itself.
// If this returns true, no amount of retries will fix the issue.
//
// Notes: Provider responses are not uniform. Here are some examples:
// Github
//   - Returns a 200 with Code "bad_refresh_token" and Description "The refresh token passed is incorrect or expired."
//
// Gitea [TODO: get an expired refresh token]
//   - [Bad JWT] Returns 400 with Code "unauthorized_client" and Description "unable to parse refresh token"
//
// Gitlab
//   - Returns 400 with Code "invalid_grant" and Description "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
func isFailedRefresh(existingToken *oauth2.Token, err error) bool {
	if existingToken.RefreshToken == "" {
		return false // No refresh token, so this cannot be refreshed
	}

	if existingToken.Valid() {
		return false // Valid tokens are not refreshed
	}

	var oauthErr *oauth2.RetrieveError
	if xerrors.As(err, &oauthErr) {
		switch oauthErr.ErrorCode {
		// Known error codes that indicate a failed refresh.
		// 'Spec' means the code is defined in the spec.
		case "bad_refresh_token", // Github
			"invalid_grant",                // Gitlab & Spec
			"unauthorized_client",          // Gitea & Spec
			"unsupported_grant_type",       // Spec, refresh not supported
			"incorrect_client_credentials", // GitHub, wrong client_id/secret (HTTP 200)
			"invalid_client":               // RFC 6749 Section 5.2, client auth failed
			return true
		}

		switch oauthErr.Response.StatusCode {
		case http.StatusBadRequest, http.StatusUnauthorized, http.StatusOK:
			// Status codes that indicate the request was processed
			// and rejected. 403 is intentionally excluded: no known
			// provider returns 403 from the token endpoint, and the
			// previous 403 case caused token destruction on
			// rate-limited refresh attempts.
			return true
		case http.StatusInternalServerError, http.StatusTooManyRequests, http.StatusServiceUnavailable:
			// These do not indicate a failed refresh, but could be a temporary issue.
			return false
		}
	}

	return false
}