Skip to content

Add OAuth/social login (Google), integrated with existing registration gating #757

Description

@gaidheal1

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

  • A user can sign up and log in with a Google account from both /login and /register.
  • Social signups respect registration_enabled, the registration cap, self-serve/invite-only mode, and character availability exactly as password signups do today.
  • Logging in via Google with an email matching an existing confirmed account logs into that account rather than creating a duplicate.
  • New social-login users get a Player + ActivityTimer and is_confirmed=True, with no separate confirmation email sent.
  • The frontend receives/stores the same JWT token shape as password login; no changes needed to AuthContext/authStorage.
  • Client ID/secret are environment-specific and documented.
  • Test coverage per the section above is in place.

Open questions

  1. 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.)
  2. 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?
  3. 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)?
  4. Should OAuth-only users be able to add a password later via account settings, or stay OAuth-only indefinitely?
  5. 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?
  6. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    featuretriageThis issue is awaiting sorting

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions