From 4a340490f2c3981820797a8c767148767dc7bd60 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 10 Sep 2026 17:42:55 -0700 Subject: [PATCH 1/2] fix!: authenticate confidential clients at OAuth2 token revocation RevokeToken read client_secret into its request struct and never looked at it again. Anyone holding a token value and the app's public client_id could revoke that token, a denial of service against one integration. RFC 7009 section 2.1 has the server validate the client's credentials first and only then check that the token was issued to it. The handler now calls authenticateClient for confidential clients before the token is classified, answering 401 invalid_client on failure so an unauthenticated caller learns nothing about the token. The ownership checks in both revoke branches are unchanged, so an authenticated client still receives the RFC 7009 section 2.2 silent 200 for a token it does not own. Public clients are unchanged. The revocation parser did not accept HTTP Basic, the method RFC 6749 section 2.3.1 requires servers to support and the one Coder's own MCP client uses when it revokes. It now folds Basic credentials in through mergeBasicClientAuth exactly as the token endpoint does, including the 400 for credentials that conflict between header and body. codersdk.RevokeOAuth2Token gains a clientSecret parameter; public clients pass an empty string. Its comment claimed the middleware authenticated the client, which was the belief behind this defect. BREAKING CHANGE: a confidential client revoking without its client_secret, or with a wrong one, now receives 401 invalid_client and nothing is revoked. Sending the secret, in the form or as HTTP Basic, succeeds. Go callers of codersdk.RevokeOAuth2Token must pass the new clientSecret argument. The RFC 7009 endpoint is documented for the first time. Closes PLAT-507 (CDR-59) and the revocation half of SEC-348. --- coderd/oauth2_test.go | 195 ++++++++++++++++++++- coderd/oauth2provider/nostore_test.go | 12 +- coderd/oauth2provider/revoke.go | 37 ++++ codersdk/oauth2.go | 10 +- docs/admin/integrations/oauth2-provider.md | 61 +++++-- 5 files changed, 286 insertions(+), 29 deletions(-) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 011abfed864b0..733d1d36c5c21 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -1100,6 +1100,9 @@ func TestOAuth2ProviderRevokeCrossApp(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) tests := []struct { name string @@ -1154,19 +1157,198 @@ func TestOAuth2ProviderRevokeCrossApp(t *testing.T) { // RFC 7009: revoking under a different app than the one that // issued the token must not reveal whether it exists (no - // error), and must not actually end the session. - err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, tokenUnderTest) + // error), and must not actually end the session. The other app + // authenticates as itself, so this reaches the ownership check. + err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, noPortSecret.ClientSecretFull, tokenUnderTest) require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009") require.True(t, sessionWorks(), "cross-app revoke must not actually end the session") // Revoking under the correct, issuing app must actually work. - err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, tokenUnderTest) + err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, tokenUnderTest) require.NoError(t, err) require.False(t, sessionWorks(), "same-app revoke must end the session") }) } } +// A confidential client authenticates at revocation as it does at the token +// endpoint (RFC 7009 §2.1). Each case gets its own session because a +// successful revocation ends it. +func TestOAuth2ProviderRevokeClientAuthentication(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, ownerClient) + ctx := testutil.Context(t, testutil.WaitLong) + apps := generateApps(ctx, t, ownerClient, "revoke-client-auth") + + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID) + require.NoError(t, err) + //nolint:gocritic // OAauth2 app management requires owner permission. + noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID) + require.NoError(t, err) + + // Mints a session for apps.Default and returns the refresh token with a + // probe reporting whether the session's access token still authenticates. + newSession := func(ctx context.Context, t *testing.T) (*codersdk.Client, string, func() bool) { + t.Helper() + + userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + cfg := &oauth2.Config{ + ClientID: apps.Default.ID.String(), + ClientSecret: secret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: apps.Default.Endpoints.Authorization, + TokenURL: apps.Default.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: apps.Default.CallbackURL, + Scopes: []string{}, + } + code, verifier, err := authorizationFlow(ctx, userClient, cfg) + require.NoError(t, err) + token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier)) + require.NoError(t, err) + + works := func() bool { + checkClient := codersdk.New(userClient.URL) + checkClient.SetSessionToken(token.AccessToken) + _, err := checkClient.User(ctx, codersdk.Me) + return err == nil + } + require.True(t, works(), "session should be valid before any revoke attempt") + return userClient, token.RefreshToken, works + } + + // Posts the revocation form by hand so the request can carry HTTP Basic + // credentials, which the SDK method does not send. A 200 has no body + // (RFC 7009), so the decoded error is zero on success. + postRevoke := func(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, opts ...codersdk.RequestOption) (status int, header http.Header, oauthErr codersdk.OAuth2Error) { + t.Helper() + + opts = append(opts, func(r *http.Request) { + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + }) + resp, err := client.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), opts...) + require.NoError(t, err) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + require.NoError(t, json.NewDecoder(resp.Body).Decode(&oauthErr)) + } + return resp.StatusCode, resp.Header, oauthErr + } + + requireInvalidClient := func(t *testing.T, err error, works func() bool) { + t.Helper() + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Error(), string(codersdk.OAuth2ErrorCodeInvalidClient)) + require.True(t, works(), "a refused revocation must not end the session") + } + + t.Run("MissingSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", refreshToken) + requireInvalidClient(t, err, works) + }) + + t.Run("WrongSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull+"x", refreshToken) + requireInvalidClient(t, err, works) + }) + + // Right hash, wrong app: the secret is valid, but not for the client_id + // it is presented under. + t.Run("SecretOfAnotherApp", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, noPortSecret.ClientSecretFull, refreshToken) + requireInvalidClient(t, err, works) + }) + + // Authentication runs before the token is classified, so a caller without + // the secret learns nothing from the token it presents: 401, not the 200 + // an unknown token would otherwise receive under RFC 7009 §2.2. + t.Run("MissingSecretUnknownToken", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, _, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", "coder_notreal_notreal") + requireInvalidClient(t, err, works) + }) + + t.Run("CorrectSecretOwnToken", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, refreshToken) + require.NoError(t, err) + require.False(t, works(), "an authenticated revocation must end the session") + }) + + t.Run("BasicAuth", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + status, _, _ := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull) + }) + require.Equal(t, http.StatusOK, status) + require.False(t, works(), "a Basic-authenticated revocation must end the session") + }) + + t.Run("BasicAuthWrongSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + status, header, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull+"x") + }) + require.Equal(t, http.StatusUnauthorized, status) + require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate")) + require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error) + require.True(t, works(), "a refused revocation must not end the session") + }) + + t.Run("BasicAndBodyConflict", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + form.Set("client_id", apps.Default.ID.String()) + form.Set("client_secret", secret.ClientSecretFull+"x") + status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull) + }) + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, codersdk.OAuth2ErrorCodeInvalidRequest, oauthErr.Error) + require.Contains(t, oauthErr.ErrorDescription, "Conflicting client credentials") + require.True(t, works(), "a refused revocation must not end the session") + }) +} + func TestOAuth2ProviderPublicClientTokenLifecycle(t *testing.T) { t.Parallel() @@ -1193,11 +1375,12 @@ func TestOAuth2ProviderPublicClientTokenLifecycle(t *testing.T) { session := refreshedPublicClientSession(ctx, t) tokenUnderTest := test.tokenFor(session.token) - err := session.userClient.RevokeOAuth2Token(ctx, session.otherAppID, tokenUnderTest) + // Public clients have no secret to present. + err := session.userClient.RevokeOAuth2Token(ctx, session.otherAppID, "", tokenUnderTest) require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009") require.True(t, session.works(), "cross-app revoke must not end the session") - err = session.userClient.RevokeOAuth2Token(ctx, session.appID, tokenUnderTest) + err = session.userClient.RevokeOAuth2Token(ctx, session.appID, "", tokenUnderTest) require.NoError(t, err) require.False(t, session.works(), "public client must be able to revoke its own token") }) @@ -2253,7 +2436,7 @@ func TestOAuth2CoderClient(t *testing.T) { // 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) + err = usingOauth.RevokeOAuth2Token(ctx, app.ID, appsecret.ClientSecretFull, token.RefreshToken) require.NoError(t, err) _, err = usingOauth.User(ctx, codersdk.Me) diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go index ecd1b38ee8c1a..43982b9ee93f3 100644 --- a/coderd/oauth2provider/nostore_test.go +++ b/coderd/oauth2provider/nostore_test.go @@ -153,9 +153,19 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { form.Set("token", token.RefreshToken) form.Set("client_id", app.ID.String()) - // RFC 7009 success is a bare WriteHeader(200), never httpapi.Write. + // A confidential client authenticates at revocation (RFC 7009 §2.1), + // and the 401 is a response this route did not produce before. resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType) defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + requireNoStore(t, resp) + require.Equal(t, `Basic realm="coder"`, resp.Header.Get("WWW-Authenticate")) + + form.Set("client_secret", secret) + + // RFC 7009 success is a bare WriteHeader(200), never httpapi.Write. + resp = doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType) + defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) requireNoStore(t, resp) }) diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 7734434496779..5c6e9053fa5be 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -40,6 +40,15 @@ func extractRevocationRequest(r *http.Request) (codersdk.OAuth2TokenRevocationRe ClientSecret: r.Form.Get("client_secret"), } + // RFC 7009 §2.1 defers to RFC 6749 §2.3 for client authentication, so a + // confidential client may present its credentials as HTTP Basic here as + // it does at the token endpoint. + var err error + req.ClientID, req.ClientSecret, err = mergeBasicClientAuth(r, req.ClientID, req.ClientSecret) + if err != nil { + return codersdk.OAuth2TokenRevocationRequest{}, err + } + // RFC 7009 requires 'token' parameter. if req.Token == "" { return codersdk.OAuth2TokenRevocationRequest{}, xerrors.New("missing token parameter") @@ -67,11 +76,39 @@ func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc { } req, err := extractRevocationRequest(r) + if errors.Is(err, errConflictingClientAuth) { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body") + return + } if err != nil { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error()) return } + // RFC 7009 §2.1: "The authorization server first validates the client + // credentials (in case of a confidential client) and then verifies + // whether the token was issued to the client making the revocation + // request." A public client has no credentials to validate; the AppID + // checks in both revoke branches are its binding. Authenticating before + // the token is examined also keeps an unauthenticated caller from + // learning anything about the token it presents. + if !app.IsPublic() { + if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil { + if errors.Is(err, errBadSecret) { + logger.Warn(ctx, "oauth2 revocation refused: client authentication failed", + slog.F("app_id", app.ID)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid") + return + } + logger.Error(ctx, "token revocation failed with internal server error", + slog.Error(err), + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Internal server error") + return + } + } + // Determine if this is a refresh token (starts with "coder_") or API key // APIKeys do not have the SecretIdentifier prefix. const coderPrefix = SecretIdentifier + "_" diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index c74af6827ccdd..a854ca34c570d 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -449,12 +449,16 @@ type OAuth2TokenRevocationRequest struct { ClientSecret string `json:"client_secret,omitempty"` } -// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation. -func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error { +// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token +// revocation. A confidential client must present its clientSecret; a public +// client passes an empty string and is bound to the token by client_id alone. +func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, clientSecret, token string) error { form := url.Values{} form.Set("token", token) - // Client authentication is handled via the client_id in the app middleware form.Set("client_id", clientID.String()) + if clientSecret != "" { + form.Set("client_secret", clientSecret) + } res, err := c.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), func(r *http.Request) { r.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 3a10b3f9d1234..c2f511f009328 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -340,6 +340,28 @@ curl -X POST \ "$CODER_URL/oauth2/tokens" ``` +### Revoke a Token + +Revoke one refresh token or access token through the +[RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) endpoint that +`revocation_endpoint` advertises. A confidential client authenticates exactly +as it does at the token endpoint, with HTTP Basic or form credentials: + +```sh +curl -X POST \ + -u "$CLIENT_ID:$CLIENT_SECRET" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$REFRESH_TOKEN" \ + "$CODER_URL/oauth2/revoke" +``` + +A public client sends `client_id` alone. Revoking a refresh token also ends the +access token issued with it. The endpoint answers HTTP 200 whether or not the +token existed or belonged to the client, as RFC 7009 requires, so a client +cannot use it to probe for other clients' tokens. A confidential client that +fails to authenticate receives HTTP 401 with `error=invalid_client` and nothing +is revoked. + ### Revoke Access Revoke all tokens for an application: @@ -535,18 +557,18 @@ 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 +### "invalid_client" for a refresh or a revocation -`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. +`POST /oauth2/tokens` with `grant_type=refresh_token` and `POST /oauth2/revoke` +answer 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 and nothing is revoked 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 @@ -647,8 +669,8 @@ Public clients (`token_endpoint_auth_method: none`) additionally cannot register 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 + its `client_secret` to refresh or revoke, so a leaked token alone cannot mint + new access tokens or end another client's session - **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 @@ -669,12 +691,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. +Earlier versions did not check `client_secret` on a refresh or at the RFC 7009 +revocation endpoint, so a confidential client could refresh or revoke with a +wrong secret or none. Both now authenticate confidential clients exactly as the +authorization code grant does, and a request without a valid secret answers +HTTP 401 with `error=invalid_client`. The refresh token is not consumed and +nothing is revoked, so a client that adds its secret recovers without +re-authorizing. Public clients are unaffected. ## Standards Compliance From 21609f36b8db7dfbbc8bff0c202b71f36cf42ded Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 11:05:47 -0700 Subject: [PATCH 2/2] chore: address review of the revoke client auth log fields, docs, and test comments Log client_id and app_name on the authentication refusal to match the other lines in RevokeToken. Say in the docs that revocation authenticates as a refresh does and show where the form-credential example lives. Trim two test comments to the behavior under test. --- coderd/oauth2_test.go | 3 +++ coderd/oauth2provider/nostore_test.go | 2 +- coderd/oauth2provider/revoke.go | 3 ++- docs/admin/integrations/oauth2-provider.md | 6 ++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 17983822bec0f..0bb785ec58173 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -1281,6 +1281,9 @@ func TestOAuth2ProviderRevokeClientAuthentication(t *testing.T) { // Authentication runs before the token is classified, so a caller without // the secret learns nothing from the token it presents: 401, not the 200 // an unknown token would otherwise receive under RFC 7009 §2.2. + // The fake token is not tied to the session, so only the status check + // carries this case; the session probe is the shared helper's + // post-condition. t.Run("MissingSecretUnknownToken", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go index 43982b9ee93f3..7b03c7180b31b 100644 --- a/coderd/oauth2provider/nostore_test.go +++ b/coderd/oauth2provider/nostore_test.go @@ -154,7 +154,7 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { form.Set("client_id", app.ID.String()) // A confidential client authenticates at revocation (RFC 7009 §2.1), - // and the 401 is a response this route did not produce before. + // so the 401 refusal must be no-store as well. resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType) defer resp.Body.Close() require.Equal(t, http.StatusUnauthorized, resp.StatusCode) diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 5c6e9053fa5be..fb6a86d22b59e 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -96,7 +96,8 @@ func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc { if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil { if errors.Is(err, errBadSecret) { logger.Warn(ctx, "oauth2 revocation refused: client authentication failed", - slog.F("app_id", app.ID)) + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid") return } diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index c2f511f009328..3befaf7018d48 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -344,8 +344,10 @@ curl -X POST \ Revoke one refresh token or access token through the [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) endpoint that -`revocation_endpoint` advertises. A confidential client authenticates exactly -as it does at the token endpoint, with HTTP Basic or form credentials: +`revocation_endpoint` advertises. A confidential client authenticates as it +does on a refresh, with HTTP Basic as below or with `client_id` and +`client_secret` form fields as in the refresh examples above. An omitted or +wrong secret answers HTTP 401 with `error=invalid_client`: ```sh curl -X POST \