Skip to content

feat: add OAuth2 (XOAUTH2) authentication for IMAP#147

Open
sebykrueger wants to merge 1 commit into
dmarcguardhq:mainfrom
sebykrueger:feat/oauth2-imap-xoauth2
Open

feat: add OAuth2 (XOAUTH2) authentication for IMAP#147
sebykrueger wants to merge 1 commit into
dmarcguardhq:mainfrom
sebykrueger:feat/oauth2-imap-xoauth2

Conversation

@sebykrueger

Copy link
Copy Markdown

Summary

  • Adds Google XOAUTH2 as an alternative to App Password authentication for IMAP β€” useful for Workspace tenants that disable App Passwords and for users who prefer revocable OAuth grants.
  • Introduces a nested imap.auth block with a discriminator field (type: password|xoauth2); existing password-only configs validate and run unchanged via implicit fallback.
  • New internal/imap/oauth package implements the Google provider, RFC 8252 loopback flow with PKCE S256, and a SASL XOAUTH2 client (the emersion/go-sasl library only ships OAUTHBEARER, which Microsoft 365 doesn't accept over IMAP β€” so XOAUTH2 keeps the door open for the M365 follow-up).
  • --oauth-login runs the device-side bootstrap on a host with a browser, then the daemon runs fully headless using a refresh token from <dirname(database.path)>/secrets.json or the IMAP_OAUTH_REFRESH_TOKEN env override.
  • New parse_dmarc_imap_auth_required Prometheus gauge fires on terminal auth errors (invalid_grant, 4xx) so operators can alert when re-auth is needed.

Why loopback flow, not device flow?

The device authorization grant (RFC 8628) was the first design β€” it works on truly headless servers. But Google does not permit https://mail.google.com/ in the device-flow scope allowlist, so it's structurally unusable for Gmail IMAP. Loopback flow is the standard recommendation (RFC 8252 Β§3.1.1) for native/CLI tools β€” rclone, mbsync, mutt_oauth2.py, and gmailctl all use the same pattern.

For Docker users this means: run --oauth-login once on a host with a browser, paste the printed IMAP_OAUTH_REFRESH_TOKEN=... line into a .env file, then the container is fully headless. The compose.yml is updated to include env_file: [.env, required: false] so existing App Password users are unaffected.

Provider scope

Google only in v1. The provider abstraction is structured so Microsoft 365 / Entra ID is essentially a one-file addition (different OAuth endpoints, scope = https://outlook.office.com/IMAP.AccessAsUser.All offline_access, same XOAUTH2 SASL mechanism).

Test plan

  • go test ./... β€” config backwards-compat (password-only validates), XOAUTH2 validation (missing client_id/secret rejected, unknown provider rejected), secrets file round-trip with 0600 permissions and env override precedence, terminal vs transient OAuth error classification, PKCE verifier/challenge match, XOAUTH2 wire format
  • go vet ./... clean
  • go build ./... clean (CGO and non-CGO)
  • End-to-end manual test: built host binary, created Desktop app OAuth client in Google Cloud, ran --oauth-login (browser opened, consent succeeded, refresh token printed), set IMAP_OAUTH_REFRESH_TOKEN in .env, ran docker compose up -d, daemon connected to imap.gmail.com:993 and authenticated successfully via XOAUTH2
  • Verified existing password configs still validate and would still work (no schema-breaking changes; new auth block defaults to absent β†’ password mode)

Adds a Google XOAUTH2 path alongside the existing password authentication
for users on Workspace tenants that disable App Passwords (or who prefer
revocable OAuth grants). Existing password configs continue to work
unchanged.

Implementation
- Nested `imap.auth` block in IMAPConfig with discriminator
  (`type: password|xoauth2`); implicit fallback to password when the
  block is absent and `password` is set.
- New internal/imap/oauth package: Google provider, RFC 8252 loopback
  redirect flow with PKCE S256, RFC 6750 SASL XOAUTH2 client (the
  emersion/go-sasl library only ships OAUTHBEARER, which Microsoft
  doesn't accept over IMAP).
- `--oauth-login` CLI flag runs the loopback flow on a host with a
  browser; refresh token written to `<dirname(database.path)>/secrets.json`
  (mode 0600) when the path is writable, otherwise printed for use via
  the `IMAP_OAUTH_REFRESH_TOKEN` env override.
- Token caching via stock `oauth2.ReuseTokenSource` β€” access tokens stay
  in memory across fetch cycles within a single daemon run.
- New `parse_dmarc_imap_auth_required` Prometheus gauge: set to 1 on
  terminal auth errors (`invalid_grant`, 4xx) so operators can alert
  when a refresh token requires re-bootstrap.

Provider scope
- Google only in v1. The provider abstraction is structured so
  Microsoft 365 / Entra ID is a one-file addition (different endpoints,
  scope = `https://outlook.office.com/IMAP.AccessAsUser.All offline_access`).
- Hardcoded scope `https://mail.google.com/` β€” the only Google scope
  that grants IMAP access. Narrower Gmail API scopes work via REST only.
- Device authorization grant (RFC 8628) was evaluated and rejected:
  Google does not permit `mail.google.com` in the device-flow scope
  allowlist, so loopback is the only viable flow for headless servers
  (run --oauth-login on a host with a browser, then ship the refresh
  token to the daemon via env var or file).

Docker
- compose.yml gains `env_file: [.env]` with `required: false` so
  existing App Password users are unaffected.

Tests
- Config: backwards-compat (password-only), XOAUTH2 validation, unknown
  provider/auth-type rejection, secrets-path co-location.
- OAuth: secrets round-trip with 0600 perms + env override, terminal
  vs transient error classification, PKCE verifier/challenge
  correctness, XOAUTH2 wire format.
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