Problem / motivation
Signup/login today is email + password only. For an ADHD-focused productivity app, every point of friction at signup is a point where someone bounces — a "Continue with Google" option removes the password-creation/memorisation step entirely.
Notably, the plumbing for this is already half-built: django-allauth and dj-rest-auth are existing dependencies, allauth, allauth.account, and allauth.socialaccount are already in INSTALLED_APPS, and users/adapters.py already has a CustomAccountAdapter. They're currently used only for the email/password flow (registration, mandatory email confirmation, password reset) — allauth.socialaccount itself is unused: no provider is registered, no SOCIALACCOUNT_PROVIDERS config exists, and there's no social login endpoint or frontend button anywhere in the repo or its history.
Proposed behaviour
- "Continue with Google" appears as an alternative to the email/password form on both
/login and /register.
- Completing it returns the user to the app already authenticated, holding the same JWT access/refresh token pair
POST /auth/jwt/create/ returns today — AuthContext.login() and authStorage.ts need no changes.
- New users signing up via Google pass through the same registration gates as password signups (see Technical approach) rather than skipping them.
- Returning users logging in via Google whose verified email matches an existing account get logged into that account rather than creating a duplicate (see Account/linking considerations).
Scope
In scope:
- One provider: Google (see Technical approach for why, and Open Questions for whether a second is warranted now).
- Backend: enable the Google provider, a login endpoint that returns the app's existing JWT shape, a
SOCIALACCOUNT_ADAPTER that enforces the current registration-gating rules and bootstraps Player/ActivityTimer the way CustomUserManager.create_user does today.
- Frontend: a Google sign-in button on Login/Register wired into the existing
AuthContext.login() path.
- Account linking behaviour for an OAuth email that matches an existing account.
- Per-environment config (local/staging/prod client IDs, redirect origins).
Out of scope (candidates for follow-up issues):
- Additional providers (Apple, GitHub, etc.).
- Account-settings UI for linking/unlinking a provider or adding a password after OAuth-only signup.
- Removing password login — it stays available regardless.
Technical approach
The app is a pure JWT SPA: REST_USE_JWT=True, tokens are stored client-side (authStorage.ts) and attached as Authorization: Bearer, and there's no server-side session/cookie login flow (SessionAuthentication is commented out in REST_FRAMEWORK). This rules out allauth's default social-login flow, which is built around a server-rendered redirect + session cookie. The fit that matches how this app already works is dj-rest-auth's token-exchange pattern: the frontend runs Google Identity Services (JS SDK) itself, gets a credential from Google, and POSTs it to a new backend endpoint (e.g. /auth/google/) that verifies it via allauth's GoogleOAuth2Adapter and returns the same token-pair shape CustomTokenObtainPairView returns today — no new client-side auth model.
Concretely:
- Add
allauth.socialaccount.providers.google to INSTALLED_APPS and configure SOCIALACCOUNT_PROVIDERS (scopes limited to openid/email/profile).
- Add a
CustomSocialAccountAdapter (users/adapters.py, alongside the existing CustomAccountAdapter) that:
- Gates
is_open_for_signup on the same checks CustomRegisterView/CustomRegisterSerializer already enforce: GameSettings.registration_enabled, the registration cap (verified_user_count() vs registration_cap), self_serve_registration, and invite code/token where relevant. Without this, OAuth becomes an unguarded side door around the waitlist/invite system.
- Calls
ensure_player_setup_for_user() for new users — social signups don't go through CustomUserManager.create_user, so this bootstrap has to be added explicitly or new social users end up without a Player/ActivityTimer.
- Sets
is_confirmed=True directly for new social users (Google has already verified the email), rather than sending our own confirmation email.
- No heavy new dependencies expected —
requests and PyJWT are already present; allauth's Google adapter uses those internally.
A short spike is worth doing before committing to the exact endpoint shape (see Open Questions #6) — dj-rest-auth's built-in SocialLoginView may need adjustment to cleanly return this app's JWT shape rather than its default session/token response.
Account/linking considerations
- Matching key: verified email (Google always returns one).
- OAuth email matches an existing confirmed account → link the
SocialAccount to that user and log them in. Whether this happens silently or with an explicit "linked to your existing account" notice is an open question below.
- OAuth email matches an existing but unconfirmed account (password registered, confirmation email never actioned) → flagged as an open question; auto-linking here has a real security edge (someone else's OAuth login "claiming" an account whose original owner never confirmed), but blocking it also creates a confusing dead end for the legitimate owner.
- Pure-OAuth accounts have no usable password (
set_unusable_password()), which Django's auth system already supports — but CustomPasswordResetSerializer and any "forgot password" UI need to behave sensibly for a user who never set one.
Configuration requirements
- New env vars, following the existing per-environment secret pattern (e.g.
CF_TURNSTILE_SECRET_KEY): GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET.
- The client ID is public and should be served to the frontend the same way
RegistrationStatusAPIView already serves turnstile_site_key — no frontend rebuild needed to rotate it.
- Authorized JS origins/redirect URIs registered in Google Cloud Console per environment (local, staging, prod); document alongside existing entries in
docs/internal/environment-and-configuration.md and docs/internal/deployment-runbook.md.
SITE_ID = 1 is already set; confirm the django.contrib.sites domain lines up with FRONTEND_URL per environment if allauth's flow touches it.
Testing requirements
- Backend: adapter tests for
is_open_for_signup under each registration-gating state (mirrors the existing CustomAccountAdapterTest style in users/tests/tests.py); tests for new-account vs existing-account linking by email; tests confirming Player/ActivityTimer creation for social signups; a test asserting the response shape matches /auth/jwt/create/'s.
- Google token verification must be mocked in tests — no live network calls.
- Frontend: unit tests for the Google button/handler feeding into
AuthContext.login(); a Playwright E2E happy path if Google's flow can be reasonably stubbed in CI (call out explicitly if it can't, rather than skipping silently).
- Full regression pass on the existing password login/registration/waitlist/invite test suite — none of it should need to change.
Acceptance criteria
Open questions
- Auto-link on a matching but unconfirmed email, or block/require the user to confirm via password first? (security tradeoff noted above — needs a product decision, not just an engineering one.)
- Should linking a Google login to an existing password account be silent, or should the user see an explicit "linked to your existing account" notice?
- Is Google sufficient as the only provider for v1, or should Apple be evaluated in the same pass (mobile app-store guidelines sometimes expect it alongside other third-party OAuth)?
- Should OAuth-only users be able to add a password later via account settings, or stay OAuth-only indefinitely?
- Should the registration cap/waitlist gating apply identically to OAuth signups, or does product want different treatment — e.g. still capped, but exempt from CAPTCHA/disposable-email checks since Google has already vetted the identity?
- Needs a short spike: does dj-rest-auth's built-in social-login view cleanly return this app's existing JWT shape under
REST_USE_JWT=True, or does the endpoint need to be hand-rolled around allauth's GoogleOAuth2Adapter?
Problem / motivation
Signup/login today is email + password only. For an ADHD-focused productivity app, every point of friction at signup is a point where someone bounces — a "Continue with Google" option removes the password-creation/memorisation step entirely.
Notably, the plumbing for this is already half-built:
django-allauthanddj-rest-authare existing dependencies,allauth,allauth.account, andallauth.socialaccountare already inINSTALLED_APPS, andusers/adapters.pyalready has aCustomAccountAdapter. They're currently used only for the email/password flow (registration, mandatory email confirmation, password reset) —allauth.socialaccountitself is unused: no provider is registered, noSOCIALACCOUNT_PROVIDERSconfig exists, and there's no social login endpoint or frontend button anywhere in the repo or its history.Proposed behaviour
/loginand/register.POST /auth/jwt/create/returns today —AuthContext.login()andauthStorage.tsneed no changes.Scope
In scope:
SOCIALACCOUNT_ADAPTERthat enforces the current registration-gating rules and bootstrapsPlayer/ActivityTimerthe wayCustomUserManager.create_userdoes today.AuthContext.login()path.Out of scope (candidates for follow-up issues):
Technical approach
The app is a pure JWT SPA:
REST_USE_JWT=True, tokens are stored client-side (authStorage.ts) and attached asAuthorization: Bearer, and there's no server-side session/cookie login flow (SessionAuthenticationis commented out inREST_FRAMEWORK). This rules out allauth's default social-login flow, which is built around a server-rendered redirect + session cookie. The fit that matches how this app already works is dj-rest-auth's token-exchange pattern: the frontend runs Google Identity Services (JS SDK) itself, gets a credential from Google, and POSTs it to a new backend endpoint (e.g./auth/google/) that verifies it via allauth'sGoogleOAuth2Adapterand returns the same token-pair shapeCustomTokenObtainPairViewreturns today — no new client-side auth model.Concretely:
allauth.socialaccount.providers.googletoINSTALLED_APPSand configureSOCIALACCOUNT_PROVIDERS(scopes limited toopenid/email/profile).CustomSocialAccountAdapter(users/adapters.py, alongside the existingCustomAccountAdapter) that:is_open_for_signupon the same checksCustomRegisterView/CustomRegisterSerializeralready enforce:GameSettings.registration_enabled, the registration cap (verified_user_count()vsregistration_cap),self_serve_registration, and invite code/token where relevant. Without this, OAuth becomes an unguarded side door around the waitlist/invite system.ensure_player_setup_for_user()for new users — social signups don't go throughCustomUserManager.create_user, so this bootstrap has to be added explicitly or new social users end up without aPlayer/ActivityTimer.is_confirmed=Truedirectly for new social users (Google has already verified the email), rather than sending our own confirmation email.requestsandPyJWTare already present; allauth's Google adapter uses those internally.A short spike is worth doing before committing to the exact endpoint shape (see Open Questions #6) — dj-rest-auth's built-in
SocialLoginViewmay need adjustment to cleanly return this app's JWT shape rather than its default session/token response.Account/linking considerations
SocialAccountto that user and log them in. Whether this happens silently or with an explicit "linked to your existing account" notice is an open question below.set_unusable_password()), which Django's auth system already supports — butCustomPasswordResetSerializerand any "forgot password" UI need to behave sensibly for a user who never set one.Configuration requirements
CF_TURNSTILE_SECRET_KEY):GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET.RegistrationStatusAPIViewalready servesturnstile_site_key— no frontend rebuild needed to rotate it.docs/internal/environment-and-configuration.mdanddocs/internal/deployment-runbook.md.SITE_ID = 1is already set; confirm thedjango.contrib.sitesdomain lines up withFRONTEND_URLper environment if allauth's flow touches it.Testing requirements
is_open_for_signupunder each registration-gating state (mirrors the existingCustomAccountAdapterTeststyle inusers/tests/tests.py); tests for new-account vs existing-account linking by email; tests confirmingPlayer/ActivityTimercreation for social signups; a test asserting the response shape matches/auth/jwt/create/'s.AuthContext.login(); a Playwright E2E happy path if Google's flow can be reasonably stubbed in CI (call out explicitly if it can't, rather than skipping silently).Acceptance criteria
/loginand/register.registration_enabled, the registration cap, self-serve/invite-only mode, and character availability exactly as password signups do today.Player+ActivityTimerandis_confirmed=True, with no separate confirmation email sent.AuthContext/authStorage.Open questions
REST_USE_JWT=True, or does the endpoint need to be hand-rolled around allauth'sGoogleOAuth2Adapter?