decision
ADR-0007: Auth via JWT + Google OAuth + AI Foundation PKCE
ADR-0007 (Accepted, 2026-05-12): Auth via JWT + Google OAuth + AI Foundation PKCE.
Status: Accepted
Date: 2026-05-12
Deciders: Seth (Lead Architect)
Context
Slice 1 needs user authentication for the chat and search endpoints. The kickoff used X-Mi-Tier headers for tier routing — no real auth. Three identity sources need to work:
- Standalone signup — email/password for users who arrive at
app.membersintel.comdirectly - Google OAuth — “Sign in with Google” for users who prefer social login
- WordPress-embedded iframe — the AI Foundation plugin (
~/Local Sites/memberpress-testing/memberpress-ai-foundation/) provides OAuth 2.1 PKCE for MemberPress admin users who access MemberIntel from within their WP dashboard
The kickoff spec (ADR-0001) already established the entitlement service with ModelHandle and check_and_consume. The question is how the authenticated identity flows into entitlement decisions.
Decision
JWT as the universal auth token, with three identity on-ramps:
- Email/password —
/api/v1/auth/registerand/api/v1/auth/loginreturn JWT access + refresh tokens (HS256, 15min access / 7day refresh) - Google OAuth —
/api/v1/auth/googlereturns a consent URL;/api/v1/auth/google/callbackexchanges the code for Google user info, creates-or-links the account, and returns JWTs - AI Foundation PKCE — deferred to V1.5/V2. The plugin already has OAuth 2.1 PKCE; the iframe will pass its token to MemberIntel, which validates it server-side. This is a separate integration point, not a Slice 1 deliverable.
All endpoints (except /auth/* and /health) require a valid JWT via Authorization: Bearer <token>. The get_current_user FastAPI dependency decodes the JWT and looks up the User in the database.
The tier field in the JWT payload (sub, email, tier, type, exp) is set at token issuance time from user.tier. Tier changes take effect on the next token refresh.
Consequences
Positive:
- Single auth mechanism (JWT) for all endpoints — simple middleware, simple testing
- Google OAuth creates-or-links accounts, so users who sign up with email can later connect Google
- The
user.google_idnullable column supports linking without breaking existing sessions - AI Foundation PKCE is additive — it validates the plugin’s token and mints our own JWT. No schema changes needed.
Negative / costs:
- JWTs are stateless — revocation requires a denylist or short TTL + refresh rotation. The 15min access TTL limits the damage window.
- Tier changes don’t take effect until the next refresh. A user who upgrades from free to pro must wait up to 15 minutes (or manually refresh).
- Google OAuth requires client ID/secret management (stored in Secret Manager for production).
- CSRF protection for the OAuth callback requires a
stateparameter (implemented).
Mitigations:
- Short access TTL (15min) + refresh rotation limits the impact of stolen tokens
- The
stateparameter in the Google OAuth flow prevents authorization code injection - Password reset emails use Resend (transactional) with one-time SHA-256 hashed tokens that expire in 1 hour
- The
email_verifiedcheck on Google userinfo prevents unverified-email attacks
Alternatives considered
- Session-based auth (server-side sessions in Redis) — rejected: adds Redis as a dependency, complicates Cloud Run (stateless), JWTs are simpler for API-first architecture
- Opaque API keys (no user model) — rejected: no tier enforcement, no conversation persistence, no identity
- Auth0 / Clerk / Firebase Auth — rejected: adds a third-party dependency for something we can implement in ~500 lines; the auth surface is small (register, login, forgot/reset, OAuth callback)
- AI Foundation PKCE as the only auth — rejected: only works for WP-embedded users; standalone users need email/Google