Files
coder/coderd/oauth2_test.go
T
Bobby Ho 42fcd332c6 fix: remove CORS from the OAuth2 authorization endpoint (#29143)
fix(oauth2): exclude authorization endpoint from permissive CORS

- Apply standard CORS to `/oauth2/authorize`, with or without a trailing slash, as required by OAuth 2.1 §3.1.
- Normalize repeated slashes before selecting the policy to prevent exclusion bypasses, including in wsproxy.
- Preserve permissive CORS for other OAuth2 endpoints, discovery documents, and MCP. Same-origin consent behavior is unchanged.
- Add middleware and router regression tests and update provider security docs.
2026-09-10 10:49:28 -07:00

2285 lines
77 KiB
Go

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 = "https"
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 = "http://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)
// 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
// 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 the refresh request
// presents apps.NoPort's client_id. 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: "WrongApp",
app: apps.Default,
refreshAsApp: &apps.NoPort,
error: "The refresh token is invalid or expired",
},
{
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
}
cfg := &oauth2.Config{
ClientID: refreshAsApp.ID.String(),
ClientSecret: secret.ClientSecretFull,
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")
})
}
}
// 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)
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.
err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, 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, tokenUnderTest)
require.NoError(t, err)
require.False(t, sessionWorks(), "same-app revoke must end the session")
})
}
}
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)
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", "http://localhost1:8080/foo/bar"),
NoPort: create("app-b", "http://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, 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"))
})
}