Ctrl K
ComunidadGuías…

coderd/oauth2_test.go

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

Archivo original ↗Preguntar a KiBot
package coderd_test

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"path"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/require"
	"golang.org/x/oauth2"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/coderd/apikey"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/coderd/oauth2provider"
	"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
	"github.com/coder/coder/v2/coderd/userpassword"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/serpent"
)

func TestOAuth2ProviderApps(t *testing.T) {
	t.Parallel()

	// NOTE: Unit tests for OAuth2 provider app validation have been migrated to
	// oauth2provider/provider_test.go for better separation of concerns.
	// This test function now focuses on integration testing with the full server stack.

	t.Run("IntegrationFlow", func(t *testing.T) {
		t.Parallel()

		client := coderdtest.New(t, nil)
		_ = coderdtest.CreateFirstUser(t, client)
		ctx := testutil.Context(t, testutil.WaitLong)

		// Test basic app creation and management in integration context
		//nolint:gocritic // OAuth2 app management requires owner permission.
		app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
			Name:        fmt.Sprintf("integration-test-%d", time.Now().UnixNano()%1000000),
			CallbackURL: "http://localhost:3000",
		})
		require.NoError(t, err)
		require.NotEmpty(t, app.ID)
		require.NotEmpty(t, app.Name)
		require.Equal(t, "http://localhost:3000", app.CallbackURL)
	})
}

func TestOAuth2ProviderAppSecrets(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)

	ctx := testutil.Context(t, testutil.WaitLong)

	// Make some apps.
	apps := generateApps(ctx, t, client, "app-secrets")

	t.Run("DeleteNonExisting", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		// Should not be able to create secrets for a non-existent app.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		_, err := client.OAuth2ProviderAppSecrets(ctx, uuid.New())
		require.Error(t, err)

		// Should not be able to delete non-existing secrets when there is no app.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		err = client.DeleteOAuth2ProviderAppSecret(ctx, uuid.New(), uuid.New())
		require.Error(t, err)

		// Should not be able to delete non-existing secrets when the app exists.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, uuid.New())
		require.Error(t, err)

		// Should not be able to delete an existing secret with the wrong app ID.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
		require.NoError(t, err)

		//nolint:gocritic // OAauth2 app management requires owner permission.
		err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secret.ID)
		require.Error(t, err)
	})

	t.Run("OK", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		// No secrets yet.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		secrets, err := client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
		require.NoError(t, err)
		require.Len(t, secrets, 0)

		// Should be able to create secrets.
		for i := 0; i < 5; i++ {
			//nolint:gocritic // OAauth2 app management requires owner permission.
			secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
			require.NoError(t, err)
			require.NotEmpty(t, secret.ClientSecretFull)
			require.True(t, len(secret.ClientSecretFull) > 6)

			//nolint:gocritic // OAauth2 app management requires owner permission.
			_, err = client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
			require.NoError(t, err)
		}

		// Should get secrets now, but only for the one app.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
		require.NoError(t, err)
		require.Len(t, secrets, 5)
		for _, secret := range secrets {
			require.Len(t, secret.ClientSecretTruncated, 6)
		}

		// Should be able to delete a secret.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secrets[0].ID)
		require.NoError(t, err)
		secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
		require.NoError(t, err)
		require.Len(t, secrets, 4)

		// No secrets once the app is deleted.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		err = client.DeleteOAuth2ProviderApp(ctx, apps.Default.ID)
		require.NoError(t, err)

		//nolint:gocritic // OAauth2 app management requires owner permission.
		_, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
		require.Error(t, err)
	})

	t.Run("RejectsPublicClient", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		oauth2providertest.EnableDCR(t, client)
		app := oauth2providertest.RegisterPublicClient(t, client, "app-secrets-public", "http://localhost:8080/callback")
		appID, err := uuid.Parse(app.ClientID)
		require.NoError(t, err)

		//nolint:gocritic // OAuth2 app management requires owner permission.
		_, err = client.PostOAuth2ProviderAppSecret(ctx, appID)
		// Check the status and message, since a bare require.Error would also
		// pass for a request that never reached the guard.
		var sdkError *codersdk.Error
		require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
		require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
		require.Contains(t, sdkError.Message, "public OAuth2 app")

		// No partial secret was created.
		//nolint:gocritic // OAuth2 app management requires owner permission.
		secrets, err := client.OAuth2ProviderAppSecrets(ctx, appID)
		require.NoError(t, err)
		require.Empty(t, secrets)
	})
}

func TestOAuth2ProviderTokenExchange(t *testing.T) {
	t.Parallel()

	db, pubsub := dbtestutil.NewDB(t)
	ownerClient := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)
	apps := generateApps(ctx, t, ownerClient, "token-exchange")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)

	// The typical oauth2 flow from this point is:
	// Create an oauth2.Config using the id, secret, endpoints, and redirect:
	//	cfg := oauth2.Config{ ... }
	// Display url for the user to click:
	//	userClickURL := cfg.AuthCodeURL("random_state")
	//	userClickURL looks like: https://idp url/authorize?
	//								client_id=...
	//								response_type=code
	//								redirect_uri=.. (back to backstage url) ..
	//								scope=...
	//								state=...
	// *1* User clicks "Allow" on provided page above
	// The redirect_uri is followed which sends back to backstage with the code and state
	// Now backstage has the info to do a cfg.Exchange() in the back to get an access token.
	//
	// ---NOTE---: If the user has already approved this oauth app, then *1* is optional.
	//             Coder can just immediately redirect back to backstage without user intervention.
	tests := []struct {
		name string
		app  codersdk.OAuth2ProviderApp
		// The flow is setup(ctx, client, user) -> preAuth(cfg) -> cfg.AuthCodeURL() -> preToken(cfg) -> cfg.Exchange()
		setup      func(context.Context, *codersdk.Client, codersdk.User) error
		preAuth    func(valid *oauth2.Config)
		authError  string
		preToken   func(valid *oauth2.Config)
		tokenError string

		// If null, assume the code should be valid.
		defaultCode *string
		// custom allows some more advanced manipulation of the oauth2 exchange.
		exchangeMutate []oauth2.AuthCodeOption
	}{
		{
			name: "AuthInParams",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				valid.Endpoint.AuthStyle = oauth2.AuthStyleInParams
			},
		},
		{
			name: "AuthInvalidAppID",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				valid.ClientID = uuid.NewString()
			},
			authError: "invalid_client",
		},
		{
			name: "TokenInvalidAppID",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientID = uuid.NewString()
			},
			tokenError: "invalid_client",
		},
		{
			name: "InvalidPort",
			app:  apps.NoPort,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Host = newURL.Hostname() + ":8081"
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "WrongAppHost",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				valid.RedirectURL = apps.NoPort.CallbackURL
			},
			authError: "Invalid query params:",
		},
		{
			name: "InvalidHostPrefix",
			app:  apps.NoPort,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Host = "prefix" + newURL.Hostname()
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "InvalidHost",
			app:  apps.NoPort,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Host = "invalid"
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "InvalidHostAndPort",
			app:  apps.NoPort,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Host = "invalid:8080"
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "InvalidPath",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Path = path.Join("/prepend", newURL.Path)
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "MissingPath",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Path = "/"
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "DifferentProtocol",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Scheme = "http"
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "NestedPath",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Path = path.Join(newURL.Path, "nested")
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			// Some oauth implementations allow this, but our users can host
			// at subdomains. So we should not.
			name: "Subdomain",
			app:  apps.Default,
			preAuth: func(valid *oauth2.Config) {
				newURL := must(url.Parse(valid.RedirectURL))
				newURL.Host = "sub." + newURL.Host
				valid.RedirectURL = newURL.String()
			},
			authError: "Invalid query params:",
		},
		{
			name: "NoSecretScheme",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = "1234_4321"
			},
			tokenError: "The client credentials are invalid",
		},
		{
			name: "InvalidSecretScheme",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = "notcoder_1234_4321"
			},
			tokenError: "The client credentials are invalid",
		},
		{
			name: "MissingSecretSecret",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = "coder_1234"
			},
			tokenError: "The client credentials are invalid",
		},
		{
			name: "MissingSecretPrefix",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = "coder__1234"
			},
			tokenError: "The client credentials are invalid",
		},
		{
			name: "InvalidSecretPrefix",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = "coder_1234_4321"
			},
			tokenError: "The client credentials are invalid",
		},
		{
			name: "MissingSecret",
			app:  apps.Default,
			preToken: func(valid *oauth2.Config) {
				valid.ClientSecret = ""
			},
			tokenError: "invalid_request",
		},
		{
			name:        "NoCodeScheme",
			app:         apps.Default,
			defaultCode: new("1234_4321"),
			tokenError:  "The authorization code is invalid or expired",
		},
		{
			name:        "InvalidCodeScheme",
			app:         apps.Default,
			defaultCode: new("notcoder_1234_4321"),
			tokenError:  "The authorization code is invalid or expired",
		},
		{
			name:        "MissingCodeSecret",
			app:         apps.Default,
			defaultCode: new("coder_1234"),
			tokenError:  "The authorization code is invalid or expired",
		},
		{
			name:        "MissingCodePrefix",
			app:         apps.Default,
			defaultCode: new("coder__1234"),
			tokenError:  "The authorization code is invalid or expired",
		},
		{
			name:        "InvalidCodePrefix",
			app:         apps.Default,
			defaultCode: new("coder_1234_4321"),
			tokenError:  "The authorization code is invalid or expired",
		},
		{
			name:        "MissingCode",
			app:         apps.Default,
			defaultCode: new(""),
			tokenError:  "invalid_request",
		},
		{
			name:       "InvalidGrantType",
			app:        apps.Default,
			tokenError: "unsupported_grant_type",
			exchangeMutate: []oauth2.AuthCodeOption{
				oauth2.SetAuthURLParam("grant_type", "foobar"),
			},
		},
		{
			name:       "EmptyGrantType",
			app:        apps.Default,
			tokenError: "unsupported_grant_type",
			exchangeMutate: []oauth2.AuthCodeOption{
				oauth2.SetAuthURLParam("grant_type", ""),
			},
		},
		{
			name:        "ExpiredCode",
			app:         apps.Default,
			defaultCode: new("coder_prefix_code"),
			tokenError:  "The authorization code is invalid or expired",
			setup: func(ctx context.Context, client *codersdk.Client, user codersdk.User) error {
				// Insert an expired code.
				hashedCode, err := userpassword.Hash("prefix_code")
				if err != nil {
					return err
				}
				_, err = db.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
					ID:           uuid.New(),
					CreatedAt:    dbtime.Now().Add(-time.Minute * 11),
					ExpiresAt:    dbtime.Now().Add(-time.Minute),
					SecretPrefix: []byte("prefix"),
					HashedSecret: []byte(hashedCode),
					AppID:        apps.Default.ID,
					UserID:       user.ID,
					Scope:        string(database.ApiKeyScopeCoderAll),
				})
				return err
			},
		},
		{
			// secret belongs to apps.Default (see the shared "secret" above),
			// but this app's client_id is apps.NoPort. The token endpoint
			// must reject a secret that belongs to a different app than the
			// one identified by client_id, rather than trusting client_id
			// alone to attribute the resulting token.
			name:       "SecretBelongsToDifferentApp",
			app:        apps.NoPort,
			tokenError: "The client credentials are invalid",
		},
		{
			name: "OK",
			app:  apps.Default,
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			// Each test gets its own user, since we allow only one code per user and
			// app at a time and running tests in parallel could clobber each other.
			userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
			if test.setup != nil {
				err := test.setup(ctx, userClient, user)
				require.NoError(t, err)
			}

			// Each test gets its own oauth2.Config so they can run in parallel.
			// In practice, you would only use 1 as a singleton.
			valid := &oauth2.Config{
				ClientID:     test.app.ID.String(),
				ClientSecret: secret.ClientSecretFull,
				Endpoint: oauth2.Endpoint{
					AuthURL:       test.app.Endpoints.Authorization,
					DeviceAuthURL: test.app.Endpoints.DeviceAuth,
					TokenURL:      test.app.Endpoints.Token,
					// TODO: @emyrk we should support both types.
					AuthStyle: oauth2.AuthStyleInParams,
				},
				RedirectURL: test.app.CallbackURL,
				Scopes:      []string{},
			}

			if test.preAuth != nil {
				test.preAuth(valid)
			}

			var code string
			var verifier string
			if test.defaultCode != nil {
				code = *test.defaultCode
				// These subtests exercise malformed/expired code_
				// handling; the code lookup fails before code_verifier is
				// ever compared, but it still has to satisfy RFC 7636
				// §4.1's format floor to reach that point.
				verifier = strings.Repeat("a", 43)
			} else {
				var err error
				code, verifier, err = authorizationFlow(ctx, userClient, valid)
				if test.authError != "" {
					require.Error(t, err)
					require.ErrorContains(t, err, test.authError)
					// If this errors the token exchange will fail. So end here.
					return
				}
				require.NoError(t, err)
			}

			// Mutate the valid config for the exchange.
			if test.preToken != nil {
				test.preToken(valid)
			}

			// Do the actual exchange. Include PKCE code_verifier when
			// we obtained a code through the authorization flow.
			exchangeOpts := append([]oauth2.AuthCodeOption{
				oauth2.SetAuthURLParam("code_verifier", verifier),
			}, test.exchangeMutate...)
			token, err := valid.Exchange(ctx, code, exchangeOpts...)
			if test.tokenError != "" {
				require.Error(t, err)
				require.ErrorContains(t, err, test.tokenError)
			} else {
				require.NoError(t, err)
				require.NotEmpty(t, token.AccessToken)
				require.True(t, dbtime.Now().Before(token.Expiry))

				// Check that the token works.
				newClient := codersdk.New(userClient.URL)
				newClient.SetSessionToken(token.AccessToken)

				gotUser, err := newClient.User(ctx, codersdk.Me)
				require.NoError(t, err)
				require.Equal(t, user.ID, gotUser.ID)
			}
		})
	}
}

// TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp covers
// authorizationCodeGrant's code-ownership check in isolation. The token
// endpoint validates redirect_uri against the app resolved from client_id
// (before the grant runs at all) and separately against the redirect_uri
// recorded on the code itself (inside the grant). Both must pass for the
// request to reach the code-ownership check, which only happens when the
// app identified by client_id and the app that originally issued the code
// happen to share the exact same callback URL, which is plausible for
// native clients that commonly register a conventional localhost
// redirect. Two apps with distinct callbacks (as in the table above) can
// never reach this check via a redirect_uri mismatch; this test
// constructs the one scenario that does.
func TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp(t *testing.T) {
	t.Parallel()

	ownerClient := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)

	const sharedCallback = "https://localhost1:8080/foo/bar"
	createApp := func(name string) (codersdk.OAuth2ProviderApp, codersdk.OAuth2ProviderAppSecretFull) {
		//nolint:gocritic // OAauth2 app management requires owner permission.
		app, err := ownerClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
			Name:        fmt.Sprintf("%s-%d", name, time.Now().UnixNano()),
			CallbackURL: sharedCallback,
		})
		require.NoError(t, err)
		//nolint:gocritic // OAauth2 app management requires owner permission.
		secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, app.ID)
		require.NoError(t, err)
		return app, secret
	}
	appA, _ := createApp("code-owner")
	appB, secretB := createApp("code-thief")

	userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

	cfgA := &oauth2.Config{
		ClientID: appA.ID.String(),
		Endpoint: oauth2.Endpoint{
			AuthURL:   appA.Endpoints.Authorization,
			TokenURL:  appA.Endpoints.Token,
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: sharedCallback,
		Scopes:      []string{},
	}
	code, verifier, err := authorizationFlow(ctx, userClient, cfgA)
	require.NoError(t, err)

	// Exchange the code issued for appA, but presenting appB's client_id
	// and appB's own valid secret. redirect_uri is identical for both
	// apps, so both the request-level check (against the app resolved
	// from client_id) and the grant's own check (against the code's
	// recorded redirect_uri) pass, isolating the code-ownership check.
	cfgB := &oauth2.Config{
		ClientID:     appB.ID.String(),
		ClientSecret: secretB.ClientSecretFull,
		Endpoint: oauth2.Endpoint{
			TokenURL:  appB.Endpoints.Token,
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: sharedCallback,
		Scopes:      []string{},
	}
	_, err = cfgB.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
	require.Error(t, err)
	require.ErrorContains(t, err, "The authorization code is invalid or expired")
}

func TestOAuth2ProviderPublicClientTokenExchange(t *testing.T) {
	t.Parallel()

	ownerClient := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	oauth2providertest.EnableDCR(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)

	// Neither app has a secret, so code ownership and PKCE are all that bind an
	// exchange. A shared redirect URI is the common case for native apps.
	const sharedCallback = "http://localhost:8080/callback"
	appA := oauth2providertest.RegisterPublicClient(t, ownerClient, "public-code-owner", sharedCallback)
	appB := oauth2providertest.RegisterPublicClient(t, ownerClient, "public-code-thief", sharedCallback)

	userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

	authURL := ownerClient.URL.JoinPath("/oauth2/authorize").String()
	tokenURL := ownerClient.URL.JoinPath("/oauth2/tokens").String()

	cfgA := &oauth2.Config{
		ClientID: appA.ClientID,
		Endpoint: oauth2.Endpoint{
			AuthURL:   authURL,
			TokenURL:  tokenURL,
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: sharedCallback,
		Scopes:      []string{},
	}
	code, verifier, err := authorizationFlow(ctx, userClient, cfgA)
	require.NoError(t, err)

	// Redeem appA's code under appB's client_id: everything except the code's
	// own app_id lines up.
	cfgB := &oauth2.Config{
		ClientID: appB.ClientID,
		Endpoint: oauth2.Endpoint{
			TokenURL:  tokenURL,
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: sharedCallback,
		Scopes:      []string{},
	}
	_, err = cfgB.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
	require.Error(t, err)
	require.ErrorContains(t, err, "The authorization code is invalid or expired")

	// An under-length verifier fails the format check before the grant runs, so
	// it does not consume the code (RFC 6749 §10.5).
	_, err = cfgA.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", "a"))
	require.Error(t, err)
	require.ErrorContains(t, err, "code_verifier")

	// A well-formed verifier reaches the PKCE comparison, which does consume the
	// code: otherwise a leaked code could be replayed with unlimited guesses.
	wrongVerifier, _ := oauth2providertest.GeneratePKCE(t)
	_, err = cfgA.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", wrongVerifier))
	require.Error(t, err)
	require.ErrorContains(t, err, "The PKCE code verifier is invalid")

	// Consumed above, so even the matching verifier can no longer redeem it.
	_, err = cfgA.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
	require.Error(t, err)
	require.ErrorContains(t, err, "The authorization code is invalid or expired")
}

func TestOAuth2ProviderTokenRefresh(t *testing.T) {
	t.Parallel()
	ctx := testutil.Context(t, testutil.WaitLong)

	db, pubsub := dbtestutil.NewDB(t)
	ownerClient := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	apps := generateApps(ctx, t, ownerClient, "token-refresh")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)
	//nolint:gocritic // OAuth2 app management requires owner permission.
	noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
	require.NoError(t, err)

	// One path not tested here is when the token is empty, because Go's OAuth2
	// client library will not even try to make the request.
	tests := []struct {
		name string
		app  codersdk.OAuth2ProviderApp
		// refreshAsApp, if set, performs the refresh request under this
		// app's client_id/endpoints instead of app, while the token itself
		// still belongs to app. Used to test that refreshing a token under
		// a different app's client_id is rejected outright, rather than
		// silently re-parenting the token to the presented client_id.
		refreshAsApp *codersdk.OAuth2ProviderApp
		// refreshSecret, if set, is presented instead of apps.Default's.
		refreshSecret string
		// If null, assume the token should be valid.
		defaultToken *string
		error        string
		expires      time.Time
	}{
		{
			name:         "NoTokenScheme",
			app:          apps.Default,
			defaultToken: new("1234_4321"),
			error:        "The refresh token is invalid or expired",
		},
		{
			name:         "InvalidTokenScheme",
			app:          apps.Default,
			defaultToken: new("notcoder_1234_4321"),
			error:        "The refresh token is invalid or expired",
		},
		{
			name:         "MissingTokenSecret",
			app:          apps.Default,
			defaultToken: new("coder_1234"),
			error:        "The refresh token is invalid or expired",
		},
		{
			name:         "MissingTokenPrefix",
			app:          apps.Default,
			defaultToken: new("coder__1234"),
			error:        "The refresh token is invalid or expired",
		},
		{
			name:         "InvalidTokenPrefix",
			app:          apps.Default,
			defaultToken: new("coder_1234_4321"),
			error:        "The refresh token is invalid or expired",
		},
		{
			name:    "Expired",
			app:     apps.Default,
			expires: time.Now().Add(time.Minute * -1),
			error:   "The refresh token is invalid or expired",
		},
		{
			// The token belongs to apps.Default, but apps.NoPort, correctly
			// authenticated as itself, presents it. This must be rejected
			// outright: silently accepting it (and re-parenting the
			// token's app_id to whatever client_id is presented) would let
			// a stolen refresh token be laundered to a different app,
			// after which the issuing app could no longer revoke it.
			name:          "WrongAppOwnSecret",
			app:           apps.Default,
			refreshAsApp:  &apps.NoPort,
			refreshSecret: noPortSecret.ClientSecretFull,
			error:         "The refresh token is invalid or expired",
		},
		{
			// The advisory's shape: any client_id, without that client's
			// secret. Client authentication refuses before the token is
			// examined, so the answer names the credentials, not the token.
			name:         "WrongAppOtherSecret",
			app:          apps.Default,
			refreshAsApp: &apps.NoPort,
			error:        "The client credentials are invalid",
		},
		{
			name:          "WrongSecret",
			app:           apps.Default,
			refreshSecret: secret.ClientSecretFull + "x",
			error:         "The client credentials are invalid",
		},
		{
			name: "OK",
			app:  apps.Default,
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

			// Insert the token and its key.
			key, sessionToken, err := apikey.Generate(apikey.CreateParams{
				UserID:    user.ID,
				LoginType: database.LoginTypeOAuth2ProviderApp,
				ExpiresAt: time.Now().Add(time.Hour * 10),
			})
			require.NoError(t, err)

			newKey, err := db.InsertAPIKey(ctx, key)
			require.NoError(t, err)

			token, err := oauth2provider.GenerateSecret()
			require.NoError(t, err)

			expires := test.expires
			if expires.IsZero() {
				expires = time.Now().Add(time.Hour * 10)
			}

			_, err = db.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
				ID:          uuid.New(),
				CreatedAt:   dbtime.Now(),
				ExpiresAt:   expires,
				HashPrefix:  []byte(token.Prefix),
				RefreshHash: token.Hashed,
				AppID:       test.app.ID,
				AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true},
				APIKeyID:    newKey.ID,
				UserID:      user.ID,
				Scope:       string(database.ApiKeyScopeCoderAll),
			})
			require.NoError(t, err)

			// Check that the key works.
			newClient := codersdk.New(userClient.URL)
			newClient.SetSessionToken(sessionToken)
			gotUser, err := newClient.User(ctx, codersdk.Me)
			require.NoError(t, err)
			require.Equal(t, user.ID, gotUser.ID)

			refreshAsApp := test.app
			if test.refreshAsApp != nil {
				refreshAsApp = *test.refreshAsApp
			}
			refreshSecret := secret.ClientSecretFull
			if test.refreshSecret != "" {
				refreshSecret = test.refreshSecret
			}
			cfg := &oauth2.Config{
				ClientID:     refreshAsApp.ID.String(),
				ClientSecret: refreshSecret,
				Endpoint: oauth2.Endpoint{
					AuthURL:       refreshAsApp.Endpoints.Authorization,
					DeviceAuthURL: refreshAsApp.Endpoints.DeviceAuth,
					TokenURL:      refreshAsApp.Endpoints.Token,
					AuthStyle:     oauth2.AuthStyleInParams,
				},
				RedirectURL: refreshAsApp.CallbackURL,
				Scopes:      []string{},
			}

			// Test whether it can be refreshed.
			refreshToken := token.Formatted
			if test.defaultToken != nil {
				refreshToken = *test.defaultToken
			}
			refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{
				AccessToken:  sessionToken,
				RefreshToken: refreshToken,
				Expiry:       time.Now().Add(time.Minute * -1),
			}).Token()

			if test.error != "" {
				require.Error(t, err)
				require.ErrorContains(t, err, test.error)
			} else {
				require.NoError(t, err)
				require.NotEmpty(t, refreshed.AccessToken)

				// Old token is now invalid.
				_, err = newClient.User(ctx, codersdk.Me)
				require.Error(t, err)
				require.ErrorContains(t, err, "401")

				// Refresh token is valid.
				newClient := codersdk.New(userClient.URL)
				newClient.SetSessionToken(refreshed.AccessToken)

				gotUser, err := newClient.User(ctx, codersdk.Me)
				require.NoError(t, err)
				require.Equal(t, user.ID, gotUser.ID)
			}
		})
	}
}

type exchangeSetup struct {
	cfg      *oauth2.Config
	app      codersdk.OAuth2ProviderApp
	secret   codersdk.OAuth2ProviderAppSecretFull
	code     string
	verifier string
}

func TestOAuth2ProviderRevoke(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, client)

	tests := []struct {
		name string
		// fn performs some action that removes the user's code and token.
		fn func(context.Context, *codersdk.Client, exchangeSetup)
		// replacesToken specifies whether the action replaces the token or only
		// deletes it.
		replacesToken bool
	}{
		{
			name: "DeleteApp",
			fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
				//nolint:gocritic // OAauth2 app management requires owner permission.
				err := client.DeleteOAuth2ProviderApp(ctx, s.app.ID)
				require.NoError(t, err)
			},
		},
		{
			name: "DeleteSecret",
			fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
				//nolint:gocritic // OAauth2 app management requires owner permission.
				err := client.DeleteOAuth2ProviderAppSecret(ctx, s.app.ID, s.secret.ID)
				require.NoError(t, err)
			},
		},
		{
			name: "DeleteApp",
			fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
				err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID)
				require.NoError(t, err)
			},
		},
		{
			name: "OverrideCodeAndToken",
			fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
				// Generating a new code should wipe out the old code.
				code, verifier, err := authorizationFlow(ctx, client, s.cfg)
				require.NoError(t, err)

				// Generating a new token should wipe out the old token.
				_, err = s.cfg.Exchange(ctx, code,
					oauth2.SetAuthURLParam("code_verifier", verifier),
				)
				require.NoError(t, err)
			},
			replacesToken: true,
		},
	}

	setup := func(ctx context.Context, testClient *codersdk.Client, name string) exchangeSetup {
		// We need a new app each time because we only allow one code and token per
		// app and user at the moment and because the test might delete the app.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
			Name:        name,
			CallbackURL: "http://localhost",
		})
		require.NoError(t, err)

		// We need a new secret every time because the test might delete the secret.
		//nolint:gocritic // OAauth2 app management requires owner permission.
		secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
		require.NoError(t, err)

		cfg := &oauth2.Config{
			ClientID:     app.ID.String(),
			ClientSecret: secret.ClientSecretFull,
			Endpoint: oauth2.Endpoint{
				AuthURL:       app.Endpoints.Authorization,
				DeviceAuthURL: app.Endpoints.DeviceAuth,
				TokenURL:      app.Endpoints.Token,
				AuthStyle:     oauth2.AuthStyleInParams,
			},
			RedirectURL: app.CallbackURL,
			Scopes:      []string{},
		}

		// Go through the auth flow to get a code.
		code, verifier, err := authorizationFlow(ctx, testClient, cfg)
		require.NoError(t, err)

		return exchangeSetup{
			cfg:      cfg,
			app:      app,
			secret:   secret,
			code:     code,
			verifier: verifier,
		}
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)
			testClient, testUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

			testEntities := setup(ctx, testClient, test.name+"-1")

			// Delete before the exchange completes (code should delete and attempting
			// to finish the exchange should fail).
			test.fn(ctx, testClient, testEntities)

			// Exchange should fail because the code should be gone.
			_, err := testEntities.cfg.Exchange(ctx, testEntities.code,
				oauth2.SetAuthURLParam("code_verifier", testEntities.verifier),
			)
			require.Error(t, err)

			// Try again, this time letting the exchange complete first.
			testEntities = setup(ctx, testClient, test.name+"-2")
			token, err := testEntities.cfg.Exchange(ctx, testEntities.code,
				oauth2.SetAuthURLParam("code_verifier", testEntities.verifier),
			)
			require.NoError(t, err)

			// Validate the returned access token and that the app is listed.
			newClient := codersdk.New(client.URL)
			newClient.SetSessionToken(token.AccessToken)

			gotUser, err := newClient.User(ctx, codersdk.Me)
			require.NoError(t, err)
			require.Equal(t, testUser.ID, gotUser.ID)

			filter := codersdk.OAuth2ProviderAppFilter{UserID: testUser.ID}
			apps, err := testClient.OAuth2ProviderApps(ctx, filter)
			require.NoError(t, err)
			require.Contains(t, apps, testEntities.app)

			// Should not show up for another user.
			apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{UserID: owner.UserID})
			require.NoError(t, err)
			require.Len(t, apps, 0)

			// Perform the deletion.
			test.fn(ctx, testClient, testEntities)

			// App should no longer show up for the user unless it was replaced.
			if !test.replacesToken {
				apps, err = testClient.OAuth2ProviderApps(ctx, filter)
				require.NoError(t, err)
				require.NotContains(t, apps, testEntities.app, fmt.Sprintf("contains %q", testEntities.app.Name))
			}

			// The token should no longer be valid.
			_, err = newClient.User(ctx, codersdk.Me)
			require.Error(t, err)
			require.ErrorContains(t, err, "401")
		})
	}
}

func TestOAuth2ProviderRevokeInvalidToken(t *testing.T) {
	t.Parallel()

	ownerClient := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)
	apps := generateApps(ctx, t, ownerClient, "revoke-invalid-token")

	//nolint:gocritic // OAuth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)

	// Same length as the original, so the token keeps its shape but not its secret.
	replaceAfterLast := func(tok, sep string) string {
		idx := strings.LastIndex(tok, sep)
		require.NotEqual(t, -1, idx, "token should contain %q", sep)
		return tok[:idx+1] + strings.Repeat("a", len(tok)-idx-1)
	}

	tests := []struct {
		name string
		// tokenFor returns the token to present at the revocation endpoint.
		tokenFor func(*codersdk.Client, *oauth2.Token) string
		// staysValid says the token under test is a real token that must keep
		// working after the refused revocation.
		staysValid bool
	}{
		{
			name: "RefreshTokenWrongSecret",
			tokenFor: func(_ *codersdk.Client, tok *oauth2.Token) string {
				return replaceAfterLast(tok.RefreshToken, "_")
			},
		},
		{
			name: "AccessTokenWrongSecret",
			tokenFor: func(_ *codersdk.Client, tok *oauth2.Token) string {
				return replaceAfterLast(tok.AccessToken, "-")
			},
		},
		{
			name: "SessionTokenFromAnotherLogin",
			tokenFor: func(userClient *codersdk.Client, _ *oauth2.Token) string {
				return userClient.SessionToken()
			},
			staysValid: true,
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

			cfg := &oauth2.Config{
				ClientID:     apps.Default.ID.String(),
				ClientSecret: secret.ClientSecretFull,
				Endpoint: oauth2.Endpoint{
					AuthURL:   apps.Default.Endpoints.Authorization,
					TokenURL:  apps.Default.Endpoints.Token,
					AuthStyle: oauth2.AuthStyleInParams,
				},
				RedirectURL: apps.Default.CallbackURL,
				Scopes:      []string{},
			}

			code, verifier, err := authorizationFlow(ctx, userClient, cfg)
			require.NoError(t, err)
			token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
			require.NoError(t, err)

			tokenWorks := func(tok string) bool {
				checkClient := codersdk.New(userClient.URL)
				checkClient.SetSessionToken(tok)
				_, err := checkClient.User(ctx, codersdk.Me)
				return err == nil
			}
			require.True(t, tokenWorks(token.AccessToken), "session should be valid before the revoke attempt")

			tokenUnderTest := test.tokenFor(userClient, token)

			// RFC 7009 §2.2 answers 200 for a token the client cannot revoke,
			// so the reply says nothing about which tokens exist.
			err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, tokenUnderTest)
			require.NoError(t, err, "revoking a token this client was never issued must answer 200")
			require.True(t, tokenWorks(token.AccessToken), "a refused revocation must not end the session")
			if test.staysValid {
				require.True(t, tokenWorks(tokenUnderTest), "a refused revocation must leave the presented token working")
			}
		})
	}
}

// TestOAuth2ProviderRevokeCrossApp covers RFC 7009 revocation's ownership
// check, which compares a token's app_id directly rather than joining
// through app_secret_id. That rewrite had zero test coverage on its
// unequal branch: revoking a token while presenting a different app's
// client_id than the one that issued it must be rejected (masked as a
// success per RFC 7009, since revocation must not reveal whether a token
// exists), and must leave the token's session intact. Revoking under the
// correct, issuing app must still work.
func TestOAuth2ProviderRevokeCrossApp(t *testing.T) {
	t.Parallel()

	ownerClient := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)
	apps := generateApps(ctx, t, ownerClient, "revoke-cross-app")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)
	//nolint:gocritic // OAauth2 app management requires owner permission.
	noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
	require.NoError(t, err)

	tests := []struct {
		name string
		// tokenFor extracts the token under test from a successful exchange,
		// covering both the refresh-token (revokeRefreshTokenInTx) and
		// access-token (revokeAPIKeyInTx) revocation branches.
		tokenFor func(*oauth2.Token) string
	}{
		{
			name:     "AccessToken",
			tokenFor: func(tok *oauth2.Token) string { return tok.AccessToken },
		},
		{
			name:     "RefreshToken",
			tokenFor: func(tok *oauth2.Token) string { return tok.RefreshToken },
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

			cfg := &oauth2.Config{
				ClientID:     apps.Default.ID.String(),
				ClientSecret: secret.ClientSecretFull,
				Endpoint: oauth2.Endpoint{
					AuthURL:       apps.Default.Endpoints.Authorization,
					DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
					TokenURL:      apps.Default.Endpoints.Token,
					AuthStyle:     oauth2.AuthStyleInParams,
				},
				RedirectURL: apps.Default.CallbackURL,
				Scopes:      []string{},
			}

			code, verifier, err := authorizationFlow(ctx, userClient, cfg)
			require.NoError(t, err)
			token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
			require.NoError(t, err)

			sessionWorks := func() bool {
				checkClient := codersdk.New(userClient.URL)
				checkClient.SetSessionToken(token.AccessToken)
				_, err := checkClient.User(ctx, codersdk.Me)
				return err == nil
			}
			require.True(t, sessionWorks(), "session should be valid before any revoke attempt")

			tokenUnderTest := test.tokenFor(token)

			// RFC 7009: revoking under a different app than the one that
			// issued the token must not reveal whether it exists (no
			// error), and must not actually end the session. The other app
			// authenticates as itself, so this reaches the ownership check.
			err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, noPortSecret.ClientSecretFull, tokenUnderTest)
			require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009")
			require.True(t, sessionWorks(), "cross-app revoke must not actually end the session")

			// Revoking under the correct, issuing app must actually work.
			err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, tokenUnderTest)
			require.NoError(t, err)
			require.False(t, sessionWorks(), "same-app revoke must end the session")
		})
	}
}

// A confidential client authenticates at revocation as it does at the token
// endpoint (RFC 7009 §2.1). Each case gets its own session because a
// successful revocation ends it.
func TestOAuth2ProviderRevokeClientAuthentication(t *testing.T) {
	t.Parallel()

	ownerClient := coderdtest.New(t, nil)
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)
	apps := generateApps(ctx, t, ownerClient, "revoke-client-auth")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)
	//nolint:gocritic // OAauth2 app management requires owner permission.
	noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
	require.NoError(t, err)

	// Mints a session for apps.Default and returns the refresh token with a
	// probe reporting whether the session's access token still authenticates.
	newSession := func(ctx context.Context, t *testing.T) (*codersdk.Client, string, func() bool) {
		t.Helper()

		userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
		cfg := &oauth2.Config{
			ClientID:     apps.Default.ID.String(),
			ClientSecret: secret.ClientSecretFull,
			Endpoint: oauth2.Endpoint{
				AuthURL:   apps.Default.Endpoints.Authorization,
				TokenURL:  apps.Default.Endpoints.Token,
				AuthStyle: oauth2.AuthStyleInParams,
			},
			RedirectURL: apps.Default.CallbackURL,
			Scopes:      []string{},
		}
		code, verifier, err := authorizationFlow(ctx, userClient, cfg)
		require.NoError(t, err)
		token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
		require.NoError(t, err)

		works := func() bool {
			checkClient := codersdk.New(userClient.URL)
			checkClient.SetSessionToken(token.AccessToken)
			_, err := checkClient.User(ctx, codersdk.Me)
			return err == nil
		}
		require.True(t, works(), "session should be valid before any revoke attempt")
		return userClient, token.RefreshToken, works
	}

	// Posts the revocation form by hand so the request can carry HTTP Basic
	// credentials, which the SDK method does not send. A 200 has no body
	// (RFC 7009), so the decoded error is zero on success.
	postRevoke := func(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, opts ...codersdk.RequestOption) (status int, header http.Header, oauthErr codersdk.OAuth2Error) {
		t.Helper()

		opts = append(opts, func(r *http.Request) {
			r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		})
		resp, err := client.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), opts...)
		require.NoError(t, err)
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			require.NoError(t, json.NewDecoder(resp.Body).Decode(&oauthErr))
		}
		return resp.StatusCode, resp.Header, oauthErr
	}

	requireInvalidClient := func(t *testing.T, err error, works func() bool) {
		t.Helper()

		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Error(), string(codersdk.OAuth2ErrorCodeInvalidClient))
		require.True(t, works(), "a refused revocation must not end the session")
	}

	requireInvalidRequest := func(t *testing.T, status int, oauthErr codersdk.OAuth2Error, wantDescription string, works func() bool) {
		t.Helper()

		require.Equal(t, http.StatusBadRequest, status)
		require.Equal(t, codersdk.OAuth2ErrorCodeInvalidRequest, oauthErr.Error)
		require.Contains(t, oauthErr.ErrorDescription, wantDescription)
		require.True(t, works(), "a refused revocation must not end the session")
	}

	t.Run("MissingSecret", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", refreshToken)
		requireInvalidClient(t, err, works)
	})

	t.Run("WrongSecret", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull+"x", refreshToken)
		requireInvalidClient(t, err, works)
	})

	// Right hash, wrong app: the secret is valid, but not for the client_id
	// it is presented under.
	t.Run("SecretOfAnotherApp", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, noPortSecret.ClientSecretFull, refreshToken)
		requireInvalidClient(t, err, works)
	})

	// Authentication runs before the token is classified, so a caller without
	// the secret learns nothing from the token it presents: 401, not the 200
	// an unknown token would otherwise receive under RFC 7009 §2.2.
	// The fake token is not tied to the session, so only the status check
	// carries this case; the session probe is the shared helper's
	// post-condition.
	t.Run("MissingSecretUnknownToken", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, _, works := newSession(ctx, t)

		err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", "coder_notreal_notreal")
		requireInvalidClient(t, err, works)
	})

	t.Run("CorrectSecretOwnToken", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, refreshToken)
		require.NoError(t, err)
		require.False(t, works(), "an authenticated revocation must end the session")
	})

	t.Run("BasicAuth", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		status, _, _ := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull)
		})
		require.Equal(t, http.StatusOK, status)
		require.False(t, works(), "a Basic-authenticated revocation must end the session")
	})

	t.Run("BasicAuthWrongSecret", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		status, header, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull+"x")
		})
		require.Equal(t, http.StatusUnauthorized, status)
		require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate"))
		require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error)
		require.True(t, works(), "a refused revocation must not end the session")
	})

	t.Run("BasicAndBodyConflict", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_id", apps.Default.ID.String())
		form.Set("client_secret", secret.ClientSecretFull+"x")
		status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull)
		})
		requireInvalidRequest(t, status, oauthErr, "Conflicting client credentials", works)
	})

	t.Run("SecretInQueryString", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_id", apps.Default.ID.String())
		status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			q := r.URL.Query()
			q.Set("client_secret", secret.ClientSecretFull)
			r.URL.RawQuery = q.Encode()
		})
		requireInvalidRequest(t, status, oauthErr, "URL query string", works)
	})

	t.Run("SecretInQueryStringAndBody", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_id", apps.Default.ID.String())
		form.Set("client_secret", secret.ClientSecretFull)
		status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			q := r.URL.Query()
			q.Set("client_secret", secret.ClientSecretFull)
			r.URL.RawQuery = q.Encode()
		})
		requireInvalidRequest(t, status, oauthErr, "URL query string", works)
	})

	t.Run("EmptySecretInQueryString", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_id", apps.Default.ID.String())
		form.Set("client_secret", secret.ClientSecretFull)
		status, _, _ := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			q := r.URL.Query()
			q.Set("client_secret", "")
			r.URL.RawQuery = q.Encode()
		})
		require.Equal(t, http.StatusOK, status)
		require.False(t, works(), "the revocation must end the session")
	})

	t.Run("MalformedQueryDescriptionIsSanitized", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_secret", secret.ClientSecretFull)
		status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			r.URL.RawQuery = "client_id=" + apps.Default.ID.String() + "&%zz=1"
		})
		requireInvalidRequest(t, status, oauthErr, "invalid URL escape", works)
		require.NotContains(t, oauthErr.ErrorDescription, `"`)
	})

	t.Run("EmptyAndRealSecretInQueryString", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		userClient, refreshToken, works := newSession(ctx, t)

		form := url.Values{}
		form.Set("token", refreshToken)
		form.Set("client_id", apps.Default.ID.String())
		status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
			q := r.URL.Query()
			q["client_secret"] = []string{"", secret.ClientSecretFull}
			r.URL.RawQuery = q.Encode()
		})
		requireInvalidRequest(t, status, oauthErr, "URL query string", works)
	})
}

func TestOAuth2ProviderPublicClientTokenLifecycle(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		// tokenFor covers both revokeRefreshTokenInTx and revokeAPIKeyInTx.
		tokenFor func(*oauth2.Token) string
	}{
		{
			name:     "AccessToken",
			tokenFor: func(tok *oauth2.Token) string { return tok.AccessToken },
		},
		{
			name:     "RefreshToken",
			tokenFor: func(tok *oauth2.Token) string { return tok.RefreshToken },
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			session := refreshedPublicClientSession(ctx, t)
			tokenUnderTest := test.tokenFor(session.token)

			// Public clients have no secret to present.
			err := session.userClient.RevokeOAuth2Token(ctx, session.otherAppID, "", tokenUnderTest)
			require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009")
			require.True(t, session.works(), "cross-app revoke must not end the session")

			err = session.userClient.RevokeOAuth2Token(ctx, session.appID, "", tokenUnderTest)
			require.NoError(t, err)
			require.False(t, session.works(), "public client must be able to revoke its own token")
		})
	}
}

// publicClientSession is a refreshed public-client token with the app that
// issued it and an unrelated public app.
type publicClientSession struct {
	token      *oauth2.Token
	appID      uuid.UUID
	otherAppID uuid.UUID
	userClient *codersdk.Client
	// works reports whether token.AccessToken still authenticates a request.
	works func() bool
}

// refreshedPublicClientSession registers two public clients and runs one through
// the code exchange and a refresh, asserting each minted row is secretless.
func refreshedPublicClientSession(ctx context.Context, t *testing.T) publicClientSession {
	t.Helper()

	db, pubsub := dbtestutil.NewDB(t)
	ownerClient := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	oauth2providertest.EnableDCR(t, ownerClient)

	const callback = "http://localhost:8080/callback"
	app := oauth2providertest.RegisterPublicClient(t, ownerClient, "public-lifecycle", callback)
	otherApp := oauth2providertest.RegisterPublicClient(t, ownerClient, "public-lifecycle-other", callback)

	appID, err := uuid.Parse(app.ClientID)
	require.NoError(t, err)
	otherAppID, err := uuid.Parse(otherApp.ClientID)
	require.NoError(t, err)

	userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

	cfg := &oauth2.Config{
		ClientID: app.ClientID,
		Endpoint: oauth2.Endpoint{
			AuthURL:   ownerClient.URL.JoinPath("/oauth2/authorize").String(),
			TokenURL:  ownerClient.URL.JoinPath("/oauth2/tokens").String(),
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: callback,
		Scopes:      []string{},
	}

	code, verifier, err := authorizationFlow(ctx, userClient, cfg)
	require.NoError(t, err)
	token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
	require.NoError(t, err)
	require.NotEmpty(t, token.RefreshToken)

	// Locates the row by key ID alone, so it says nothing about whether the
	// token authenticates; the works closure is what proves that.
	assertSecretlessToken := func(accessToken string) {
		t.Helper()
		keyID, _, err := httpmw.SplitAPIToken(accessToken)
		require.NoError(t, err)
		// Raw store handle, not the dbauthz-wrapped one, so no system actor.
		dbToken, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(ctx, keyID)
		require.NoError(t, err)
		require.False(t, dbToken.AppSecretID.Valid, "public client token must have a NULL app_secret_id")
		require.Equal(t, appID, dbToken.AppID)
	}
	assertSecretlessToken(token.AccessToken)

	// Refresh carries AppSecretID forward, so the refreshed row must still be
	// secretless.
	refreshCfg := *cfg
	refreshed, err := refreshCfg.TokenSource(ctx, &oauth2.Token{
		RefreshToken: token.RefreshToken,
		Expiry:       time.Now().Add(-time.Hour),
	}).Token()
	require.NoError(t, err)
	require.NotEmpty(t, refreshed.AccessToken)
	assertSecretlessToken(refreshed.AccessToken)

	works := func() bool {
		checkClient := codersdk.New(userClient.URL)
		checkClient.SetSessionToken(refreshed.AccessToken)
		_, err := checkClient.User(ctx, codersdk.Me)
		return err == nil
	}
	require.True(t, works(), "refreshed public-client session should be valid")

	return publicClientSession{
		token:      refreshed,
		appID:      appID,
		otherAppID: otherAppID,
		userClient: userClient,
		works:      works,
	}
}

type provisionedApps struct {
	Default   codersdk.OAuth2ProviderApp
	NoPort    codersdk.OAuth2ProviderApp
	Subdomain codersdk.OAuth2ProviderApp
	// For sorting purposes these are included. You will likely never touch them.
	Extra []codersdk.OAuth2ProviderApp
}

func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, suffix string) provisionedApps {
	create := func(name, callback string) codersdk.OAuth2ProviderApp {
		name = fmt.Sprintf("%s-%s", name, suffix)
		//nolint:gocritic // OAauth2 app management requires owner permission.
		app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
			Name:        name,
			CallbackURL: callback,
			Icon:        "",
		})
		require.NoError(t, err)
		require.Equal(t, name, app.Name)
		require.Equal(t, callback, app.CallbackURL)
		return app
	}

	return provisionedApps{
		Default:   create("app-a", "https://localhost1:8080/foo/bar"),
		NoPort:    create("app-b", "https://localhost2"),
		Subdomain: create("app-z", "http://30.localhost:3000"),
		Extra: []codersdk.OAuth2ProviderApp{
			create("app-x", "http://20.localhost:3000"),
			create("app-y", "http://10.localhost:3000"),
		},
	}
}

// generatePKCE creates a PKCE verifier and S256 challenge for testing.
func generatePKCE() (verifier, challenge string) {
	verifier = uuid.NewString() + uuid.NewString()
	h := sha256.Sum256([]byte(verifier))
	challenge = base64.RawURLEncoding.EncodeToString(h[:])
	return verifier, challenge
}

func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2.Config) (code, codeVerifier string, err error) {
	state := uuid.NewString()
	codeVerifier, challenge := generatePKCE()
	authURL := cfg.AuthCodeURL(state,
		oauth2.SetAuthURLParam("code_challenge", challenge),
		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
	)

	// Make a POST request to simulate clicking "Allow" on the authorization page.
	// This bypasses the HTML consent page and directly processes the authorization.
	code, err = oidctest.OAuth2GetCode(
		authURL,
		func(req *http.Request) (*http.Response, error) {
			// Change to POST to simulate the form submission.
			req.Method = http.MethodPost

			// Prevent automatic redirect following.
			client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			}
			return client.Request(ctx, req.Method, req.URL.String(), nil)
		},
	)
	return code, codeVerifier, err
}

func must[T any](value T, err error) T {
	if err != nil {
		panic(err)
	}
	return value
}

// TestOAuth2ProviderResourceIndicators tests RFC 8707 Resource Indicators support
// including resource parameter validation in authorization and token exchange flows.
func TestOAuth2ProviderResourceIndicators(t *testing.T) {
	t.Parallel()

	db, pubsub := dbtestutil.NewDB(t)
	ownerClient := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})
	owner := coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)
	apps := generateApps(ctx, t, ownerClient, "resource-indicators")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)

	resource := ownerClient.URL.String()

	tests := []struct {
		name               string
		authResource       string // Resource parameter during authorization
		tokenResource      string // Resource parameter during token exchange
		refreshResource    string // Resource parameter during refresh
		expectAuthError    bool
		expectTokenError   bool
		expectRefreshError bool
	}{
		{
			name: "NoResourceParameter",
			// Standard flow without resource parameter
		},
		{
			name:            "ValidResourceParameter",
			authResource:    resource,
			tokenResource:   resource,
			refreshResource: resource,
		},
		{
			name:             "ResourceInAuthOnly",
			authResource:     resource,
			tokenResource:    "", // Missing in token exchange
			expectTokenError: true,
		},
		{
			name:             "ResourceInTokenOnly",
			authResource:     "", // Missing in auth
			tokenResource:    resource,
			expectTokenError: true,
		},
		{
			name:             "ResourceMismatch",
			authResource:     "https://resource1.example.com",
			tokenResource:    "https://resource2.example.com", // Different resource
			expectTokenError: true,
		},
		{
			name:               "RefreshWithDifferentResource",
			authResource:       resource,
			tokenResource:      resource,
			refreshResource:    "https://different.example.com", // Different in refresh
			expectRefreshError: true,
		},
		{
			name:            "RefreshWithoutResource",
			authResource:    resource,
			tokenResource:   resource,
			refreshResource: "", // No resource in refresh (allowed)
		},
		{
			name:            "RefreshWithSameResource",
			authResource:    resource,
			tokenResource:   resource,
			refreshResource: resource, // Same resource in refresh
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)

			userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)

			cfg := &oauth2.Config{
				ClientID:     apps.Default.ID.String(),
				ClientSecret: secret.ClientSecretFull,
				Endpoint: oauth2.Endpoint{
					AuthURL:   apps.Default.Endpoints.Authorization,
					TokenURL:  apps.Default.Endpoints.Token,
					AuthStyle: oauth2.AuthStyleInParams,
				},
				RedirectURL: apps.Default.CallbackURL,
				Scopes:      []string{},
			}

			// Step 1: Authorization with resource parameter and PKCE.
			state := uuid.NewString()
			verifier, challenge := generatePKCE()
			authURL := cfg.AuthCodeURL(state,
				oauth2.SetAuthURLParam("code_challenge", challenge),
				oauth2.SetAuthURLParam("code_challenge_method", "S256"),
			)
			if test.authResource != "" {
				// Add resource parameter to auth URL.
				parsedURL, err := url.Parse(authURL)
				require.NoError(t, err)
				query := parsedURL.Query()
				query.Set("resource", test.authResource)
				parsedURL.RawQuery = query.Encode()
				authURL = parsedURL.String()
			}

			// Simulate authorization flow
			code, err := oidctest.OAuth2GetCode(
				authURL,
				func(req *http.Request) (*http.Response, error) {
					req.Method = http.MethodPost
					userClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
						return http.ErrUseLastResponse
					}
					return userClient.Request(ctx, req.Method, req.URL.String(), nil)
				},
			)

			if test.expectAuthError {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)

			// Step 2: Token exchange with resource parameter
			// Use custom token exchange since golang.org/x/oauth2 doesn't support resource parameter in token requests
			token, err := customTokenExchange(ctx, ownerClient.URL.String(), apps.Default.ID.String(), secret.ClientSecretFull, code, apps.Default.CallbackURL, test.tokenResource, verifier)
			if test.expectTokenError {
				require.Error(t, err)
				require.Contains(t, err.Error(), "invalid_target")
				return
			}
			require.NoError(t, err)
			require.NotEmpty(t, token.AccessToken)

			// Per RFC 8707, audience is stored in database but not returned in token response
			// The audience validation happens server-side during API requests

			// Step 3: Test API access with token audience validation
			newClient := codersdk.New(userClient.URL)
			newClient.SetSessionToken(token.AccessToken)

			// Token should work for API access
			gotUser, err := newClient.User(ctx, codersdk.Me)
			require.NoError(t, err)
			require.Equal(t, user.ID, gotUser.ID)

			// Step 4: Test refresh token flow with resource parameter
			if token.RefreshToken != "" {
				// Note: OAuth2 library doesn't easily support custom parameters in refresh flows
				// For now, we test basic refresh functionality without resource parameter
				// TODO: Implement custom refresh flow testing with resource parameter

				// Create a token source with refresh capability
				tokenSource := cfg.TokenSource(ctx, &oauth2.Token{
					AccessToken:  token.AccessToken,
					RefreshToken: token.RefreshToken,
					Expiry:       time.Now().Add(-time.Minute), // Force refresh
				})

				// Test token refresh
				refreshedToken, err := tokenSource.Token()
				require.NoError(t, err)
				require.NotEmpty(t, refreshedToken.AccessToken)

				// Old token should be invalid
				_, err = newClient.User(ctx, codersdk.Me)
				require.Error(t, err)

				// New token should work
				newClient.SetSessionToken(refreshedToken.AccessToken)
				gotUser, err = newClient.User(ctx, codersdk.Me)
				require.NoError(t, err)
				require.Equal(t, user.ID, gotUser.ID)
			}
		})
	}
}

// TestOAuth2ProviderCrossResourceAudienceValidation tests that tokens are properly
// validated against the audience/resource server they were issued for.
func TestOAuth2ProviderCrossResourceAudienceValidation(t *testing.T) {
	t.Parallel()

	db, pubsub := dbtestutil.NewDB(t)

	// Set up first Coder instance (resource server 1)
	server1 := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})
	owner := coderdtest.CreateFirstUser(t, server1)

	// Set up second Coder instance (resource server 2) - simulate different host
	server2 := coderdtest.New(t, &coderdtest.Options{
		Database: db,
		Pubsub:   pubsub,
	})

	ctx := testutil.Context(t, testutil.WaitLong)

	// Create OAuth2 app
	apps := generateApps(ctx, t, server1, "cross-resource")

	//nolint:gocritic // OAauth2 app management requires owner permission.
	secret, err := server1.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)
	userClient, user := coderdtest.CreateAnotherUser(t, server1, owner.OrganizationID)

	// Get token with specific audience for server1
	resource1 := server1.URL.String()
	cfg := &oauth2.Config{
		ClientID:     apps.Default.ID.String(),
		ClientSecret: secret.ClientSecretFull,
		Endpoint: oauth2.Endpoint{
			AuthURL:   apps.Default.Endpoints.Authorization,
			TokenURL:  apps.Default.Endpoints.Token,
			AuthStyle: oauth2.AuthStyleInParams,
		},
		RedirectURL: apps.Default.CallbackURL,
		Scopes:      []string{},
	}

	// Authorization with resource parameter for server1 and PKCE.
	state := uuid.NewString()
	verifier, challenge := generatePKCE()
	authURL := cfg.AuthCodeURL(state,
		oauth2.SetAuthURLParam("code_challenge", challenge),
		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
	)
	parsedURL, err := url.Parse(authURL)
	require.NoError(t, err)
	query := parsedURL.Query()
	query.Set("resource", resource1)
	parsedURL.RawQuery = query.Encode()
	authURL = parsedURL.String()

	code, err := oidctest.OAuth2GetCode(
		authURL,
		func(req *http.Request) (*http.Response, error) {
			req.Method = http.MethodPost
			userClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			}
			return userClient.Request(ctx, req.Method, req.URL.String(), nil)
		},
	)
	require.NoError(t, err)

	// Exchange code for token with resource parameter and PKCE verifier.
	token, err := cfg.Exchange(ctx, code,
		oauth2.SetAuthURLParam("resource", resource1),
		oauth2.SetAuthURLParam("code_verifier", verifier),
	)
	require.NoError(t, err)
	require.NotEmpty(t, token.AccessToken)

	// Token should work on server1 (correct audience)
	client1 := codersdk.New(server1.URL)
	client1.SetSessionToken(token.AccessToken)
	gotUser, err := client1.User(ctx, codersdk.Me)
	require.NoError(t, err)
	require.Equal(t, user.ID, gotUser.ID)

	// Token should NOT work on server2 (different audience/host) if audience validation is implemented
	// Note: This test verifies that the audience validation middleware properly rejects
	// tokens issued for different resource servers
	client2 := codersdk.New(server2.URL)
	client2.SetSessionToken(token.AccessToken)

	// This should fail due to audience mismatch if validation is properly implemented
	// The expected behavior depends on whether the middleware detects Host differences
	if _, err := client2.User(ctx, codersdk.Me); err != nil {
		// This is expected if audience validation is working properly
		t.Logf("Cross-resource token properly rejected: %v", err)
		// Assert that the error is related to audience validation
		require.Contains(t, err.Error(), "audience")
	} else {
		// The token might still work if both servers use the same database but different URLs
		// since the actual audience validation depends on Host header comparison
		t.Logf("Cross-resource token was accepted (both servers use same database)")
		// For now, we accept this behavior since both servers share the same database
		// In a real cross-deployment scenario, this should fail
	}

	// TODO: Enhance this test when we have better cross-deployment testing setup
	// For now, this verifies the basic token flow works correctly
}

// TestOAuth2RefreshExpiryOutlivesAccess verifies that refresh token expiry is
// greater than the provisioned access token (API key) expiry per configuration.
func TestOAuth2RefreshExpiryOutlivesAccess(t *testing.T) {
	t.Parallel()

	// Set explicit lifetimes to make comparison deterministic.
	db, pubsub := dbtestutil.NewDB(t)
	dv := coderdtest.DeploymentValues(t, func(d *codersdk.DeploymentValues) {
		d.Sessions.DefaultDuration = serpent.Duration(1 * time.Hour)
		d.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
	})
	ownerClient := coderdtest.New(t, &coderdtest.Options{
		Database:         db,
		Pubsub:           pubsub,
		DeploymentValues: dv,
	})
	_ = coderdtest.CreateFirstUser(t, ownerClient)
	ctx := testutil.Context(t, testutil.WaitLong)

	// Create app and secret
	// Keep suffix short to satisfy name validation (<=32 chars, alnum + hyphens).
	apps := generateApps(ctx, t, ownerClient, "ref-exp")
	//nolint:gocritic // Owner permission required for app secret creation
	secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
	require.NoError(t, err)

	cfg := &oauth2.Config{
		ClientID:     apps.Default.ID.String(),
		ClientSecret: secret.ClientSecretFull,
		Endpoint: oauth2.Endpoint{
			AuthURL:       apps.Default.Endpoints.Authorization,
			DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
			TokenURL:      apps.Default.Endpoints.Token,
			AuthStyle:     oauth2.AuthStyleInParams,
		},
		RedirectURL: apps.Default.CallbackURL,
		Scopes:      []string{},
	}

	// Authorization and token exchange
	code, verifier, err := authorizationFlow(ctx, ownerClient, cfg)
	require.NoError(t, err)
	tok, err := cfg.Exchange(ctx, code,
		oauth2.SetAuthURLParam("code_verifier", verifier),
	)
	require.NoError(t, err)
	require.NotEmpty(t, tok.AccessToken)
	require.NotEmpty(t, tok.RefreshToken)

	// Parse refresh token prefix (coder_<prefix>_<secret>)
	parts := strings.Split(tok.RefreshToken, "_")
	require.Len(t, parts, 3)
	prefix := parts[1]

	// Look up refresh token row and associated API key
	dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(prefix))
	require.NoError(t, err)
	apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
	require.NoError(t, err)

	// Assert refresh token expiry is strictly after access token expiry
	require.Truef(t, dbToken.ExpiresAt.After(apiKey.ExpiresAt),
		"expected refresh expiry %s to be after access expiry %s",
		dbToken.ExpiresAt, apiKey.ExpiresAt,
	)
}

// customTokenExchange performs a custom OAuth2 token exchange with support for resource parameter
// This is needed because golang.org/x/oauth2 doesn't support custom parameters in token requests
func customTokenExchange(ctx context.Context, baseURL, clientID, clientSecret, code, redirectURI, resource, codeVerifier string) (*oauth2.Token, error) {
	data := url.Values{}
	data.Set("grant_type", "authorization_code")
	data.Set("code", code)
	data.Set("client_id", clientID)
	data.Set("client_secret", clientSecret)
	data.Set("redirect_uri", redirectURI)
	if resource != "" {
		data.Set("resource", resource)
	}
	if codeVerifier != "" {
		data.Set("code_verifier", codeVerifier)
	}

	req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		var errorResp struct {
			Error            string `json:"error"`
			ErrorDescription string `json:"error_description"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&errorResp)
		return nil, xerrors.Errorf("oauth2: %q %q", errorResp.Error, errorResp.ErrorDescription)
	}

	var token oauth2.Token
	if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
		return nil, err
	}

	return &token, nil
}

// TestOAuth2DynamicClientRegistration tests RFC 7591 dynamic client registration
func TestOAuth2DynamicClientRegistration(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)
	oauth2providertest.EnableDCR(t, client)

	t.Run("BasicRegistration", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		clientName := fmt.Sprintf("test-client-basic-%d", time.Now().UnixNano())
		req := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://example.com/callback"},
			ClientName:   clientName,
			ClientURI:    "https://example.com",
			LogoURI:      "https://example.com/logo.png",
			TOSURI:       "https://example.com/tos",
			PolicyURI:    "https://example.com/privacy",
			Contacts:     []string{"admin@example.com"},
		}

		// Register client
		resp, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.NoError(t, err)

		// Verify response fields
		require.NotEmpty(t, resp.ClientID)
		require.NotEmpty(t, resp.ClientSecret)
		require.NotEmpty(t, resp.RegistrationAccessToken)
		require.NotEmpty(t, resp.RegistrationClientURI)
		require.Greater(t, resp.ClientIDIssuedAt, int64(0))
		require.Equal(t, int64(0), resp.ClientSecretExpiresAt) // Non-expiring

		// Verify default values
		require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
		require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeRefreshToken)
		require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
		require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)

		// Verify request values are preserved
		require.Equal(t, req.RedirectURIs, resp.RedirectURIs)
		require.Equal(t, req.ClientName, resp.ClientName)
		require.Equal(t, req.ClientURI, resp.ClientURI)
		require.Equal(t, req.LogoURI, resp.LogoURI)
		require.Equal(t, req.TOSURI, resp.TOSURI)
		require.Equal(t, req.PolicyURI, resp.PolicyURI)
		require.Equal(t, req.Contacts, resp.Contacts)
	})

	t.Run("MinimalRegistration", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		req := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://minimal.com/callback"},
		}

		// Register client with minimal fields
		resp, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.NoError(t, err)

		// Should still get all required fields
		require.NotEmpty(t, resp.ClientID)
		require.NotEmpty(t, resp.ClientSecret)
		require.NotEmpty(t, resp.RegistrationAccessToken)
		require.NotEmpty(t, resp.RegistrationClientURI)

		// Should have defaults applied
		require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
		require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
		require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
	})

	t.Run("InvalidRedirectURI", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		req := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"not-a-url"},
		}

		_, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_client_metadata")
	})

	t.Run("NoRedirectURIs", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		req := codersdk.OAuth2ClientRegistrationRequest{
			ClientName: fmt.Sprintf("no-uris-client-%d", time.Now().UnixNano()),
		}

		_, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_client_metadata")
	})
}

// TestOAuth2DynamicClientRegistrationDisabled verifies the admin DCR
// enabled/disabled toggle: new registrations are rejected while a client
// that registered before DCR was disabled keeps working (RFC 7592
// self-management, authorization, and token exchange are unaffected).
func TestOAuth2DynamicClientRegistrationDisabled(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)
	ctx := testutil.Context(t, testutil.WaitLong)

	oauth2providertest.EnableDCR(t, client)

	// Register a client while DCR is still enabled.
	regResp, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
		RedirectURIs: []string{oauth2providertest.TestRedirectURI},
	})
	require.NoError(t, err)

	_, err = client.PutOAuth2ProviderSettings(ctx, codersdk.OAuth2ProviderSettings{
		DynamicClientRegistrationEnabled: new(false),
	})
	require.NoError(t, err)

	t.Run("NewRegistrationRejected", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		_, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{oauth2providertest.TestRedirectURI},
		})
		require.Error(t, err)
		var sdkError *codersdk.Error
		require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
		require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
	})

	t.Run("DiscoveryOmitsRegistrationEndpoint", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.Request(ctx, http.MethodGet, "/.well-known/oauth-authorization-server", nil)
		require.NoError(t, err)
		defer res.Body.Close()
		require.Equal(t, http.StatusOK, res.StatusCode)

		var metadata codersdk.OAuth2AuthorizationServerMetadata
		require.NoError(t, json.NewDecoder(res.Body).Decode(&metadata))
		require.Empty(t, metadata.RegistrationEndpoint)
	})

	t.Run("ExistingClientSelfManagementUnaffected", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)
		config, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, regResp.RegistrationAccessToken)
		require.NoError(t, err)
		require.Equal(t, regResp.ClientID, config.ClientID)
	})

	t.Run("ExistingClientAuthorizeAndTokenUnaffected", func(t *testing.T) {
		t.Parallel()
		codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
		state := oauth2providertest.GenerateState(t)

		code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), oauth2providertest.AuthorizeParams{
			ClientID:            regResp.ClientID,
			ResponseType:        "code",
			RedirectURI:         oauth2providertest.TestRedirectURI,
			State:               state,
			CodeChallenge:       codeChallenge,
			CodeChallengeMethod: "S256",
		})
		require.NotEmpty(t, code)

		token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), oauth2providertest.TokenExchangeParams{
			GrantType:    "authorization_code",
			Code:         code,
			ClientID:     regResp.ClientID,
			ClientSecret: regResp.ClientSecret,
			CodeVerifier: codeVerifier,
			RedirectURI:  oauth2providertest.TestRedirectURI,
		})
		require.NotEmpty(t, token.AccessToken)
	})
}

// TestOAuth2ClientConfiguration tests RFC 7592 client configuration management
func TestOAuth2ClientConfiguration(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)
	oauth2providertest.EnableDCR(t, client)

	// Helper to register a client
	registerClient := func(t *testing.T) (string, string, string) {
		ctx := testutil.Context(t, testutil.WaitLong)
		// Use shorter client name to avoid database varchar(64) constraint
		clientName := fmt.Sprintf("client-%d", time.Now().UnixNano())
		req := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://example.com/callback"},
			ClientName:   clientName,
			ClientURI:    "https://example.com",
		}

		resp, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.NoError(t, err)
		return resp.ClientID, resp.RegistrationAccessToken, clientName
	}

	t.Run("GetConfiguration", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		clientID, token, clientName := registerClient(t)

		// Get client configuration
		config, err := client.GetOAuth2ClientConfiguration(ctx, clientID, token)
		require.NoError(t, err)

		// Verify fields
		require.Equal(t, clientID, config.ClientID)
		require.Greater(t, config.ClientIDIssuedAt, int64(0))
		require.Equal(t, []string{"https://example.com/callback"}, config.RedirectURIs)
		require.Equal(t, clientName, config.ClientName)
		require.Equal(t, "https://example.com", config.ClientURI)

		// Should not contain client_secret in GET response
		require.Empty(t, config.RegistrationAccessToken) // Not included in GET
	})

	t.Run("UpdateConfiguration", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		clientID, token, _ := registerClient(t)

		// Update client configuration
		updatedName := fmt.Sprintf("updated-test-client-%d", time.Now().UnixNano())
		updateReq := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://newdomain.com/callback", "https://example.com/callback"},
			ClientName:   updatedName,
			ClientURI:    "https://newdomain.com",
			LogoURI:      "https://newdomain.com/logo.png",
		}

		config, err := client.PutOAuth2ClientConfiguration(ctx, clientID, token, updateReq)
		require.NoError(t, err)

		// Verify updates
		require.Equal(t, clientID, config.ClientID)
		require.Equal(t, updateReq.RedirectURIs, config.RedirectURIs)
		require.Equal(t, updateReq.ClientName, config.ClientName)
		require.Equal(t, updateReq.ClientURI, config.ClientURI)
		require.Equal(t, updateReq.LogoURI, config.LogoURI)
	})

	t.Run("DeleteConfiguration", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		clientID, token, _ := registerClient(t)

		// Delete client
		err := client.DeleteOAuth2ClientConfiguration(ctx, clientID, token)
		require.NoError(t, err)

		// Should no longer be able to get configuration
		_, err = client.GetOAuth2ClientConfiguration(ctx, clientID, token)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")
	})

	t.Run("InvalidToken", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		clientID, _, _ := registerClient(t)
		invalidToken := "invalid-token"

		// Should fail with invalid token
		_, err := client.GetOAuth2ClientConfiguration(ctx, clientID, invalidToken)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")
	})

	t.Run("NonexistentClient", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		fakeClientID := uuid.NewString()
		fakeToken := "fake-token"

		_, err := client.GetOAuth2ClientConfiguration(ctx, fakeClientID, fakeToken)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")
	})

	t.Run("MissingAuthHeader", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		clientID, _, _ := registerClient(t)

		// Try to access without token (empty string)
		_, err := client.GetOAuth2ClientConfiguration(ctx, clientID, "")
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")
	})
}

// TestOAuth2RegistrationAccessToken tests the registration access token middleware
func TestOAuth2RegistrationAccessToken(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)
	_ = coderdtest.CreateFirstUser(t, client)
	oauth2providertest.EnableDCR(t, client)

	t.Run("ValidToken", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		// Register a client
		req := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://example.com/callback"},
			ClientName:   fmt.Sprintf("token-test-client-%d", time.Now().UnixNano()),
		}

		resp, err := client.PostOAuth2ClientRegistration(ctx, req)
		require.NoError(t, err)

		// Valid token should work
		config, err := client.GetOAuth2ClientConfiguration(ctx, resp.ClientID, resp.RegistrationAccessToken)
		require.NoError(t, err)
		require.Equal(t, resp.ClientID, config.ClientID)
	})

	t.Run("ManuallyCreatedClient", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		// Create a client through the normal API (not dynamic registration)
		appReq := codersdk.PostOAuth2ProviderAppRequest{
			Name:        fmt.Sprintf("manual-%d", time.Now().UnixNano()%1000000),
			CallbackURL: "https://manual.com/callback",
		}

		app, err := client.PostOAuth2ProviderApp(ctx, appReq)
		require.NoError(t, err)

		// Should not be able to manage via RFC 7592 endpoints
		_, err = client.GetOAuth2ClientConfiguration(ctx, app.ID.String(), "any-token")
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token") // Client was not dynamically registered
	})

	t.Run("TokenPasswordComparison", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitLong)

		// Register two clients to ensure tokens are unique
		timestamp := time.Now().UnixNano()
		req1 := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://client1.com/callback"},
			ClientName:   fmt.Sprintf("client-1-%d", timestamp),
		}
		req2 := codersdk.OAuth2ClientRegistrationRequest{
			RedirectURIs: []string{"https://client2.com/callback"},
			ClientName:   fmt.Sprintf("client-2-%d", timestamp+1),
		}

		resp1, err := client.PostOAuth2ClientRegistration(ctx, req1)
		require.NoError(t, err)

		resp2, err := client.PostOAuth2ClientRegistration(ctx, req2)
		require.NoError(t, err)

		// Each client should only work with its own token
		_, err = client.GetOAuth2ClientConfiguration(ctx, resp1.ClientID, resp1.RegistrationAccessToken)
		require.NoError(t, err)

		_, err = client.GetOAuth2ClientConfiguration(ctx, resp2.ClientID, resp2.RegistrationAccessToken)
		require.NoError(t, err)

		// Cross-client tokens should fail
		_, err = client.GetOAuth2ClientConfiguration(ctx, resp1.ClientID, resp2.RegistrationAccessToken)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")

		_, err = client.GetOAuth2ClientConfiguration(ctx, resp2.ClientID, resp1.RegistrationAccessToken)
		require.Error(t, err)
		require.Contains(t, err.Error(), "invalid_token")
	})
}

// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client.
func TestOAuth2CoderClient(t *testing.T) {
	t.Parallel()

	owner := coderdtest.New(t, nil)
	first := coderdtest.CreateFirstUser(t, owner)

	// Setup an oauth app
	ctx := testutil.Context(t, testutil.WaitLong)
	app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
		Name:        "new-app",
		CallbackURL: "http://localhost",
	})
	require.NoError(t, err)

	appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID)
	require.NoError(t, err)

	cfg := &oauth2.Config{
		ClientID:     app.ID.String(),
		ClientSecret: appsecret.ClientSecretFull,
		Endpoint: oauth2.Endpoint{
			AuthURL:       app.Endpoints.Authorization,
			DeviceAuthURL: app.Endpoints.DeviceAuth,
			TokenURL:      app.Endpoints.Token,
			AuthStyle:     oauth2.AuthStyleInParams,
		},
		RedirectURL: app.CallbackURL,
		Scopes:      []string{},
	}

	// Make a new user
	client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)

	// Do an OAuth2 token exchange and get a new client with an oauth token.
	state := uuid.NewString()
	verifier, challenge := generatePKCE()

	// Get an OAuth2 code for a token exchange.
	code, err := oidctest.OAuth2GetCode(
		cfg.AuthCodeURL(state,
			oauth2.SetAuthURLParam("code_challenge", challenge),
			oauth2.SetAuthURLParam("code_challenge_method", "S256"),
		),
		func(req *http.Request) (*http.Response, error) {
			// Change to POST to simulate the form submission.
			req.Method = http.MethodPost

			// Prevent automatic redirect following.
			client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			}
			return client.Request(ctx, req.Method, req.URL.String(), nil)
		},
	)
	require.NoError(t, err)

	token, err := cfg.Exchange(ctx, code,
		oauth2.SetAuthURLParam("code_verifier", verifier),
	)
	require.NoError(t, err)

	// Use the oauth client's authentication
	// TODO: The SDK could probably support this with a better syntax/api.
	oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
	usingOauth := codersdk.New(owner.URL)
	usingOauth.HTTPClient = oauthClient

	me, err := usingOauth.User(ctx, codersdk.Me)
	require.NoError(t, err)
	require.Equal(t, user.ID, me.ID)

	// Revoking the refresh token should prevent further access
	// Revoking the refresh also invalidates the associated access token.
	err = usingOauth.RevokeOAuth2Token(ctx, app.ID, appsecret.ClientSecretFull, token.RefreshToken)
	require.NoError(t, err)

	_, err = usingOauth.User(ctx, codersdk.Me)
	require.Error(t, err)
}

// NOTE: OAuth2 client registration validation tests have been migrated to
// oauth2provider/validation_test.go for better separation of concerns

// TestOAuth2AuthorizeNoCORS checks that the CORS middleware sits in front of
// the OAuth2 routes and excludes the authorization endpoint.
func TestOAuth2AuthorizeNoCORS(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, nil)

	preflight := func(t *testing.T, route, method string) http.Header {
		t.Helper()
		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.Request(ctx, http.MethodOptions, route, nil, func(r *http.Request) {
			r.Header.Set("Origin", "https://app.example.com")
			r.Header.Set("Access-Control-Request-Method", method)
		})
		require.NoError(t, err)
		defer res.Body.Close()
		require.Equal(t, http.StatusOK, res.StatusCode)
		return res.Header
	}

	t.Run("Authorize", func(t *testing.T) {
		t.Parallel()
		headers := preflight(t, "/oauth2/authorize", http.MethodGet)
		require.Empty(t, headers.Get("Access-Control-Allow-Origin"))
	})

	// The router collapses repeated slashes, so this still reaches the
	// authorize handler and must get the same CORS policy.
	t.Run("AuthorizeRepeatedSlash", func(t *testing.T) {
		t.Parallel()
		headers := preflight(t, "/oauth2//authorize", http.MethodGet)
		require.Empty(t, headers.Get("Access-Control-Allow-Origin"))
	})

	// The authorize cases only check that headers are absent, which an
	// unmounted middleware would also satisfy. This case proves the
	// middleware is wired in front of the router.
	t.Run("Tokens", func(t *testing.T) {
		t.Parallel()
		headers := preflight(t, "/oauth2/tokens", http.MethodPost)
		require.Equal(t, "*", headers.Get("Access-Control-Allow-Origin"))
		require.Equal(t, http.MethodPost, headers.Get("Access-Control-Allow-Methods"))
	})
}