From 3b1d12076c2e7572fd280655f22166bdaf761b75 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 10 Sep 2026 17:40:02 -0700 Subject: [PATCH 1/4] fix!: authenticate confidential clients on the OAuth2 refresh grant refreshTokenGrant never read client_secret. Anyone holding a stolen refresh token and the app's public client_id could mint new tokens without the app's credentials, which RFC 6749 section 6 and OAuth 2.1 section 3.2.1 require confidential clients to present. The token to app ownership check landed earlier; this is the authentication half. The grant now calls authenticateClient before it parses the refresh token, so a caller without the secret answers 401 invalid_client regardless of whether the token is live, and learns nothing about it. Public clients are unchanged: they hold no secret, and the AppID binding plus single-use rotation tie their refresh to the client. A refused refresh mints nothing and leaves the token usable. Client authentication failures at the token endpoint are now logged at warn with the grant type and app id and nothing from the body. BREAKING CHANGE: a confidential client that refreshes without its client_secret, or with a wrong one, now receives 401 invalid_client instead of 200. Sending the secret, in the form or as HTTP Basic, succeeds. Public clients are unaffected. Two existing expectations flip because authentication now runs first: a refresh presenting another app's secret is invalid_client rather than invalid_grant, and refreshing after the presented secret was deleted is invalid_client rather than invalid_grant. Closes PLAT-506 (GHSA-whr4-xxrp-33vc) and the refresh half of SEC-348. --- coderd/oauth2_test.go | 35 +++- coderd/oauth2provider/tokens.go | 18 ++ coderd/oauth2provider/tokens_test.go | 199 ++++++++++++++++++++- docs/admin/integrations/oauth2-provider.md | 23 +++ 4 files changed, 268 insertions(+), 7 deletions(-) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index f7232c200a2..011abfed864 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -723,6 +723,9 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { //nolint:gocritic // OAauth2 app management requires owner permission. secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID) require.NoError(t, err) + //nolint:gocritic // OAauth2 app management requires owner permission. + noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID) + require.NoError(t, err) // 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. @@ -735,6 +738,8 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { // a different app's client_id is rejected outright, rather than // silently re-parenting the token to the presented client_id. refreshAsApp *codersdk.OAuth2ProviderApp + // refreshSecret, if set, is presented instead of apps.Default's. + refreshSecret string // If null, assume the token should be valid. defaultToken *string error string @@ -777,16 +782,32 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { 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 + // The token belongs to apps.Default, but apps.NoPort, correctly + // authenticated as itself, presents it. This must be rejected // outright: silently accepting it (and re-parenting the // token's app_id to whatever client_id is presented) would let // a stolen refresh token be laundered to a different app, // after which the issuing app could no longer revoke it. - name: "WrongApp", + name: "WrongAppOwnSecret", + app: apps.Default, + refreshAsApp: &apps.NoPort, + refreshSecret: noPortSecret.ClientSecretFull, + error: "The refresh token is invalid or expired", + }, + { + // The advisory's shape: any client_id, without that client's + // secret. Client authentication refuses before the token is + // examined, so the answer names the credentials, not the token. + name: "WrongAppOtherSecret", app: apps.Default, refreshAsApp: &apps.NoPort, - error: "The refresh token is invalid or expired", + error: "The client credentials are invalid", + }, + { + name: "WrongSecret", + app: apps.Default, + refreshSecret: secret.ClientSecretFull + "x", + error: "The client credentials are invalid", }, { name: "OK", @@ -844,9 +865,13 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { if test.refreshAsApp != nil { refreshAsApp = *test.refreshAsApp } + refreshSecret := secret.ClientSecretFull + if test.refreshSecret != "" { + refreshSecret = test.refreshSecret + } cfg := &oauth2.Config{ ClientID: refreshAsApp.ID.String(), - ClientSecret: secret.ClientSecretFull, + ClientSecret: refreshSecret, Endpoint: oauth2.Endpoint{ AuthURL: refreshAsApp.Endpoints.Authorization, DeviceAuthURL: refreshAsApp.Endpoints.DeviceAuth, diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 4cb736ddef3..10ed26f0380 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -380,6 +380,12 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L } if errors.Is(err, errBadSecret) { + // A refresh or exchange without the client's secret is the shape a + // replayed stolen token takes, so it is recorded. Nothing from the + // request body: the caller lacked the credential, and the token it + // did present should not land in a log. + logger.Warn(ctx, "oauth2 token request refused: client authentication failed", + slog.F("grant_type", req.GrantType), slog.F("app_id", app.ID)) writeTokenError(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid") return } @@ -675,6 +681,18 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog. } func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) { + // A confidential client proves possession of its secret before anything + // is learned about the token it presents (RFC 6749 §6, OAuth 2.1 §3.2.1). + // A public client has no secret; the dbToken.AppID check below and the + // single-use rotation are what bind its refresh to the client. The + // refreshed row keeps the secret the grant was obtained under, so the + // matched secret is not needed here. + if !app.IsPublic() { + if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil { + return codersdk.OAuth2TokenResponse{}, err + } + } + // Validate the token. token, err := ParseFormattedSecret(req.RefreshToken) if err != nil { diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index a26e34e41c1..2aac17cd4db 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -643,8 +643,10 @@ func TestOAuth2RefreshRevokedToken(t *testing.T) { require.NoError(t, client.DeleteOAuth2ProviderAppSecret(ctx, app.ID, app.SecretID)) + // The cascade removed the token row too, but the deleted secret fails + // client authentication first, so the answer names the credentials. status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken)) - requireTokenGrantError(t, status, body) + requireTokenClientError(t, status, body) }) // The third revocation path FR12 names. It never reaches the grant: the @@ -671,6 +673,174 @@ func TestOAuth2RefreshRevokedToken(t *testing.T) { // every table in it. The refresh reads nothing from api_keys before the // returning-row delete, so the missing key surfaces there as invalid_grant; // a read of the key ahead of the delete answered HTTP 500 here. +// A confidential client authenticates on refresh as it does on the code +// exchange (RFC 6749 §6, OAuth 2.1 §3.2.1). A refusal mints nothing and +// leaves the token usable, so the client recovers by presenting its secret. +func TestOAuth2RefreshClientAuthentication(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + + // Posts the form by hand so the request can carry HTTP Basic credentials + // and the response headers can be inspected. + postForm := func(ctx context.Context, t *testing.T, form url.Values, opts ...func(*http.Request)) (status int, header http.Header, body string) { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/tokens", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, opt := range opts { + opt(req) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, resp.Header, string(raw) + } + + // The token must survive the refusal and redeem on the next, correct + // attempt: nothing was consumed. + requireRefused := func(ctx context.Context, t *testing.T, app appWithSecret, refreshToken string, status int, body string) { + t.Helper() + + requireTokenClientError(t, status, body) + _ = tokenRow(ctx, t, db, refreshToken) + status, body = postTokenRequest(ctx, t, client, refreshForm(app, refreshToken)) + requireTokenResponse(t, status, body) + } + + t.Run("MissingSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Del("client_secret") + status, header, body := postForm(ctx, t, form) + require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate")) + requireRefused(ctx, t, app, refreshToken, status, body) + }) + + t.Run("WrongSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Set("client_secret", app.ClientSecret+"x") + status, body := postTokenRequest(ctx, t, client, form) + requireRefused(ctx, t, app, refreshToken, status, body) + }) + + // Right hash, wrong app: the secret is valid, but not for the client_id + // it is presented under. This is the fourth step of authenticateClient + // and the one a copy of the check would be most likely to lose. + t.Run("SecretOfAnotherApp", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + other := seedAppWithSecret(t, db, sql.NullString{}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Set("client_secret", other.ClientSecret) + status, body := postTokenRequest(ctx, t, client, form) + requireRefused(ctx, t, app, refreshToken, status, body) + }) + + // Authentication runs before the token is examined, so a caller without + // the secret cannot learn whether a token is live: 401 either way, never + // the invalid_grant an authenticated caller gets for a dead token. + t.Run("WrongSecretUnknownToken", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + + form := refreshForm(app, "coder_notreal_notreal") + form.Set("client_secret", app.ClientSecret+"x") + status, body := postTokenRequest(ctx, t, client, form) + requireTokenClientError(t, status, body) + }) + + t.Run("BasicAuth", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Del("client_id") + form.Del("client_secret") + status, _, body := postForm(ctx, t, form, func(r *http.Request) { + r.SetBasicAuth(app.ID.String(), app.ClientSecret) + }) + requireTokenResponse(t, status, body) + }) + + t.Run("BasicAndBodyConflict", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Set("client_secret", app.ClientSecret+"x") + status, _, body := postForm(ctx, t, form, func(r *http.Request) { + r.SetBasicAuth(app.ID.String(), app.ClientSecret) + }) + desc := requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidRequest) + require.Contains(t, desc, "Conflicting client credentials") + _ = tokenRow(ctx, t, db, refreshToken) + }) + + // A public client has no secret to check; the token's app binding and + // single-use rotation are what tie its refresh to the client. + t.Run("PublicClientNoSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedPublicApp(t, db) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Del("client_secret") + status, body := postTokenRequest(ctx, t, client, form) + token := requireTokenResponse(t, status, body) + require.False(t, tokenRow(ctx, t, db, token.RefreshToken).AppSecretID.Valid, + "a public client's refreshed token must reference no secret") + }) + + // No secret to compare against, so one sent anyway is ignored rather than + // rejected, as on the code exchange. + t.Run("PublicClientIgnoresSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedPublicApp(t, db) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + form := refreshForm(app, refreshToken) + form.Set("client_secret", "coder_unnecessary_secret") + status, body := postTokenRequest(ctx, t, client, form) + requireTokenResponse(t, status, body) + }) +} + func TestOAuth2RefreshKeyMissing(t *testing.T) { t.Parallel() @@ -812,6 +982,19 @@ func seedAppWithSecret(t *testing.T, db database.Store, allowlist sql.NullString } } +// seedPublicApp seeds a public client, which holds no secret. The zero +// SecretID tells seedRefreshToken to leave app_secret_id NULL. +func seedPublicApp(t *testing.T, db database.Store) appWithSecret { + t.Helper() + + app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ + Name: testutil.GetRandomName(t), + CallbackURL: appCallbackURL, + ClientType: database.OAuth2ProviderAppClientTypePublic, + }) + return appWithSecret{OAuth2ProviderApp: app} +} + // setAppAllowlist rewrites an app's registered scopes, leaving every other // column as seeded. func setAppAllowlist(ctx context.Context, t *testing.T, db database.Store, app appWithSecret, allowlist sql.NullString) { @@ -864,7 +1047,8 @@ func seedRefreshToken(ctx context.Context, t *testing.T, db database.Store, app HashPrefix: []byte(secret.Prefix), RefreshHash: secret.Hashed, AppID: app.ID, - AppSecretID: uuid.NullUUID{UUID: app.SecretID, Valid: true}, + // A public client's token references no secret. + AppSecretID: uuid.NullUUID{UUID: app.SecretID, Valid: app.SecretID != uuid.Nil}, APIKeyID: key.ID, UserID: userID, Scope: scope, @@ -1028,6 +1212,17 @@ func requireTokenGrantError(t *testing.T, status int, body string) string { return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidGrant) } +// requireTokenClientError asserts the RFC 6749 §5.2 invalid_client response: +// 401 rather than the 400 every other token error carries. +func requireTokenClientError(t *testing.T, status int, body string) { + t.Helper() + + require.Equal(t, http.StatusUnauthorized, status, body) + var oauthErr codersdk.OAuth2Error + require.NoError(t, json.Unmarshal([]byte(body), &oauthErr)) + require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error) +} + func requireTokenScopeError(t *testing.T, status int, body string) string { t.Helper() return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidScope) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 7e3f0a60d16..3a10b3f9d12 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -535,6 +535,19 @@ confers `organization_member:read`, which a workspace build needs and which can name will fail to create a workspace. Refresh without a `scope` to return to the composite. +### "invalid_client" for a refresh + +`POST /oauth2/tokens` with `grant_type=refresh_token` answers HTTP 401 with +`error=invalid_client` when a confidential client does not authenticate. The +usual causes are a `client_secret` that was omitted, a secret that belongs to a +different client, or a secret that has since been deleted or rotated. Present +the client's current secret, as HTTP Basic or as a form parameter, following +[Refresh Tokens](#refresh-tokens). The refresh token is not consumed by the +refusal, so the retry needs no new authorization. If the secret was deleted, +the tokens issued under it were revoked with it, and the client must authorize +again. Public clients have no secret and never receive this error for omitting +one. + ### "unsupported_response_type" returned to your callback Coder supports the authorization code flow only, so `response_type=code` is the single accepted value. @@ -633,6 +646,9 @@ Public clients (`token_endpoint_auth_method: none`) additionally cannot register custom URI schemes for native apps (`myapp://`) are permitted, and public clients additionally cannot use `mailto:`, `tel:`, or `sms:` - **Rotate secrets**: Periodically rotate client secrets using the management API +- **Refresh tokens are not self-sufficient**: a confidential client must present + its `client_secret` on every refresh, so a leaked refresh token alone cannot + mint new access tokens - **No CORS on the authorization endpoint**: `/oauth2/authorize` is reached only by browser navigation and sends no CORS headers, as OAuth 2.1 requires. The token, registration, revocation, and metadata endpoints do allow @@ -653,6 +669,13 @@ enforced, and such a request answers HTTP 400 with `error=invalid_scope`. The refresh token is not consumed, so a client that drops the parameter or asks for less recovers without re-authorizing. +Earlier versions did not check `client_secret` on a refresh, so a confidential +client could refresh with a wrong secret or none. The refresh grant now +authenticates confidential clients exactly as the authorization code grant +does, and a refresh without a valid secret answers HTTP 401 with +`error=invalid_client`. The refresh token is not consumed, so a client that +adds its secret recovers without re-authorizing. Public clients are unaffected. + ## Standards Compliance This implementation follows established OAuth2 standards including From 17f5833a7f652f416ed2739eef43918e9c146302 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 10 Sep 2026 21:13:23 -0700 Subject: [PATCH 2/4] test(coderd/oauth2provider): restore the comment on TestOAuth2RefreshKeyMissing Inserting TestOAuth2RefreshClientAuthentication split the key-missing test from its doc comment, so the new test read as if it disabled FK constraints. Move the comment back and shorten both. --- coderd/oauth2provider/tokens_test.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index 2aac17cd4db..0e248440e3a 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -667,15 +667,8 @@ func TestOAuth2RefreshRevokedToken(t *testing.T) { }) } -// A token row whose api_key_id names no key. The FK cascade makes that -// unreachable through any API, so the constraints come off to seed it, and -// this test takes a database of its own because disabling them applies to -// every table in it. The refresh reads nothing from api_keys before the -// returning-row delete, so the missing key surfaces there as invalid_grant; -// a read of the key ahead of the delete answered HTTP 500 here. -// A confidential client authenticates on refresh as it does on the code -// exchange (RFC 6749 §6, OAuth 2.1 §3.2.1). A refusal mints nothing and -// leaves the token usable, so the client recovers by presenting its secret. +// A confidential client authenticates on refresh as on the code exchange +// (RFC 6749 §6). A refusal leaves the token usable for a correct retry. func TestOAuth2RefreshClientAuthentication(t *testing.T) { t.Parallel() @@ -841,6 +834,10 @@ func TestOAuth2RefreshClientAuthentication(t *testing.T) { }) } +// A token row whose api_key_id names no key is unreachable through the API +// because of the FK cascade, so the constraints come off to seed it. That +// applies to every table, hence the private database. The missing key must +// surface as invalid_grant, not HTTP 500. func TestOAuth2RefreshKeyMissing(t *testing.T) { t.Parallel() From d01505ccf514cfb18d50cd3b38d1020b5137af3b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 09:45:24 -0700 Subject: [PATCH 3/4] chore(coderd): address review of the refresh client auth comments and test helpers - Reword the failed-client-authentication log comment to cover missing, wrong, and foreign secrets. - Name the app-binding check in the SecretOfAnotherApp test comment instead of its position in authenticateClient. - Extend tryTokenRequest with request options and the response header so postForm no longer copies the request construction. - Spell OAuth2 correctly in the newly added nolint rationale. --- coderd/oauth2_test.go | 2 +- coderd/oauth2provider/tokens.go | 8 +++--- coderd/oauth2provider/tokens_test.go | 42 +++++++++++++--------------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 011abfed864..5f6430e4a49 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -723,7 +723,7 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { //nolint:gocritic // OAauth2 app management requires owner permission. secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID) require.NoError(t, err) - //nolint:gocritic // OAauth2 app management requires owner permission. + //nolint:gocritic // OAuth2 app management requires owner permission. noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID) require.NoError(t, err) diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 192f0436470..45f59c304da 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -380,10 +380,10 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L } if errors.Is(err, errBadSecret) { - // A refresh or exchange without the client's secret is the shape a - // replayed stolen token takes, so it is recorded. Nothing from the - // request body: the caller lacked the credential, and the token it - // did present should not land in a log. + // A missing, wrong, or foreign secret is what a replayed stolen token + // looks like, so the refusal is logged. The request body stays out of + // the log: the caller never proved its credential, and the token it + // sent should not be recorded. logger.Warn(ctx, "oauth2 token request refused: client authentication failed", slog.F("grant_type", req.GrantType), slog.F("app_id", app.ID)) writeTokenError(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid") diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index 0e248440e3a..be2ebc60eaa 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -551,7 +551,7 @@ func requireExactlyOneAccepted(ctx context.Context, t *testing.T, client *coders send := func() attempt { start.Done() start.Wait() - status, body, err := tryTokenRequest(ctx, t, client, form) + status, _, body, err := tryTokenRequest(ctx, t, client, form) return attempt{status: status, body: body, err: err} } @@ -679,23 +679,14 @@ func TestOAuth2RefreshClientAuthentication(t *testing.T) { }) owner := coderdtest.CreateFirstUser(t, client) - // Posts the form by hand so the request can carry HTTP Basic credentials - // and the response headers can be inspected. + // Keeps the response headers and lets the request carry HTTP Basic + // credentials. postForm := func(ctx context.Context, t *testing.T, form url.Values, opts ...func(*http.Request)) (status int, header http.Header, body string) { t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/tokens", strings.NewReader(form.Encode())) + status, header, body, err := tryTokenRequest(ctx, t, client, form, opts...) require.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - for _, opt := range opts { - opt(req) - } - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - require.NoError(t, err) - return resp.StatusCode, resp.Header, string(raw) + return status, header, body } // The token must survive the refusal and redeem on the next, correct @@ -737,8 +728,9 @@ func TestOAuth2RefreshClientAuthentication(t *testing.T) { }) // Right hash, wrong app: the secret is valid, but not for the client_id - // it is presented under. This is the fourth step of authenticateClient - // and the one a copy of the check would be most likely to lose. + // it is presented under. This exercises the secret's app-binding check + // in authenticateClient, the one a copy of the check would be most + // likely to lose. t.Run("SecretOfAnotherApp", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -1150,7 +1142,7 @@ func exchangeCode(ctx context.Context, t *testing.T, client *codersdk.Client, ap func postTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) (int, string) { t.Helper() - status, body, err := tryTokenRequest(ctx, t, client, form) + status, _, body, err := tryTokenRequest(ctx, t, client, form) require.NoError(t, err) return status, body } @@ -1158,27 +1150,31 @@ func postTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client // tryTokenRequest returns the request error instead of asserting on it, so a // caller on a spawned goroutine can carry it back to the test goroutine. // require there runs runtime.Goexit, which skips whatever the goroutine still -// owed its parent. -func tryTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) (int, string, error) { +// owed its parent. The opts run on the built request, for callers that need +// to set HTTP Basic credentials or other headers. +func tryTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, opts ...func(*http.Request)) (int, http.Header, string, error) { t.Helper() req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/tokens", strings.NewReader(form.Encode())) if err != nil { - return 0, "", xerrors.Errorf("build token request: %w", err) + return 0, nil, "", xerrors.Errorf("build token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, opt := range opts { + opt(req) + } resp, err := http.DefaultClient.Do(req) if err != nil { - return 0, "", xerrors.Errorf("post token request: %w", err) + return 0, nil, "", xerrors.Errorf("post token request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return 0, "", xerrors.Errorf("read token response: %w", err) + return 0, nil, "", xerrors.Errorf("read token response: %w", err) } - return resp.StatusCode, string(body), nil + return resp.StatusCode, resp.Header, string(body), nil } func requireTokenResponse(t *testing.T, status int, body string) codersdk.OAuth2TokenResponse { From 0cdf1b813764907fd8087173dac1732c03f6c4a7 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 09:49:15 -0700 Subject: [PATCH 4/4] chore(coderd/oauth2provider): drop the client auth comment in refreshTokenGrant --- coderd/oauth2provider/tokens.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 45f59c304da..c1e7c772a81 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -681,12 +681,6 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog. } func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) { - // A confidential client proves possession of its secret before anything - // is learned about the token it presents (RFC 6749 §6, OAuth 2.1 §3.2.1). - // A public client has no secret; the dbToken.AppID check below and the - // single-use rotation are what bind its refresh to the client. The - // refreshed row keeps the secret the grant was obtained under, so the - // matched secret is not needed here. if !app.IsPublic() { if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil { return codersdk.OAuth2TokenResponse{}, err