Skip to content

feat: revoke upstream OAuth2 grant on account delete via ?revoke=true#595

Open
bgeihsgt wants to merge 1 commit into
postalsys:masterfrom
bgeihsgt:feat/revoke-oauth-on-account-delete
Open

feat: revoke upstream OAuth2 grant on account delete via ?revoke=true#595
bgeihsgt wants to merge 1 commit into
postalsys:masterfrom
bgeihsgt:feat/revoke-oauth-on-account-delete

Conversation

@bgeihsgt
Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in revoke query parameter to DELETE /v1/account/{account}. When revoke=true, EmailEngine attempts to revoke the upstream OAuth2 grant at the provider before tearing down the account's local state, by calling the existing revokeToken method on the OAuth2 client returned by oauth2Apps.getClient(provider).

Default behavior is unchanged (revoke=false).

Motivation

Right now, DELETE /v1/account/{account} clears EmailEngine-side state but leaves the user's OAuth grant active at the provider. From the end user's perspective, "disconnecting" their mailbox in a downstream application leaves access still listed at myaccount.google.com/permissions, and reconnecting re-uses the existing grant rather than prompting fresh consent — which is confusing and a soft security wart.

The pieces to fix this already exist inside EmailEngine: GmailOauth.revokeToken (lib/oauth/gmail.js:748) is implemented, well-tested in test/oauth-integration-test.js, and currently wired to one call site only (the OAuth callback path that handles insufficient granular consent scopes). This PR wires the same primitive into the account-delete path behind an explicit opt-in flag.

The alternative is to enable enableOAuthTokensApi so downstream apps can pull the access token themselves and revoke it client-side. That works, but it exposes raw OAuth tokens over the API surface to do something EmailEngine could just do itself, and many operators (myself included) don't want to flip that setting on. A server-side opt-in keeps the token inside EmailEngine.

Behavior

  • revoke=false (default): unchanged — DELETE works exactly as before.
  • revoke=true + OAuth2 provider with revokeToken (Gmail): the access token from accountData.oauth2.accessToken is POSTed to the provider's revoke endpoint via the existing oauthClient.revokeToken(...). Per Google's docs, revoking either the access or refresh token invalidates the entire grant.
  • revoke=true + OAuth2 provider without revokeToken (Outlook, MailRu): no-op — the typeof oauthClient.revokeToken === 'function' guard skips silently.
  • revoke=true + non-OAuth account (IMAP/SMTP password): no-op — the accountData.oauth2.provider guard skips silently.
  • Any error during the revoke step (network failure, non-OK from provider, OAuth app not configured) is caught, logged via this.logger.warn, and does not block deletion. The existing best-effort semantics inside revokeToken itself are preserved.

Implementation

Two files, ~26 lines:

  • lib/account.js delete(opts) — accepts an optional { revoke } flag; when set and the account is OAuth2-backed, resolves the OAuth client via oauth2Apps.getClient and calls revokeToken inside a try/catch.
  • lib/api-routes/account-routes.js DELETE handler — adds the revoke query param with the same Joi.boolean().truthy('Y','true','1').falsy('N','false',0).default(false) pattern used elsewhere in this file (e.g., reconnect, sync, flush), and passes it through to accountObject.delete({ revoke }).

Tests

The underlying revokeToken is already covered by three tests in test/oauth-integration-test.js:

  • sends a correct revocation request,
  • does not throw on HTTP error,
  • does not throw on network error.

This PR's wiring is straightforward best-effort plumbing on top of that, so I haven't added new tests. Happy to add a focused test if you'd like — most natural place would be a node:test block exercising Account.delete({ revoke: true }) against a stub OAuth client, but I wanted to keep the diff minimal for the first round of review.

Notes

  • Outlook does not currently expose a revokeToken method in lib/oauth/outlook.js. Microsoft's v2 OAuth doesn't have a meaningful revoke endpoint for delegated permissions anyway, so this PR intentionally does not change Outlook behavior.
  • This is a draft PR for early feedback on the approach (especially around the opt-in vs. always-revoke choice). Happy to add tests, docs, or restructure based on direction.

Per .github/contributing.md, I confirm I am submitting this change for assignment to Postal Systems OÜ.

DELETE /v1/account/{account} now accepts a 'revoke' query parameter.
When revoke=true and the account is OAuth2-backed with a provider that
implements revokeToken (currently Gmail), EmailEngine posts the active
access token to the provider's revoke endpoint before tearing down the
account state.

The revoke step is best-effort. Failures (network errors, non-OK
responses, missing OAuth app, providers without revokeToken support)
are logged and do not block deletion, so the externally visible
behavior of DELETE remains unchanged when the upstream provider
refuses or cannot honor the call. Default (revoke=false) preserves
the prior no-revoke behavior for existing integrations.
@bgeihsgt bgeihsgt marked this pull request as ready for review May 14, 2026 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant