Skip to content

feat: add per-user Gmail OAuth send flow#382

Open
decofe wants to merge 2 commits into
mainfrom
centaur/1780472311-1610-24448
Open

feat: add per-user Gmail OAuth send flow#382
decofe wants to merge 2 commits into
mainfrom
centaur/1780472311-1610-24448

Conversation

@decofe
Copy link
Copy Markdown
Member

@decofe decofe commented Jun 3, 2026

Summary

Adds per-user Gmail OAuth send support for Slack users:

  • /ai-email-connect, /ai-email-status, /ai-email-disconnect ephemeral Slack commands
  • Signed pairing-code OAuth initiation plus PKCE (S256) to prevent leaked OAuth URL account binding
  • FastAPI OAuth callback and internal Gmail send-confirmation endpoints
  • Encrypted refresh token storage and encrypted pending draft confirmations in Postgres, including body, recipients, cc, bcc, and subject
  • Constrained send_email tool that accepts only trigger_message_ts, to, cc, bcc, subject, and body
  • Ephemeral Send/Cancel confirmation flow with signed, user-bound, expiring button payloads
  • Google token revocation on disconnect, invalid-grant handling, refresh-token rotation persistence
  • Structured audit event gmail_oauth_send_audit
  • Per-user pairing/draft/send rate limits, rate-limit pruning, plus global hourly send circuit breaker
  • Optional SLACKBOT_EPHEMERAL_API_KEY split for /api/slack/ephemeral
  • User-facing docs and key rotation notes

Approved Plan

Approved in Slack thread: https://slack.com/archives/C0A87C21805/p1780471107585029

Follow-up review fixes included: exact trigger-message lookup instead of latest-thread-user lookup, PKCE, openid email scope for connected-account status, encrypted recipients/subject, revoke failure isolation, expanded guardrail tests, pairing-token rate limiting, rate-limit pruning, confirmation-only tool wording, and optional narrower ephemeral-posting key.

Security Guardrails Checklist

  • Every send action is authorized by verified Slack user_id from the exact persisted triggering Slack message row, not message text or latest thread message.
  • OAuth grant lookup is keyed by (slack_team_id, slack_user_id).
  • Sends use the OAuth token belonging to the triggering Slack user only.
  • OAuth initiation is delivered ephemerally and requires an in-Slack pairing code/button.
  • OAuth authorization uses PKCE (code_challenge_method=S256) with verifier recomputed from signed state/button-click material.
  • Draft preview and Send/Cancel buttons are ephemeral.
  • Send success/failure/cancel/status/disconnect responses are ephemeral.
  • No email content, recipients, or connection state is intentionally posted to the public channel transcript.
  • Every send requires a full draft confirmation.
  • Button payloads embed the requester user ID and are verified on click.
  • Buttons expire after 5 minutes.
  • LLM/tool schema cannot choose sender or pass from/on_behalf_of/as_user.
  • The send_email tool only stages a confirmation; Gmail send happens only after the user clicks Send.
  • Token lookup is deterministic server-side code outside LLM control.
  • OAuth state is signed, TTL-bound, and binds Slack team/user.
  • Callback stores tokens against Slack IDs from state, not Google response identity.
  • Refresh tokens are encrypted at rest; access tokens are not persisted.
  • Pending draft confirmations are encrypted at rest, including recipients and subject.
  • Tokens are not logged; audit logs omit body.
  • OAuth scopes are limited to Gmail send plus openid email for connected-account display.
  • Disconnect calls Google's revoke endpoint, catches revoke errors, and still marks the local grant revoked.
  • Reconnect invalidates prior pending confirmations.
  • invalid_grant marks the connection invalid and prompts reconnect behavior.
  • Refresh-token rotation persists a replacement refresh token if Google returns one.
  • OAuth params use access_type=offline, prompt=consent, configured redirect URI, and no open redirect.
  • /ai-email-status distinguishes not-connected, connected-as-email, and connection-invalid states.
  • Per-user pairing, confirmation-creation, and send limits are DB-backed.
  • Global send circuit breaker is DB-backed.
  • Old rate-limit buckets are pruned.

Infra Reuse Summary

  • Token/draft/rate-limit storage: existing Centaur Postgres via DATABASE_URL; new tables only.
  • Encryption keys: existing secret injection / 1Password-backed env path with GMAIL_OAUTH_TOKEN_ENCRYPTION_KEY and GMAIL_OAUTH_TOKEN_KEY_VERSION.
  • Audit logging: existing structured logs / VictoriaLogs pipeline via event gmail_oauth_send_audit.
  • Rate limiting: existing Postgres counter table; no Redis/cache/service added.
  • OAuth/API: existing FastAPI API service.
  • Slack interactions: existing Slackbot Hono service and Slack signature middleware.
  • Ephemeral Slack posting: optional narrower SLACKBOT_EPHEMERAL_API_KEY; falls back to existing Slackbot key when unset for rollout compatibility.

No new managed service, database, queue, secret manager, or SaaS was introduced.

Test Results

  • uv run pytest tests/test_gmail_oauth.py - 9 passed
  • uv run ruff check api tests/test_gmail_oauth.py - passed
  • bun run check:types - passed
  • bun test src/index.test.ts src/gmail-oauth.test.ts - 5 passed

Manual QA

  1. Configure GMAIL_OAUTH_CLIENT_ID, GMAIL_OAUTH_CLIENT_SECRET, GMAIL_OAUTH_REDIRECT_URI, GMAIL_OAUTH_STATE_SIGNING_KEY, GMAIL_OAUTH_TOKEN_ENCRYPTION_KEY, and GMAIL_OAUTH_TOKEN_KEY_VERSION through the existing deployment secret path.
  2. Optionally configure SLACKBOT_EPHEMERAL_API_KEY for the narrower ephemeral-posting route key.
  3. Register exactly the configured redirect URI in Google OAuth client settings.
  4. In a public Slack channel, run /ai-email-status; verify an ephemeral not-connected response.
  5. Run /ai-email-connect; verify an ephemeral pairing code and Begin connection button.
  6. Enter the pairing code and click Begin connection; verify the OAuth URL includes access_type=offline, prompt=consent, code_challenge_method=S256, and only gmail.send openid email scopes.
  7. Complete OAuth; verify callback success and DB row for (team_id, user_id) with encrypted refresh token.
  8. Ask Centaur to send an email from the triggering Slack message; verify only the requester receives the ephemeral draft preview.
  9. Click Send as requester; verify Gmail sends, confirmation row becomes sent, and audit log omits body.
  10. Click Send as a different user or after 5 minutes; verify ephemeral rejection and audit result.
  11. Run /ai-email-disconnect; verify Google revoke call, local grant marked revoked, and pending confirmations invalidated.

Deferred Items / Follow-ups

  • Multi-key decrypt support would make encryption-key rotation fully online; current docs specify a maintenance-window rotation procedure.
  • Attachments, shared mailboxes, distribution lists, domain-wide delegation, and non-Google providers remain out of scope.

Prompted by: @imfromthebay

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

Cloudflare Workers docs preview

https://pr-382-centaur-docs.porto.workers.dev

@decofe decofe force-pushed the centaur/1780472311-1610-24448 branch from 653e92a to 25996ca Compare June 3, 2026 07:55
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