diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 5f6430e4a4901..0bb785ec58173 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,201 @@ 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. + // The fake token is not tied to the session, so only the status check + // carries this case; the session probe is the shared helper's + // post-condition. + t.Run("MissingSecretUnknownToken", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, _, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", "coder_notreal_notreal") + requireInvalidClient(t, err, works) + }) + + t.Run("CorrectSecretOwnToken", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, refreshToken) + require.NoError(t, err) + require.False(t, works(), "an authenticated revocation must end the session") + }) + + t.Run("BasicAuth", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + status, _, _ := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull) + }) + require.Equal(t, http.StatusOK, status) + require.False(t, works(), "a Basic-authenticated revocation must end the session") + }) + + t.Run("BasicAuthWrongSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + status, header, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull+"x") + }) + require.Equal(t, http.StatusUnauthorized, status) + require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate")) + require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error) + require.True(t, works(), "a refused revocation must not end the session") + }) + + t.Run("BasicAndBodyConflict", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + userClient, refreshToken, works := newSession(ctx, t) + + form := url.Values{} + form.Set("token", refreshToken) + form.Set("client_id", apps.Default.ID.String()) + form.Set("client_secret", secret.ClientSecretFull+"x") + status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) { + r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull) + }) + 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 +1378,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 +2439,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..7b03c7180b31b 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), + // 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) + 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..fb6a86d22b59e 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,40 @@ 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("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 + } + 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 b063757b7a49e..a233306f09b3d 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -340,6 +340,30 @@ 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 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 \ + -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 +559,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 +671,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 +693,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. Coder now enforces the `scope` an application declared for itself when it self-registered through [Dynamic Client Registration](#dynamic-client-registration). This affects only deployments that enabled Dynamic Client Registration and have an application that self-registered with a `scope`.