mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Reject non-empty `client_secret` query parameters on token and revocation endpoints with `400 invalid_request`, enforcing OAuth 2.1 §2.4.1 (D6-04). - **Breaking:** Send secrets in the request body or `Authorization` header, and rotate any previously sent in URLs. Rejections consume nothing, so clients can retry without reauthorization. - Empty query secrets count as absent, but token requests also supplying a body secret still fail duplicate-parameter validation. Authorize ignores query secrets and logs a warning without the value. - Sanitize and cap revocation parse errors, correctly report repeated token parameters, and update tests and API docs. Refusal logs include the app ID, never the secret. - Other query parameters (`client_id`, `refresh_token`, `code`, and revocation tokens) remain accepted; body-only credential parsing is tracked in PLAT-660. No migration or new error codes. Refs PLAT-602.
2677 lines
92 KiB
Go
2677 lines
92 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 = "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"))
|
|
})
|
|
}
|