mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
OAuth2 tokens issued by Coder ignore scope entirely. The authorize endpoint parses the `scope` parameter and then discards it, and both grant paths mint API keys with full API access regardless of what the client requested or what the app's allowlist permits. There is also nowhere to put a negotiated scope: nothing carries one from the authorize step to the token it produces. Schema and query groundwork for that pipeline. No behavior change on its own. - Migration `000569` adds a `scope` column to `oauth2_provider_app_codes` and `oauth2_provider_app_tokens`, so a negotiated scope can travel from a code to the token it is exchanged for, and from a token to its refreshed successor. - Existing rows are backfilled to `coder:all`, then both columns become NOT NULL with a non-empty CHECK. Every OAuth2 key is unrestricted in fact today, so the backfill only writes that down, and a caller that omits the column now fails instead of silently issuing full access. - Adds `DeleteOAuth2ProviderAppCodeByIDReturningRow` and `DeleteAPIKeyByIDReturningRow`, which return `sql.ErrNoRows` when the row is already gone. Postgres serializes concurrent deletes on the row lock, so exactly one caller gets a row back, which is what will let the grant paths enforce single use of a code or refresh token without a read-then-write race. - No callers yet. The existing blind deletes and all of their call sites are untouched, and codes and tokens record `coder:all` until a later phase negotiates a real value. Phase 1 of [PLAT-470](https://linear.app/codercom/issue/PLAT-470), tracked as [PLAT-478](https://linear.app/codercom/issue/PLAT-478/phase-1-schema-and-queries). Scope validation at authorize, applying the negotiated scope in the code grant, and refresh narrowing follow as separate PRs. Verified locally: `make gen` and `make lint` clean, the migrations suite passes both up and down, and dbauthz's `TestMethodTestSuite` passes. <details> <summary>End-to-end scope enforcement flow (green marks what this PR touches)</summary> ```mermaid flowchart TD subgraph authorize["/oauth2/authorize"] AZ1["ShowAuthorizePage (GET)<br/>renders consent page"] AZ2["ProcessAuthorize (POST)<br/>scope parsed, then discarded"] Q1["InsertOAuth2ProviderAppCode<br/>gains a Scope param"] AZ1 --> AZ2 --> Q1 end Q1 --> CODES[("oauth2_provider_app_codes<br/>new column: scope text NOT NULL")] subgraph codegrant["POST /oauth2/token, grant_type=authorization_code"] G1["authorizationCodeGrant"] Q2["GetOAuth2ProviderAppCodeByPrefix<br/>now returns Scope"] Q4["DeleteOAuth2ProviderAppCodeByIDReturningRow<br/>added, no caller yet"] G2["apikey.Generate + UserRBACSubject<br/>hardcoded to full access"] G1 --> Q2 --> G2 G1 -.-> Q4 end CODES --> G1 G2 --> Q3 Q3["InsertOAuth2ProviderAppToken<br/>gains a Scope param"] Q3 --> TOKENS[("oauth2_provider_app_tokens<br/>new column: scope text NOT NULL")] subgraph refresh["POST /oauth2/token, grant_type=refresh_token"] G3["refreshTokenGrant"] Q5["GetOAuth2ProviderAppTokenByPrefix<br/>now returns Scope"] Q6["DeleteAPIKeyByIDReturningRow<br/>added, no caller yet"] G3 --> Q5 G3 -.-> Q6 end TOKENS --> G3 Q5 --> Q3 subgraph enforce["Every authenticated API request"] E1["httpmw ExtractAPIKey"] --> E2["APIKey.ScopeSet()"] --> E3["UserRBACSubject"] --> E4["dbauthz authorize"] end TOKENS --> E1 classDef changed fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#1b3c1e classDef dormant fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,stroke-dasharray:5 3,color:#1b3c1e class Q1,Q2,Q3,Q5,CODES,TOKENS changed class Q4,Q6 dormant ``` Solid green is added or changed here. Dashed green exists but has no caller yet. Everything else is unchanged, including the enforcement engine at the bottom, which already reads a key's scopes correctly and only needs real data fed into it. </details> <details> <summary>Suggested reading order</summary> Most of the diff is generated. `dump.sql`, `models.go`, `querier.go`, `queries.sql.go`, `check_constraint.go`, and the dbmock and dbmetrics packages all come from `make gen`. 1. `migrations/000569_oauth2_scope_columns.{up,down}.sql`: additive column, backfill, NOT NULL, CHECK, and a `COMMENT ON COLUMN` on each. 2. `queries/oauth2.sql` and `queries/apikeys.sql`: `scope` added to both insert column lists, plus the two new returning-row deletes alongside the untouched originals. The `Get...ByPrefix` selects needed no edit, since they are `SELECT *`. 3. `dbauthz/dbauthz.go`: hand-written wrappers for the two new queries, each fetching by ID, authorizing delete against the fetched object, then delegating. The generic `deleteQ` helper does not fit, since it requires the delete to return only `error`. 4. `oauth2provider/authorize.go` and `oauth2provider/tokens.go`: the only production changes, all behavior-neutral. 5. `dbgen/dbgen.go` and `dbauthz/dbauthz_test.go`: seed threading, plus a case per new query. `MethodTestSuite` fails with "Method never called" for anything untested. Neither type needs to become auditable, which `make lint` confirms by not erroring on `enterprise/audit/table.go`. </details> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2018 lines
67 KiB
Go
2018 lines
67 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/oauth2provider"
|
|
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
|
|
"github.com/coder/coder/v2/coderd/userpassword"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"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)
|
|
})
|
|
}
|
|
|
|
func TestOAuth2ProviderTokenExchange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
apps := generateApps(ctx, t, ownerClient, "token-exchange")
|
|
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
|
|
// The typical oauth2 flow from this point is:
|
|
// Create an oauth2.Config using the id, secret, endpoints, and redirect:
|
|
// cfg := oauth2.Config{ ... }
|
|
// Display url for the user to click:
|
|
// userClickURL := cfg.AuthCodeURL("random_state")
|
|
// userClickURL looks like: https://idp url/authorize?
|
|
// client_id=...
|
|
// response_type=code
|
|
// redirect_uri=.. (back to backstage url) ..
|
|
// scope=...
|
|
// state=...
|
|
// *1* User clicks "Allow" on provided page above
|
|
// The redirect_uri is followed which sends back to backstage with the code and state
|
|
// Now backstage has the info to do a cfg.Exchange() in the back to get an access token.
|
|
//
|
|
// ---NOTE---: If the user has already approved this oauth app, then *1* is optional.
|
|
// Coder can just immediately redirect back to backstage without user intervention.
|
|
tests := []struct {
|
|
name string
|
|
app codersdk.OAuth2ProviderApp
|
|
// The flow is setup(ctx, client, user) -> preAuth(cfg) -> cfg.AuthCodeURL() -> preToken(cfg) -> cfg.Exchange()
|
|
setup func(context.Context, *codersdk.Client, codersdk.User) error
|
|
preAuth func(valid *oauth2.Config)
|
|
authError string
|
|
preToken func(valid *oauth2.Config)
|
|
tokenError string
|
|
|
|
// If null, assume the code should be valid.
|
|
defaultCode *string
|
|
// custom allows some more advanced manipulation of the oauth2 exchange.
|
|
exchangeMutate []oauth2.AuthCodeOption
|
|
}{
|
|
{
|
|
name: "AuthInParams",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
valid.Endpoint.AuthStyle = oauth2.AuthStyleInParams
|
|
},
|
|
},
|
|
{
|
|
name: "AuthInvalidAppID",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
valid.ClientID = uuid.NewString()
|
|
},
|
|
authError: "invalid_client",
|
|
},
|
|
{
|
|
name: "TokenInvalidAppID",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientID = uuid.NewString()
|
|
},
|
|
tokenError: "invalid_client",
|
|
},
|
|
{
|
|
name: "InvalidPort",
|
|
app: apps.NoPort,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Host = newURL.Hostname() + ":8081"
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "WrongAppHost",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
valid.RedirectURL = apps.NoPort.CallbackURL
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "InvalidHostPrefix",
|
|
app: apps.NoPort,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Host = "prefix" + newURL.Hostname()
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "InvalidHost",
|
|
app: apps.NoPort,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Host = "invalid"
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "InvalidHostAndPort",
|
|
app: apps.NoPort,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Host = "invalid:8080"
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "InvalidPath",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Path = path.Join("/prepend", newURL.Path)
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "MissingPath",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Path = "/"
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "DifferentProtocol",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Scheme = "https"
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "NestedPath",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Path = path.Join(newURL.Path, "nested")
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
// Some oauth implementations allow this, but our users can host
|
|
// at subdomains. So we should not.
|
|
name: "Subdomain",
|
|
app: apps.Default,
|
|
preAuth: func(valid *oauth2.Config) {
|
|
newURL := must(url.Parse(valid.RedirectURL))
|
|
newURL.Host = "sub." + newURL.Host
|
|
valid.RedirectURL = newURL.String()
|
|
},
|
|
authError: "Invalid query params:",
|
|
},
|
|
{
|
|
name: "NoSecretScheme",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = "1234_4321"
|
|
},
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "InvalidSecretScheme",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = "notcoder_1234_4321"
|
|
},
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "MissingSecretSecret",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = "coder_1234"
|
|
},
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "MissingSecretPrefix",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = "coder__1234"
|
|
},
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "InvalidSecretPrefix",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = "coder_1234_4321"
|
|
},
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "MissingSecret",
|
|
app: apps.Default,
|
|
preToken: func(valid *oauth2.Config) {
|
|
valid.ClientSecret = ""
|
|
},
|
|
tokenError: "invalid_request",
|
|
},
|
|
{
|
|
name: "NoCodeScheme",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref("1234_4321"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
},
|
|
{
|
|
name: "InvalidCodeScheme",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref("notcoder_1234_4321"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
},
|
|
{
|
|
name: "MissingCodeSecret",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref("coder_1234"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
},
|
|
{
|
|
name: "MissingCodePrefix",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref("coder__1234"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
},
|
|
{
|
|
name: "InvalidCodePrefix",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref("coder_1234_4321"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
},
|
|
{
|
|
name: "MissingCode",
|
|
app: apps.Default,
|
|
defaultCode: ptr.Ref(""),
|
|
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: ptr.Ref("coder_prefix_code"),
|
|
tokenError: "The authorization code is invalid or expired",
|
|
setup: func(ctx context.Context, client *codersdk.Client, user codersdk.User) error {
|
|
// Insert an expired code.
|
|
hashedCode, err := userpassword.Hash("prefix_code")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
|
|
ID: uuid.New(),
|
|
CreatedAt: dbtime.Now().Add(-time.Minute * 11),
|
|
ExpiresAt: dbtime.Now().Add(-time.Minute),
|
|
SecretPrefix: []byte("prefix"),
|
|
HashedSecret: []byte(hashedCode),
|
|
AppID: apps.Default.ID,
|
|
UserID: user.ID,
|
|
Scope: string(database.ApiKeyScopeCoderAll),
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
// secret belongs to apps.Default (see the shared "secret" above),
|
|
// but this app's client_id is apps.NoPort. The token endpoint
|
|
// must reject a secret that belongs to a different app than the
|
|
// one identified by client_id, rather than trusting client_id
|
|
// alone to attribute the resulting token.
|
|
name: "SecretBelongsToDifferentApp",
|
|
app: apps.NoPort,
|
|
tokenError: "The client credentials are invalid",
|
|
},
|
|
{
|
|
name: "OK",
|
|
app: apps.Default,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Each test gets its own user, since we allow only one code per user and
|
|
// app at a time and running tests in parallel could clobber each other.
|
|
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
if test.setup != nil {
|
|
err := test.setup(ctx, userClient, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Each test gets its own oauth2.Config so they can run in parallel.
|
|
// In practice, you would only use 1 as a singleton.
|
|
valid := &oauth2.Config{
|
|
ClientID: test.app.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: test.app.Endpoints.Authorization,
|
|
DeviceAuthURL: test.app.Endpoints.DeviceAuth,
|
|
TokenURL: test.app.Endpoints.Token,
|
|
// TODO: @emyrk we should support both types.
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: test.app.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
if test.preAuth != nil {
|
|
test.preAuth(valid)
|
|
}
|
|
|
|
var code string
|
|
var verifier string
|
|
if test.defaultCode != nil {
|
|
code = *test.defaultCode
|
|
// These subtests exercise malformed/expired code_
|
|
// handling; the code lookup fails before code_verifier is
|
|
// ever compared, but it still has to satisfy RFC 7636
|
|
// §4.1's format floor to reach that point.
|
|
verifier = strings.Repeat("a", 43)
|
|
} else {
|
|
var err error
|
|
code, verifier, err = authorizationFlow(ctx, userClient, valid)
|
|
if test.authError != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, test.authError)
|
|
// If this errors the token exchange will fail. So end here.
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Mutate the valid config for the exchange.
|
|
if test.preToken != nil {
|
|
test.preToken(valid)
|
|
}
|
|
|
|
// Do the actual exchange. Include PKCE code_verifier when
|
|
// we obtained a code through the authorization flow.
|
|
exchangeOpts := append([]oauth2.AuthCodeOption{
|
|
oauth2.SetAuthURLParam("code_verifier", verifier),
|
|
}, test.exchangeMutate...)
|
|
token, err := valid.Exchange(ctx, code, exchangeOpts...)
|
|
if test.tokenError != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, test.tokenError)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, token.AccessToken)
|
|
require.True(t, dbtime.Now().Before(token.Expiry))
|
|
|
|
// Check that the token works.
|
|
newClient := codersdk.New(userClient.URL)
|
|
newClient.SetSessionToken(token.AccessToken)
|
|
|
|
gotUser, err := newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp covers
|
|
// authorizationCodeGrant's code-ownership check in isolation. The token
|
|
// endpoint validates redirect_uri against the app resolved from client_id
|
|
// (before the grant runs at all) and separately against the redirect_uri
|
|
// recorded on the code itself (inside the grant). Both must pass for the
|
|
// request to reach the code-ownership check, which only happens when the
|
|
// app identified by client_id and the app that originally issued the code
|
|
// happen to share the exact same callback URL, which is plausible for
|
|
// native clients that commonly register a conventional localhost
|
|
// redirect. Two apps with distinct callbacks (as in the table above) can
|
|
// never reach this check via a redirect_uri mismatch; this test
|
|
// constructs the one scenario that does.
|
|
func TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ownerClient := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
const sharedCallback = "http://localhost1:8080/foo/bar"
|
|
createApp := func(name string) (codersdk.OAuth2ProviderApp, codersdk.OAuth2ProviderAppSecretFull) {
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
app, err := ownerClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: fmt.Sprintf("%s-%d", name, time.Now().UnixNano()),
|
|
CallbackURL: sharedCallback,
|
|
})
|
|
require.NoError(t, err)
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
|
require.NoError(t, err)
|
|
return app, secret
|
|
}
|
|
appA, _ := createApp("code-owner")
|
|
appB, secretB := createApp("code-thief")
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
cfgA := &oauth2.Config{
|
|
ClientID: appA.ID.String(),
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: appA.Endpoints.Authorization,
|
|
TokenURL: appA.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: sharedCallback,
|
|
Scopes: []string{},
|
|
}
|
|
code, verifier, err := authorizationFlow(ctx, userClient, cfgA)
|
|
require.NoError(t, err)
|
|
|
|
// Exchange the code issued for appA, but presenting appB's client_id
|
|
// and appB's own valid secret. redirect_uri is identical for both
|
|
// apps, so both the request-level check (against the app resolved
|
|
// from client_id) and the grant's own check (against the code's
|
|
// recorded redirect_uri) pass, isolating the code-ownership check.
|
|
cfgB := &oauth2.Config{
|
|
ClientID: appB.ID.String(),
|
|
ClientSecret: secretB.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
TokenURL: appB.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: sharedCallback,
|
|
Scopes: []string{},
|
|
}
|
|
_, err = cfgB.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "The authorization code is invalid or expired")
|
|
}
|
|
|
|
func TestOAuth2ProviderTokenRefresh(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
apps := generateApps(ctx, t, ownerClient, "token-refresh")
|
|
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
|
|
// One path not tested here is when the token is empty, because Go's OAuth2
|
|
// client library will not even try to make the request.
|
|
tests := []struct {
|
|
name string
|
|
app codersdk.OAuth2ProviderApp
|
|
// refreshAsApp, if set, performs the refresh request under this
|
|
// app's client_id/endpoints instead of app, while the token itself
|
|
// still belongs to app. Used to test that refreshing a token under
|
|
// a different app's client_id is rejected outright, rather than
|
|
// silently re-parenting the token to the presented client_id.
|
|
refreshAsApp *codersdk.OAuth2ProviderApp
|
|
// If null, assume the token should be valid.
|
|
defaultToken *string
|
|
error string
|
|
expires time.Time
|
|
}{
|
|
{
|
|
name: "NoTokenScheme",
|
|
app: apps.Default,
|
|
defaultToken: ptr.Ref("1234_4321"),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "InvalidTokenScheme",
|
|
app: apps.Default,
|
|
defaultToken: ptr.Ref("notcoder_1234_4321"),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "MissingTokenSecret",
|
|
app: apps.Default,
|
|
defaultToken: ptr.Ref("coder_1234"),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "MissingTokenPrefix",
|
|
app: apps.Default,
|
|
defaultToken: ptr.Ref("coder__1234"),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "InvalidTokenPrefix",
|
|
app: apps.Default,
|
|
defaultToken: ptr.Ref("coder_1234_4321"),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "Expired",
|
|
app: apps.Default,
|
|
expires: time.Now().Add(time.Minute * -1),
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
// The token belongs to apps.Default, but the refresh request
|
|
// presents apps.NoPort's client_id. This must be rejected
|
|
// outright: silently accepting it (and re-parenting the
|
|
// token's app_id to whatever client_id is presented) would let
|
|
// a stolen refresh token be laundered to a different app,
|
|
// after which the issuing app could no longer revoke it.
|
|
name: "WrongApp",
|
|
app: apps.Default,
|
|
refreshAsApp: &apps.NoPort,
|
|
error: "The refresh token is invalid or expired",
|
|
},
|
|
{
|
|
name: "OK",
|
|
app: apps.Default,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
// Insert the token and its key.
|
|
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
|
|
UserID: user.ID,
|
|
LoginType: database.LoginTypeOAuth2ProviderApp,
|
|
ExpiresAt: time.Now().Add(time.Hour * 10),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
newKey, err := db.InsertAPIKey(ctx, key)
|
|
require.NoError(t, err)
|
|
|
|
token, err := oauth2provider.GenerateSecret()
|
|
require.NoError(t, err)
|
|
|
|
expires := test.expires
|
|
if expires.IsZero() {
|
|
expires = time.Now().Add(time.Hour * 10)
|
|
}
|
|
|
|
_, err = db.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
|
|
ID: uuid.New(),
|
|
CreatedAt: dbtime.Now(),
|
|
ExpiresAt: expires,
|
|
HashPrefix: []byte(token.Prefix),
|
|
RefreshHash: token.Hashed,
|
|
AppID: test.app.ID,
|
|
AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true},
|
|
APIKeyID: newKey.ID,
|
|
UserID: user.ID,
|
|
Scope: string(database.ApiKeyScopeCoderAll),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Check that the key works.
|
|
newClient := codersdk.New(userClient.URL)
|
|
newClient.SetSessionToken(sessionToken)
|
|
gotUser, err := newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
|
|
refreshAsApp := test.app
|
|
if test.refreshAsApp != nil {
|
|
refreshAsApp = *test.refreshAsApp
|
|
}
|
|
cfg := &oauth2.Config{
|
|
ClientID: refreshAsApp.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: refreshAsApp.Endpoints.Authorization,
|
|
DeviceAuthURL: refreshAsApp.Endpoints.DeviceAuth,
|
|
TokenURL: refreshAsApp.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: refreshAsApp.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Test whether it can be refreshed.
|
|
refreshToken := token.Formatted
|
|
if test.defaultToken != nil {
|
|
refreshToken = *test.defaultToken
|
|
}
|
|
refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{
|
|
AccessToken: sessionToken,
|
|
RefreshToken: refreshToken,
|
|
Expiry: time.Now().Add(time.Minute * -1),
|
|
}).Token()
|
|
|
|
if test.error != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, test.error)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, refreshed.AccessToken)
|
|
|
|
// Old token is now invalid.
|
|
_, err = newClient.User(ctx, codersdk.Me)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "401")
|
|
|
|
// Refresh token is valid.
|
|
newClient := codersdk.New(userClient.URL)
|
|
newClient.SetSessionToken(refreshed.AccessToken)
|
|
|
|
gotUser, err := newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type exchangeSetup struct {
|
|
cfg *oauth2.Config
|
|
app codersdk.OAuth2ProviderApp
|
|
secret codersdk.OAuth2ProviderAppSecretFull
|
|
code string
|
|
verifier string
|
|
}
|
|
|
|
func TestOAuth2ProviderRevoke(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
|
|
tests := []struct {
|
|
name string
|
|
// fn performs some action that removes the user's code and token.
|
|
fn func(context.Context, *codersdk.Client, exchangeSetup)
|
|
// replacesToken specifies whether the action replaces the token or only
|
|
// deletes it.
|
|
replacesToken bool
|
|
}{
|
|
{
|
|
name: "DeleteApp",
|
|
fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
err := client.DeleteOAuth2ProviderApp(ctx, s.app.ID)
|
|
require.NoError(t, err)
|
|
},
|
|
},
|
|
{
|
|
name: "DeleteSecret",
|
|
fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
err := client.DeleteOAuth2ProviderAppSecret(ctx, s.app.ID, s.secret.ID)
|
|
require.NoError(t, err)
|
|
},
|
|
},
|
|
{
|
|
name: "DeleteApp",
|
|
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
|
|
err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID)
|
|
require.NoError(t, err)
|
|
},
|
|
},
|
|
{
|
|
name: "OverrideCodeAndToken",
|
|
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
|
|
// Generating a new code should wipe out the old code.
|
|
code, verifier, err := authorizationFlow(ctx, client, s.cfg)
|
|
require.NoError(t, err)
|
|
|
|
// Generating a new token should wipe out the old token.
|
|
_, err = s.cfg.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("code_verifier", verifier),
|
|
)
|
|
require.NoError(t, err)
|
|
},
|
|
replacesToken: true,
|
|
},
|
|
}
|
|
|
|
setup := func(ctx context.Context, testClient *codersdk.Client, name string) exchangeSetup {
|
|
// We need a new app each time because we only allow one code and token per
|
|
// app and user at the moment and because the test might delete the app.
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: name,
|
|
CallbackURL: "http://localhost",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// We need a new secret every time because the test might delete the secret.
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &oauth2.Config{
|
|
ClientID: app.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: app.Endpoints.Authorization,
|
|
DeviceAuthURL: app.Endpoints.DeviceAuth,
|
|
TokenURL: app.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: app.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Go through the auth flow to get a code.
|
|
code, verifier, err := authorizationFlow(ctx, testClient, cfg)
|
|
require.NoError(t, err)
|
|
|
|
return exchangeSetup{
|
|
cfg: cfg,
|
|
app: app,
|
|
secret: secret,
|
|
code: code,
|
|
verifier: verifier,
|
|
}
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
testClient, testUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
|
|
testEntities := setup(ctx, testClient, test.name+"-1")
|
|
|
|
// Delete before the exchange completes (code should delete and attempting
|
|
// to finish the exchange should fail).
|
|
test.fn(ctx, testClient, testEntities)
|
|
|
|
// Exchange should fail because the code should be gone.
|
|
_, err := testEntities.cfg.Exchange(ctx, testEntities.code,
|
|
oauth2.SetAuthURLParam("code_verifier", testEntities.verifier),
|
|
)
|
|
require.Error(t, err)
|
|
|
|
// Try again, this time letting the exchange complete first.
|
|
testEntities = setup(ctx, testClient, test.name+"-2")
|
|
token, err := testEntities.cfg.Exchange(ctx, testEntities.code,
|
|
oauth2.SetAuthURLParam("code_verifier", testEntities.verifier),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Validate the returned access token and that the app is listed.
|
|
newClient := codersdk.New(client.URL)
|
|
newClient.SetSessionToken(token.AccessToken)
|
|
|
|
gotUser, err := newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testUser.ID, gotUser.ID)
|
|
|
|
filter := codersdk.OAuth2ProviderAppFilter{UserID: testUser.ID}
|
|
apps, err := testClient.OAuth2ProviderApps(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Contains(t, apps, testEntities.app)
|
|
|
|
// Should not show up for another user.
|
|
apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{UserID: owner.UserID})
|
|
require.NoError(t, err)
|
|
require.Len(t, apps, 0)
|
|
|
|
// Perform the deletion.
|
|
test.fn(ctx, testClient, testEntities)
|
|
|
|
// App should no longer show up for the user unless it was replaced.
|
|
if !test.replacesToken {
|
|
apps, err = testClient.OAuth2ProviderApps(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, apps, testEntities.app, fmt.Sprintf("contains %q", testEntities.app.Name))
|
|
}
|
|
|
|
// The token should no longer be valid.
|
|
_, err = newClient.User(ctx, codersdk.Me)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "401")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOAuth2ProviderRevokeCrossApp covers RFC 7009 revocation's ownership
|
|
// check, which compares a token's app_id directly rather than joining
|
|
// through app_secret_id. That rewrite had zero test coverage on its
|
|
// unequal branch: revoking a token while presenting a different app's
|
|
// client_id than the one that issued it must be rejected (masked as a
|
|
// success per RFC 7009, since revocation must not reveal whether a token
|
|
// exists), and must leave the token's session intact. Revoking under the
|
|
// correct, issuing app must still work.
|
|
func TestOAuth2ProviderRevokeCrossApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ownerClient := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
apps := generateApps(ctx, t, ownerClient, "revoke-cross-app")
|
|
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
// tokenFor extracts the token under test from a successful exchange,
|
|
// covering both the refresh-token (revokeRefreshTokenInTx) and
|
|
// access-token (revokeAPIKeyInTx) revocation branches.
|
|
tokenFor func(*oauth2.Token) string
|
|
}{
|
|
{
|
|
name: "AccessToken",
|
|
tokenFor: func(tok *oauth2.Token) string { return tok.AccessToken },
|
|
},
|
|
{
|
|
name: "RefreshToken",
|
|
tokenFor: func(tok *oauth2.Token) string { return tok.RefreshToken },
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
cfg := &oauth2.Config{
|
|
ClientID: apps.Default.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: apps.Default.Endpoints.Authorization,
|
|
DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
|
|
TokenURL: apps.Default.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: apps.Default.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
code, verifier, err := authorizationFlow(ctx, userClient, cfg)
|
|
require.NoError(t, err)
|
|
token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
|
|
require.NoError(t, err)
|
|
|
|
sessionWorks := func() bool {
|
|
checkClient := codersdk.New(userClient.URL)
|
|
checkClient.SetSessionToken(token.AccessToken)
|
|
_, err := checkClient.User(ctx, codersdk.Me)
|
|
return err == nil
|
|
}
|
|
require.True(t, sessionWorks(), "session should be valid before any revoke attempt")
|
|
|
|
tokenUnderTest := test.tokenFor(token)
|
|
|
|
// RFC 7009: revoking under a different app than the one that
|
|
// issued the token must not reveal whether it exists (no
|
|
// error), and must not actually end the session.
|
|
err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, tokenUnderTest)
|
|
require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009")
|
|
require.True(t, sessionWorks(), "cross-app revoke must not actually end the session")
|
|
|
|
// Revoking under the correct, issuing app must actually work.
|
|
err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, tokenUnderTest)
|
|
require.NoError(t, err)
|
|
require.False(t, sessionWorks(), "same-app revoke must end the session")
|
|
})
|
|
}
|
|
}
|
|
|
|
type provisionedApps struct {
|
|
Default codersdk.OAuth2ProviderApp
|
|
NoPort codersdk.OAuth2ProviderApp
|
|
Subdomain codersdk.OAuth2ProviderApp
|
|
// For sorting purposes these are included. You will likely never touch them.
|
|
Extra []codersdk.OAuth2ProviderApp
|
|
}
|
|
|
|
func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, suffix string) provisionedApps {
|
|
create := func(name, callback string) codersdk.OAuth2ProviderApp {
|
|
name = fmt.Sprintf("%s-%s", name, suffix)
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: name,
|
|
CallbackURL: callback,
|
|
Icon: "",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, name, app.Name)
|
|
require.Equal(t, callback, app.CallbackURL)
|
|
return app
|
|
}
|
|
|
|
return provisionedApps{
|
|
Default: create("app-a", "http://localhost1:8080/foo/bar"),
|
|
NoPort: create("app-b", "http://localhost2"),
|
|
Subdomain: create("app-z", "http://30.localhost:3000"),
|
|
Extra: []codersdk.OAuth2ProviderApp{
|
|
create("app-x", "http://20.localhost:3000"),
|
|
create("app-y", "http://10.localhost:3000"),
|
|
},
|
|
}
|
|
}
|
|
|
|
// generatePKCE creates a PKCE verifier and S256 challenge for testing.
|
|
func generatePKCE() (verifier, challenge string) {
|
|
verifier = uuid.NewString() + uuid.NewString()
|
|
h := sha256.Sum256([]byte(verifier))
|
|
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
|
return verifier, challenge
|
|
}
|
|
|
|
func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2.Config) (code, codeVerifier string, err error) {
|
|
state := uuid.NewString()
|
|
codeVerifier, challenge := generatePKCE()
|
|
authURL := cfg.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", challenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
)
|
|
|
|
// Make a POST request to simulate clicking "Allow" on the authorization page.
|
|
// This bypasses the HTML consent page and directly processes the authorization.
|
|
code, err = oidctest.OAuth2GetCode(
|
|
authURL,
|
|
func(req *http.Request) (*http.Response, error) {
|
|
// Change to POST to simulate the form submission.
|
|
req.Method = http.MethodPost
|
|
|
|
// Prevent automatic redirect following.
|
|
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return client.Request(ctx, req.Method, req.URL.String(), nil)
|
|
},
|
|
)
|
|
return code, codeVerifier, err
|
|
}
|
|
|
|
func must[T any](value T, err error) T {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// TestOAuth2ProviderResourceIndicators tests RFC 8707 Resource Indicators support
|
|
// including resource parameter validation in authorization and token exchange flows.
|
|
func TestOAuth2ProviderResourceIndicators(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
apps := generateApps(ctx, t, ownerClient, "resource-indicators")
|
|
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
|
|
resource := ownerClient.URL.String()
|
|
|
|
tests := []struct {
|
|
name string
|
|
authResource string // Resource parameter during authorization
|
|
tokenResource string // Resource parameter during token exchange
|
|
refreshResource string // Resource parameter during refresh
|
|
expectAuthError bool
|
|
expectTokenError bool
|
|
expectRefreshError bool
|
|
}{
|
|
{
|
|
name: "NoResourceParameter",
|
|
// Standard flow without resource parameter
|
|
},
|
|
{
|
|
name: "ValidResourceParameter",
|
|
authResource: resource,
|
|
tokenResource: resource,
|
|
refreshResource: resource,
|
|
},
|
|
{
|
|
name: "ResourceInAuthOnly",
|
|
authResource: resource,
|
|
tokenResource: "", // Missing in token exchange
|
|
expectTokenError: true,
|
|
},
|
|
{
|
|
name: "ResourceInTokenOnly",
|
|
authResource: "", // Missing in auth
|
|
tokenResource: resource,
|
|
expectTokenError: true,
|
|
},
|
|
{
|
|
name: "ResourceMismatch",
|
|
authResource: "https://resource1.example.com",
|
|
tokenResource: "https://resource2.example.com", // Different resource
|
|
expectTokenError: true,
|
|
},
|
|
{
|
|
name: "RefreshWithDifferentResource",
|
|
authResource: resource,
|
|
tokenResource: resource,
|
|
refreshResource: "https://different.example.com", // Different in refresh
|
|
expectRefreshError: true,
|
|
},
|
|
{
|
|
name: "RefreshWithoutResource",
|
|
authResource: resource,
|
|
tokenResource: resource,
|
|
refreshResource: "", // No resource in refresh (allowed)
|
|
},
|
|
{
|
|
name: "RefreshWithSameResource",
|
|
authResource: resource,
|
|
tokenResource: resource,
|
|
refreshResource: resource, // Same resource in refresh
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
cfg := &oauth2.Config{
|
|
ClientID: apps.Default.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: apps.Default.Endpoints.Authorization,
|
|
TokenURL: apps.Default.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: apps.Default.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Step 1: Authorization with resource parameter and PKCE.
|
|
state := uuid.NewString()
|
|
verifier, challenge := generatePKCE()
|
|
authURL := cfg.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", challenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
)
|
|
if test.authResource != "" {
|
|
// Add resource parameter to auth URL.
|
|
parsedURL, err := url.Parse(authURL)
|
|
require.NoError(t, err)
|
|
query := parsedURL.Query()
|
|
query.Set("resource", test.authResource)
|
|
parsedURL.RawQuery = query.Encode()
|
|
authURL = parsedURL.String()
|
|
}
|
|
|
|
// Simulate authorization flow
|
|
code, err := oidctest.OAuth2GetCode(
|
|
authURL,
|
|
func(req *http.Request) (*http.Response, error) {
|
|
req.Method = http.MethodPost
|
|
userClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return userClient.Request(ctx, req.Method, req.URL.String(), nil)
|
|
},
|
|
)
|
|
|
|
if test.expectAuthError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
// Step 2: Token exchange with resource parameter
|
|
// Use custom token exchange since golang.org/x/oauth2 doesn't support resource parameter in token requests
|
|
token, err := customTokenExchange(ctx, ownerClient.URL.String(), apps.Default.ID.String(), secret.ClientSecretFull, code, apps.Default.CallbackURL, test.tokenResource, verifier)
|
|
if test.expectTokenError {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_target")
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, token.AccessToken)
|
|
|
|
// Per RFC 8707, audience is stored in database but not returned in token response
|
|
// The audience validation happens server-side during API requests
|
|
|
|
// Step 3: Test API access with token audience validation
|
|
newClient := codersdk.New(userClient.URL)
|
|
newClient.SetSessionToken(token.AccessToken)
|
|
|
|
// Token should work for API access
|
|
gotUser, err := newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
|
|
// Step 4: Test refresh token flow with resource parameter
|
|
if token.RefreshToken != "" {
|
|
// Note: OAuth2 library doesn't easily support custom parameters in refresh flows
|
|
// For now, we test basic refresh functionality without resource parameter
|
|
// TODO: Implement custom refresh flow testing with resource parameter
|
|
|
|
// Create a token source with refresh capability
|
|
tokenSource := cfg.TokenSource(ctx, &oauth2.Token{
|
|
AccessToken: token.AccessToken,
|
|
RefreshToken: token.RefreshToken,
|
|
Expiry: time.Now().Add(-time.Minute), // Force refresh
|
|
})
|
|
|
|
// Test token refresh
|
|
refreshedToken, err := tokenSource.Token()
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, refreshedToken.AccessToken)
|
|
|
|
// Old token should be invalid
|
|
_, err = newClient.User(ctx, codersdk.Me)
|
|
require.Error(t, err)
|
|
|
|
// New token should work
|
|
newClient.SetSessionToken(refreshedToken.AccessToken)
|
|
gotUser, err = newClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOAuth2ProviderCrossResourceAudienceValidation tests that tokens are properly
|
|
// validated against the audience/resource server they were issued for.
|
|
func TestOAuth2ProviderCrossResourceAudienceValidation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
|
|
// Set up first Coder instance (resource server 1)
|
|
server1 := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, server1)
|
|
|
|
// Set up second Coder instance (resource server 2) - simulate different host
|
|
server2 := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create OAuth2 app
|
|
apps := generateApps(ctx, t, server1, "cross-resource")
|
|
|
|
//nolint:gocritic // OAauth2 app management requires owner permission.
|
|
secret, err := server1.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
userClient, user := coderdtest.CreateAnotherUser(t, server1, owner.OrganizationID)
|
|
|
|
// Get token with specific audience for server1
|
|
resource1 := server1.URL.String()
|
|
cfg := &oauth2.Config{
|
|
ClientID: apps.Default.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: apps.Default.Endpoints.Authorization,
|
|
TokenURL: apps.Default.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: apps.Default.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Authorization with resource parameter for server1 and PKCE.
|
|
state := uuid.NewString()
|
|
verifier, challenge := generatePKCE()
|
|
authURL := cfg.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", challenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
)
|
|
parsedURL, err := url.Parse(authURL)
|
|
require.NoError(t, err)
|
|
query := parsedURL.Query()
|
|
query.Set("resource", resource1)
|
|
parsedURL.RawQuery = query.Encode()
|
|
authURL = parsedURL.String()
|
|
|
|
code, err := oidctest.OAuth2GetCode(
|
|
authURL,
|
|
func(req *http.Request) (*http.Response, error) {
|
|
req.Method = http.MethodPost
|
|
userClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return userClient.Request(ctx, req.Method, req.URL.String(), nil)
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Exchange code for token with resource parameter and PKCE verifier.
|
|
token, err := cfg.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("resource", resource1),
|
|
oauth2.SetAuthURLParam("code_verifier", verifier),
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, token.AccessToken)
|
|
|
|
// Token should work on server1 (correct audience)
|
|
client1 := codersdk.New(server1.URL)
|
|
client1.SetSessionToken(token.AccessToken)
|
|
gotUser, err := client1.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, gotUser.ID)
|
|
|
|
// Token should NOT work on server2 (different audience/host) if audience validation is implemented
|
|
// Note: This test verifies that the audience validation middleware properly rejects
|
|
// tokens issued for different resource servers
|
|
client2 := codersdk.New(server2.URL)
|
|
client2.SetSessionToken(token.AccessToken)
|
|
|
|
// This should fail due to audience mismatch if validation is properly implemented
|
|
// The expected behavior depends on whether the middleware detects Host differences
|
|
if _, err := client2.User(ctx, codersdk.Me); err != nil {
|
|
// This is expected if audience validation is working properly
|
|
t.Logf("Cross-resource token properly rejected: %v", err)
|
|
// Assert that the error is related to audience validation
|
|
require.Contains(t, err.Error(), "audience")
|
|
} else {
|
|
// The token might still work if both servers use the same database but different URLs
|
|
// since the actual audience validation depends on Host header comparison
|
|
t.Logf("Cross-resource token was accepted (both servers use same database)")
|
|
// For now, we accept this behavior since both servers share the same database
|
|
// In a real cross-deployment scenario, this should fail
|
|
}
|
|
|
|
// TODO: Enhance this test when we have better cross-deployment testing setup
|
|
// For now, this verifies the basic token flow works correctly
|
|
}
|
|
|
|
// TestOAuth2RefreshExpiryOutlivesAccess verifies that refresh token expiry is
|
|
// greater than the provisioned access token (API key) expiry per configuration.
|
|
func TestOAuth2RefreshExpiryOutlivesAccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set explicit lifetimes to make comparison deterministic.
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
dv := coderdtest.DeploymentValues(t, func(d *codersdk.DeploymentValues) {
|
|
d.Sessions.DefaultDuration = serpent.Duration(1 * time.Hour)
|
|
d.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
|
|
})
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
DeploymentValues: dv,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create app and secret
|
|
// Keep suffix short to satisfy name validation (<=32 chars, alnum + hyphens).
|
|
apps := generateApps(ctx, t, ownerClient, "ref-exp")
|
|
//nolint:gocritic // Owner permission required for app secret creation
|
|
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &oauth2.Config{
|
|
ClientID: apps.Default.ID.String(),
|
|
ClientSecret: secret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: apps.Default.Endpoints.Authorization,
|
|
DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
|
|
TokenURL: apps.Default.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: apps.Default.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Authorization and token exchange
|
|
code, verifier, err := authorizationFlow(ctx, ownerClient, cfg)
|
|
require.NoError(t, err)
|
|
tok, err := cfg.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("code_verifier", verifier),
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tok.AccessToken)
|
|
require.NotEmpty(t, tok.RefreshToken)
|
|
|
|
// Parse refresh token prefix (coder_<prefix>_<secret>)
|
|
parts := strings.Split(tok.RefreshToken, "_")
|
|
require.Len(t, parts, 3)
|
|
prefix := parts[1]
|
|
|
|
// Look up refresh token row and associated API key
|
|
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(prefix))
|
|
require.NoError(t, err)
|
|
apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
|
|
require.NoError(t, err)
|
|
|
|
// Assert refresh token expiry is strictly after access token expiry
|
|
require.Truef(t, dbToken.ExpiresAt.After(apiKey.ExpiresAt),
|
|
"expected refresh expiry %s to be after access expiry %s",
|
|
dbToken.ExpiresAt, apiKey.ExpiresAt,
|
|
)
|
|
}
|
|
|
|
// customTokenExchange performs a custom OAuth2 token exchange with support for resource parameter
|
|
// This is needed because golang.org/x/oauth2 doesn't support custom parameters in token requests
|
|
func customTokenExchange(ctx context.Context, baseURL, clientID, clientSecret, code, redirectURI, resource, codeVerifier string) (*oauth2.Token, error) {
|
|
data := url.Values{}
|
|
data.Set("grant_type", "authorization_code")
|
|
data.Set("code", code)
|
|
data.Set("client_id", clientID)
|
|
data.Set("client_secret", clientSecret)
|
|
data.Set("redirect_uri", redirectURI)
|
|
if resource != "" {
|
|
data.Set("resource", resource)
|
|
}
|
|
if codeVerifier != "" {
|
|
data.Set("code_verifier", codeVerifier)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errorResp struct {
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
|
|
return nil, xerrors.Errorf("oauth2: %q %q", errorResp.Error, errorResp.ErrorDescription)
|
|
}
|
|
|
|
var token oauth2.Token
|
|
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &token, nil
|
|
}
|
|
|
|
// TestOAuth2DynamicClientRegistration tests RFC 7591 dynamic client registration
|
|
func TestOAuth2DynamicClientRegistration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
oauth2providertest.EnableDCR(t, client)
|
|
|
|
t.Run("BasicRegistration", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
clientName := fmt.Sprintf("test-client-basic-%d", time.Now().UnixNano())
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
ClientName: clientName,
|
|
ClientURI: "https://example.com",
|
|
LogoURI: "https://example.com/logo.png",
|
|
TOSURI: "https://example.com/tos",
|
|
PolicyURI: "https://example.com/privacy",
|
|
Contacts: []string{"admin@example.com"},
|
|
}
|
|
|
|
// Register client
|
|
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// Verify response fields
|
|
require.NotEmpty(t, resp.ClientID)
|
|
require.NotEmpty(t, resp.ClientSecret)
|
|
require.NotEmpty(t, resp.RegistrationAccessToken)
|
|
require.NotEmpty(t, resp.RegistrationClientURI)
|
|
require.Greater(t, resp.ClientIDIssuedAt, int64(0))
|
|
require.Equal(t, int64(0), resp.ClientSecretExpiresAt) // Non-expiring
|
|
|
|
// Verify default values
|
|
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
|
|
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeRefreshToken)
|
|
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
|
|
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
|
|
|
|
// Verify request values are preserved
|
|
require.Equal(t, req.RedirectURIs, resp.RedirectURIs)
|
|
require.Equal(t, req.ClientName, resp.ClientName)
|
|
require.Equal(t, req.ClientURI, resp.ClientURI)
|
|
require.Equal(t, req.LogoURI, resp.LogoURI)
|
|
require.Equal(t, req.TOSURI, resp.TOSURI)
|
|
require.Equal(t, req.PolicyURI, resp.PolicyURI)
|
|
require.Equal(t, req.Contacts, resp.Contacts)
|
|
})
|
|
|
|
t.Run("MinimalRegistration", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://minimal.com/callback"},
|
|
}
|
|
|
|
// Register client with minimal fields
|
|
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// Should still get all required fields
|
|
require.NotEmpty(t, resp.ClientID)
|
|
require.NotEmpty(t, resp.ClientSecret)
|
|
require.NotEmpty(t, resp.RegistrationAccessToken)
|
|
require.NotEmpty(t, resp.RegistrationClientURI)
|
|
|
|
// Should have defaults applied
|
|
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
|
|
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
|
|
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
|
|
})
|
|
|
|
t.Run("InvalidRedirectURI", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"not-a-url"},
|
|
}
|
|
|
|
_, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_client_metadata")
|
|
})
|
|
|
|
t.Run("NoRedirectURIs", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
ClientName: fmt.Sprintf("no-uris-client-%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
_, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_client_metadata")
|
|
})
|
|
}
|
|
|
|
// TestOAuth2DynamicClientRegistrationDisabled verifies the admin DCR
|
|
// enabled/disabled toggle: new registrations are rejected while a client
|
|
// that registered before DCR was disabled keeps working (RFC 7592
|
|
// self-management, authorization, and token exchange are unaffected).
|
|
func TestOAuth2DynamicClientRegistrationDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
oauth2providertest.EnableDCR(t, client)
|
|
|
|
// Register a client while DCR is still enabled.
|
|
regResp, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{oauth2providertest.TestRedirectURI},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.PutOAuth2ProviderSettings(ctx, codersdk.OAuth2ProviderSettings{
|
|
DynamicClientRegistrationEnabled: ptr.Ref(false),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("NewRegistrationRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
_, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{oauth2providertest.TestRedirectURI},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkError *codersdk.Error
|
|
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
|
|
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
|
|
})
|
|
|
|
t.Run("DiscoveryOmitsRegistrationEndpoint", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
res, err := client.Request(ctx, http.MethodGet, "/.well-known/oauth-authorization-server", nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
var metadata codersdk.OAuth2AuthorizationServerMetadata
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&metadata))
|
|
require.Empty(t, metadata.RegistrationEndpoint)
|
|
})
|
|
|
|
t.Run("ExistingClientSelfManagementUnaffected", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, regResp.RegistrationAccessToken)
|
|
require.NoError(t, err)
|
|
require.Equal(t, regResp.ClientID, config.ClientID)
|
|
})
|
|
|
|
t.Run("ExistingClientAuthorizeAndTokenUnaffected", func(t *testing.T) {
|
|
t.Parallel()
|
|
codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
|
|
state := oauth2providertest.GenerateState(t)
|
|
|
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), oauth2providertest.AuthorizeParams{
|
|
ClientID: regResp.ClientID,
|
|
ResponseType: "code",
|
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
|
State: state,
|
|
CodeChallenge: codeChallenge,
|
|
CodeChallengeMethod: "S256",
|
|
})
|
|
require.NotEmpty(t, code)
|
|
|
|
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), oauth2providertest.TokenExchangeParams{
|
|
GrantType: "authorization_code",
|
|
Code: code,
|
|
ClientID: regResp.ClientID,
|
|
ClientSecret: regResp.ClientSecret,
|
|
CodeVerifier: codeVerifier,
|
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
|
})
|
|
require.NotEmpty(t, token.AccessToken)
|
|
})
|
|
}
|
|
|
|
// TestOAuth2ClientConfiguration tests RFC 7592 client configuration management
|
|
func TestOAuth2ClientConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
oauth2providertest.EnableDCR(t, client)
|
|
|
|
// Helper to register a client
|
|
registerClient := func(t *testing.T) (string, string, string) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
// Use shorter client name to avoid database varchar(64) constraint
|
|
clientName := fmt.Sprintf("client-%d", time.Now().UnixNano())
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
ClientName: clientName,
|
|
ClientURI: "https://example.com",
|
|
}
|
|
|
|
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.NoError(t, err)
|
|
return resp.ClientID, resp.RegistrationAccessToken, clientName
|
|
}
|
|
|
|
t.Run("GetConfiguration", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
clientID, token, clientName := registerClient(t)
|
|
|
|
// Get client configuration
|
|
config, err := client.GetOAuth2ClientConfiguration(ctx, clientID, token)
|
|
require.NoError(t, err)
|
|
|
|
// Verify fields
|
|
require.Equal(t, clientID, config.ClientID)
|
|
require.Greater(t, config.ClientIDIssuedAt, int64(0))
|
|
require.Equal(t, []string{"https://example.com/callback"}, config.RedirectURIs)
|
|
require.Equal(t, clientName, config.ClientName)
|
|
require.Equal(t, "https://example.com", config.ClientURI)
|
|
|
|
// Should not contain client_secret in GET response
|
|
require.Empty(t, config.RegistrationAccessToken) // Not included in GET
|
|
})
|
|
|
|
t.Run("UpdateConfiguration", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
clientID, token, _ := registerClient(t)
|
|
|
|
// Update client configuration
|
|
updatedName := fmt.Sprintf("updated-test-client-%d", time.Now().UnixNano())
|
|
updateReq := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://newdomain.com/callback", "https://example.com/callback"},
|
|
ClientName: updatedName,
|
|
ClientURI: "https://newdomain.com",
|
|
LogoURI: "https://newdomain.com/logo.png",
|
|
}
|
|
|
|
config, err := client.PutOAuth2ClientConfiguration(ctx, clientID, token, updateReq)
|
|
require.NoError(t, err)
|
|
|
|
// Verify updates
|
|
require.Equal(t, clientID, config.ClientID)
|
|
require.Equal(t, updateReq.RedirectURIs, config.RedirectURIs)
|
|
require.Equal(t, updateReq.ClientName, config.ClientName)
|
|
require.Equal(t, updateReq.ClientURI, config.ClientURI)
|
|
require.Equal(t, updateReq.LogoURI, config.LogoURI)
|
|
})
|
|
|
|
t.Run("DeleteConfiguration", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
clientID, token, _ := registerClient(t)
|
|
|
|
// Delete client
|
|
err := client.DeleteOAuth2ClientConfiguration(ctx, clientID, token)
|
|
require.NoError(t, err)
|
|
|
|
// Should no longer be able to get configuration
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, clientID, token)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
})
|
|
|
|
t.Run("InvalidToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
clientID, _, _ := registerClient(t)
|
|
invalidToken := "invalid-token"
|
|
|
|
// Should fail with invalid token
|
|
_, err := client.GetOAuth2ClientConfiguration(ctx, clientID, invalidToken)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
})
|
|
|
|
t.Run("NonexistentClient", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
fakeClientID := uuid.NewString()
|
|
fakeToken := "fake-token"
|
|
|
|
_, err := client.GetOAuth2ClientConfiguration(ctx, fakeClientID, fakeToken)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
})
|
|
|
|
t.Run("MissingAuthHeader", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
clientID, _, _ := registerClient(t)
|
|
|
|
// Try to access without token (empty string)
|
|
_, err := client.GetOAuth2ClientConfiguration(ctx, clientID, "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
})
|
|
}
|
|
|
|
// TestOAuth2RegistrationAccessToken tests the registration access token middleware
|
|
func TestOAuth2RegistrationAccessToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
oauth2providertest.EnableDCR(t, client)
|
|
|
|
t.Run("ValidToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Register a client
|
|
req := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
ClientName: fmt.Sprintf("token-test-client-%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// Valid token should work
|
|
config, err := client.GetOAuth2ClientConfiguration(ctx, resp.ClientID, resp.RegistrationAccessToken)
|
|
require.NoError(t, err)
|
|
require.Equal(t, resp.ClientID, config.ClientID)
|
|
})
|
|
|
|
t.Run("ManuallyCreatedClient", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create a client through the normal API (not dynamic registration)
|
|
appReq := codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: fmt.Sprintf("manual-%d", time.Now().UnixNano()%1000000),
|
|
CallbackURL: "https://manual.com/callback",
|
|
}
|
|
|
|
app, err := client.PostOAuth2ProviderApp(ctx, appReq)
|
|
require.NoError(t, err)
|
|
|
|
// Should not be able to manage via RFC 7592 endpoints
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, app.ID.String(), "any-token")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token") // Client was not dynamically registered
|
|
})
|
|
|
|
t.Run("TokenPasswordComparison", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Register two clients to ensure tokens are unique
|
|
timestamp := time.Now().UnixNano()
|
|
req1 := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://client1.com/callback"},
|
|
ClientName: fmt.Sprintf("client-1-%d", timestamp),
|
|
}
|
|
req2 := codersdk.OAuth2ClientRegistrationRequest{
|
|
RedirectURIs: []string{"https://client2.com/callback"},
|
|
ClientName: fmt.Sprintf("client-2-%d", timestamp+1),
|
|
}
|
|
|
|
resp1, err := client.PostOAuth2ClientRegistration(ctx, req1)
|
|
require.NoError(t, err)
|
|
|
|
resp2, err := client.PostOAuth2ClientRegistration(ctx, req2)
|
|
require.NoError(t, err)
|
|
|
|
// Each client should only work with its own token
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, resp1.ClientID, resp1.RegistrationAccessToken)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, resp2.ClientID, resp2.RegistrationAccessToken)
|
|
require.NoError(t, err)
|
|
|
|
// Cross-client tokens should fail
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, resp1.ClientID, resp2.RegistrationAccessToken)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
|
|
_, err = client.GetOAuth2ClientConfiguration(ctx, resp2.ClientID, resp1.RegistrationAccessToken)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid_token")
|
|
})
|
|
}
|
|
|
|
// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client.
|
|
func TestOAuth2CoderClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
owner := coderdtest.New(t, nil)
|
|
first := coderdtest.CreateFirstUser(t, owner)
|
|
|
|
// Setup an oauth app
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: "new-app",
|
|
CallbackURL: "http://localhost",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &oauth2.Config{
|
|
ClientID: app.ID.String(),
|
|
ClientSecret: appsecret.ClientSecretFull,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: app.Endpoints.Authorization,
|
|
DeviceAuthURL: app.Endpoints.DeviceAuth,
|
|
TokenURL: app.Endpoints.Token,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: app.CallbackURL,
|
|
Scopes: []string{},
|
|
}
|
|
|
|
// Make a new user
|
|
client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
|
|
|
// Do an OAuth2 token exchange and get a new client with an oauth token.
|
|
state := uuid.NewString()
|
|
verifier, challenge := generatePKCE()
|
|
|
|
// Get an OAuth2 code for a token exchange.
|
|
code, err := oidctest.OAuth2GetCode(
|
|
cfg.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", challenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
),
|
|
func(req *http.Request) (*http.Response, error) {
|
|
// Change to POST to simulate the form submission.
|
|
req.Method = http.MethodPost
|
|
|
|
// Prevent automatic redirect following.
|
|
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return client.Request(ctx, req.Method, req.URL.String(), nil)
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
token, err := cfg.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("code_verifier", verifier),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Use the oauth client's authentication
|
|
// TODO: The SDK could probably support this with a better syntax/api.
|
|
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
|
usingOauth := codersdk.New(owner.URL)
|
|
usingOauth.HTTPClient = oauthClient
|
|
|
|
me, err := usingOauth.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Equal(t, user.ID, me.ID)
|
|
|
|
// Revoking the refresh token should prevent further access
|
|
// Revoking the refresh also invalidates the associated access token.
|
|
err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken)
|
|
require.NoError(t, err)
|
|
|
|
_, err = usingOauth.User(ctx, codersdk.Me)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// NOTE: OAuth2 client registration validation tests have been migrated to
|
|
// oauth2provider/validation_test.go for better separation of concerns
|