mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
b776a14b46
## Summary Harden the OAuth2 provider with multiple security fixes addressing `coder/security#121` (CSRF session takeover) and converge on OAuth 2.1 compliance. ### Security Fixes | Fix | Description | Commits | |-----|-------------|---------| | **CSRF on `/oauth2/authorize`** | Enforce CSRF protection on the authorize endpoint POST (consent form submission) | `ba7d646`, `b94a64e` | | **Clickjacking: `frame-ancestors` CSP** | Prevent consent page from being iframed (`Content-Security-Policy: frame-ancestors 'none'` + `X-Frame-Options: DENY`) | `597aeb2` | | **Exact redirect URI matching** | Changed from prefix matching to full string exact matching per OAuth 2.1 §4.1.2.1 | `73d64b1`, `93897f1` | | **Store & verify `redirect_uri`** | Store redirect_uri with auth code in DB, verify at token exchange matches exactly (RFC 6749 §4.1.3) | `50569b9`, `d7ca315` | | **Mandatory PKCE** | Require `code_challenge` at authorization (for `response_type=code`) + unconditional `code_verifier` verification at token exchange | `d7ca315`, `1cda1a9` | | **Reject implicit grant** | `response_type=token` now returns `unsupported_response_type` error page (OAuth 2.1 removes implicit flow) | `d7ca315`, `91b8863` | ### Changes by File **`coderd/httpmw/csrf.go`** — Extended the CSRF `ExemptFunc` to enforce CSRF on `/oauth2/authorize` in addition to `/api` routes. The consent form POST is now CSRF-protected to prevent cross-site authorization code theft. **`site/site.go`** — Added `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY` headers to `RenderOAuthAllowPage` (consent page only — does not affect the SPA/global CSP used by AI tasks). **`coderd/httpapi/queryparams.go`** — Changed `RedirectURL` from prefix matching (`strings.HasPrefix(v.Path, base.Path)`) to full URI exact matching (`v.String() != base.String()`), comparing scheme, host, path, and query. **`coderd/oauth2provider/authorize.go`** — Added PKCE enforcement: `code_challenge` is required when `response_type=code` (via a conditional check, not `RequiredNotEmpty`, so `response_type=token` can reach the explicit rejection path). `ShowAuthorizePage` (GET) validates `response_type` before rendering and returns a 400 error page for unsupported types. `ProcessAuthorize` (POST) stores the `redirect_uri` with the auth code when explicitly provided. **`coderd/oauth2provider/tokens.go`** — PKCE verification is now unconditional (not gated on `code_challenge` being present in DB). If the stored code has a `redirect_uri`, the token endpoint verifies it matches exactly — mismatch returns `errBadCode` → `invalid_grant`. Missing `code_verifier` returns `invalid_grant`. **`codersdk/oauth2.go`** — `OAuth2ProviderResponseTypeToken` constant and `Valid()` acceptance are **kept** so the authorize handler can parse `response_type=token` and return the proper `unsupported_response_type` error rather than failing at parameter validation. **`coderd/database/migrations/000421_*`** — Added `redirect_uri text` column to `oauth2_provider_app_codes`. ### Design Decisions **`state` parameter remains optional** — The plan initially required `state` via `RequiredNotEmpty`, but this was reverted in `376a753` to avoid breaking existing clients. The `state` is still hashed and stored when provided (via `state_hash` column), securing clients that opt in. **`response_type=token` kept in `Valid()`** — Removing it from `Valid()` would cause the parameter parser to reject the request before the authorize handler can return the proper `unsupported_response_type` error. The constant is kept for correct error handling flow. **CSP scoped to consent page only** — `frame-ancestors 'none'` is set only on the OAuth consent page renderer, not globally. The SPA/global CSP was previously changed to allow framing for AI tasks ([#18102](https://github.com/coder/coder/pull/18102)); this change does not regress that. ### Out of Scope (follow-up PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting on dynamic client registration --- <details> <summary>📋 Implementation Plan</summary> # Plan: Harden OAuth2 Provider — Security Fixes + OAuth 2.1 Compliance ## Context & Why Security issue `coder/security#121` reports a critical session takeover via CSRF on the OAuth2 provider. This plan covers all remaining security fixes from that issue **plus** convergence on OAuth 2.1 requirements. The goal is a single PR that closes all actionable gaps. ## Current State (already committed on branch `csrf-sjx1`) | Fix | Status | Commits | |-----|--------|---------| | Fix 1: CSRF on `/oauth2/authorize` | ✅ Done | `ba7d646`, `b94a64e` | | CSRF token in consent form HTML | ✅ Done | `b94a64e` | | `state_hash` column + storage | ✅ Done (hash stored, but state still optional) | `9167d83`, `b94a64e` | | Tests for CSRF + state hash | ✅ Done | `e4119b5` | ## Remaining Work ### ~~Fix 2 — Require `state` parameter~~ (DROPPED) > **Decision:** Do not enforce `state` as required. The `state` parameter is still hashed and stored when provided (via `hashOAuth2State` / `state_hash` column from prior commits), but clients are not forced to supply it. This avoids breaking existing integrations that omit state. **Rollback:** Remove `"state"` from the `RequiredNotEmpty` call in `coderd/oauth2provider/authorize.go:42`: ```go // BEFORE (current on branch) p.RequiredNotEmpty("response_type", "client_id", "state", "code_challenge") // AFTER p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` No test changes needed — tests already pass `state` voluntarily. ### Fix 4 — Exact redirect URI matching Currently `coderd/httpapi/queryparams.go:233` uses prefix matching: ```go // CURRENT — prefix match if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) { ``` OAuth 2.1 requires **exact string matching**. Change to: ```go // AFTER — exact match (OAuth 2.1 §4.1.2.1) if v.Host != base.Host || v.Path != base.Path { ``` **File: `coderd/httpapi/queryparams.go` — `RedirectURL` method** Also update the error message from "must be a subset of" to "must exactly match". **Additionally**, store `redirect_uri` with the auth code and verify at the token endpoint (RFC 6749 §4.1.3): 1. **New migration** (same migration file or a new `000421`): Add `redirect_uri text` column to `oauth2_provider_app_codes` 2. **Update INSERT query** in `coderd/database/queries/oauth2.sql` to include `redirect_uri` 3. **`coderd/oauth2provider/authorize.go`**: Store `params.redirectURL.String()` when inserting the code 4. **`coderd/oauth2provider/tokens.go`**: After retrieving the code from DB, verify that `redirect_uri` from the token request matches the stored value exactly. Currently `tokens.go:103` calls `p.RedirectURL(vals, callbackURL, "redirect_uri")` for prefix validation only — it must compare against the stored redirect_uri from the code, not just the app's callback URL. <details> <summary>Why both exact match AND store+verify?</summary> Exact matching at the authorize endpoint prevents open redirectors (attacker can't use a sub-path). Storing and verifying at the token endpoint prevents code injection — an attacker who steals a code can't exchange it with a different redirect_uri than was originally authorized. This is required by RFC 6749 §4.1.3 and OAuth 2.1. </details> ### Fix 7 — `frame-ancestors` CSP on consent page The consent page can be iframed by a workspace app (same-site), which is the attack vector. Add a `Content-Security-Policy` header to prevent framing. **File: `site/site.go` — `RenderOAuthAllowPage` function (~line 731)** Before writing the response, add: ```go func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) { rw.Header().Set("Content-Type", "text/html; charset=utf-8") // Prevent the consent page from being framed to mitigate // clickjacking attacks (coder/security#121). rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") rw.Header().Set("X-Frame-Options", "DENY") ... ``` Both headers for defense-in-depth (CSP for modern browsers, X-Frame-Options for legacy). ### OAuth 2.1 — Mandatory PKCE Currently PKCE is checked only when `code_challenge` was provided during authorization (`tokens.go:258`): ```go // CURRENT — conditional check if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" { // verify PKCE } ``` OAuth 2.1 requires PKCE for ALL authorization code flows. Change to: **File: `coderd/oauth2provider/authorize.go`** — Add `"code_challenge"` to required params: ```go p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` **File: `coderd/oauth2provider/tokens.go:257-265`** — Make PKCE verification unconditional: ```go // AFTER — PKCE always required (OAuth 2.1) if req.CodeVerifier == "" { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" { // Code was issued without a challenge — should not happen // with the authorize endpoint enforcement, but defend in // depth. return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } ``` **File: `codersdk/oauth2.go`** — Remove `OAuth2ProviderResponseTypeToken` from the enum or reject it explicitly in the authorize handler. Currently it's defined at line 216 but the handler ignores `response_type` and always issues a code. We should either: - (a) Remove the `"token"` variant from the enum and reject it with `unsupported_response_type`, OR - (b) Add an explicit check in `ProcessAuthorize` that rejects `response_type=token` Option (b) is simpler and more backwards-compatible: ```go // In ProcessAuthorize, after extracting params: if params.responseType != codersdk.OAuth2ProviderResponseTypeCode { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedResponseType, "Only response_type=code is supported") return } ``` ### OAuth 2.1 — Bearer tokens in query strings `coderd/httpmw/apikey.go:743` accepts `access_token` from URL query parameters. OAuth 2.1 prohibits this. However, this may be used internally (e.g., workspace apps, DERP). Need to audit callers before removing. **Approach:** This is a larger change with potential breakage. Mark as a **separate follow-up issue** rather than including in this PR. Document the finding. ### OAuth 2.1 — Removed flows ✅ **Already compliant.** `tokens.go` only supports `authorization_code` and `refresh_token` grant types. The implicit grant (`response_type=token`) will be explicitly rejected per the PKCE section above. ### OAuth 2.1 — Refresh token rotation ✅ **Already compliant.** `tokens.go:442` deletes the old API key when a refresh token is used. ## Migration Plan All DB changes can go in a single new migration (or extend 000420 if the branch is rebased before merge). Columns to add: - `redirect_uri text` on `oauth2_provider_app_codes` The `state_hash` column is already added by migration 000420. ## Implementation Order 1. **Fix 7** — CSP headers on consent page (isolated, no deps) 2. ~~**Fix 2** — Require `state` parameter~~ (DROPPED — state stays optional) 3. **Fix 4** — Exact redirect URI matching + store/verify redirect_uri 4. **PKCE mandatory** — Require `code_challenge` + reject `response_type=token` 5. **Rollback** — Remove `"state"` from `RequiredNotEmpty` in `authorize.go` 6. **Tests** — Update/add tests for all changes 7. **`make gen`** after DB changes ## Out of Scope (separate PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting / quota on dynamic client registration </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_
1734 lines
56 KiB
Go
1734 lines
56 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/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,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
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
|
|
} 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, time.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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
// 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",
|
|
},
|
|
{
|
|
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,
|
|
AppSecretID: secret.ID,
|
|
APIKeyID: newKey.ID,
|
|
UserID: user.ID,
|
|
})
|
|
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)
|
|
|
|
cfg := &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,
|
|
AuthStyle: oauth2.AuthStyleInParams,
|
|
},
|
|
RedirectURL: test.app.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")
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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")
|
|
})
|
|
}
|
|
|
|
// TestOAuth2ClientConfiguration tests RFC 7592 client configuration management
|
|
func TestOAuth2ClientConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(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)
|
|
|
|
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
|