StudyBoost Docs

Feature: Google OAuth Sign-In & Forgot Password

Metadata


Overview

Two adjacent auth surfaces shipped together because they share auth.service.ts, auth.controller.ts, MailModule, and the same QA cycle.

  • Part 1 — Google OAuth sign-in. One-click "Continue with Google" on /login and /signup. Supports account linking when the Google email matches a verified existing account, and unlinking from /settings/security (blocked if Google is the only sign-in method). Google only.
  • Part 2 — Forgot password / reset. Wires up the recovery flow on top of the existing users.reset_token and users.reset_token_expiry columns. Emails a single-use 30-minute reset link via MailService, revokes all sessions on success, and uses constant-response anti-enumeration. Also adds the change-password endpoint powering the OAuth-only "Set a password" branch.

Both reuse the FEAT-31 cookie/JWT session pipeline — no new session machinery.


Frontend Behavior

  • Login & Signup: "Continue with Google" button above the email/password form, separated by an "OR" divider. Clicking navigates to GET /auth/oauth/google/start?next=<encoded> (302 -> Google consent). The button shows a spinner and disables the form to prevent double-submit. On Google error or consent denial the user lands on /login?error=oauth_denied with a friendly toast. A "Forgot password?" link sits next to the password field on /login.
  • /forgot-password (new): email field + "Send reset link" button posting /auth/forgot-password. Always shows the same generic success state regardless of response (anti-enumeration); resubmit is disabled for 30 seconds.
  • /reset-password?token=<opaque> (new): missing/malformed token renders an inline error with a link back to /forgot-password. New password + confirm fields with show/hide and a strength meter; posts /auth/reset-password. On success, toast + 302 to /login. Referrer-Policy: no-referrer is enforced via frontend/app/reset-password/layout.tsx so the unhashed token does not leak.
  • Settings -> Security: Google row shows {provider_email} · Connected on {linked_at} with Connect/Disconnect. Disconnect is disabled with a "Set a password before disconnecting Google" tooltip when Google is the only sign-in method.
  • Account -> Password form: branches on has_password. For has_password === false the title becomes "Set a password", the "Current password" field is hidden, and the payload omits currentPassword. After a successful submit, /auth/me is re-fetched so the form flips to "Change password" without a reload.
  • AuthUser is extended with connected_accounts?: { google?: { email; linked_at } } and has_password?: boolean.

Backend Behavior

Routes on auth.controller.ts (class-level AuthThrottlerGuard, existing 5 requests / 300 s per IP):

MethodPathGuardPurpose
GET/auth/oauth/google/startOptionalCookieAuthGuardGenerates signed state (CSRF nonce + sanitized next + optional link=1 + userId), 302 to Google consent
GET/auth/oauth/google/callbacknoneVerifies state, exchanges code, resolves user, issues session cookies, 302 to next
DELETE/auth/oauth/google/linkCookieAuthGuardUnlinks the Google identity; blocks if it would leave no sign-in method
POST/auth/forgot-password { email }AuthThrottlerGuardAlways returns generic 200; emails a reset link only for an active user with a password
POST/auth/reset-password { token, newPassword }AuthThrottlerGuardVerifies token, sets new password, clears reset columns, deletes all auth_sessions, writes security_events
POST/auth/change-password { currentPassword?, newPassword }CookieAuthGuardOAuth-only users may omit currentPassword; existing-password users must supply it

OAuth resolution logic (callback): (1) link mode — verified state.link + state.userId calls linkGoogleAccount, refusing with ConflictException if the Google sub is already linked elsewhere, no session issued; (2) existing linkuser_oauth_accounts lookup by (provider='google', provider_user_id=sub) issues a session; (3) email-linkusers lookup by lower-cased Google email: email_verified=false refuses with 302 /login?error=oauth_email_unverified, email_verified=true creates the link row and issues a session; (4) new user — a Prisma transaction creates the users row (password=null, mirrored email_verified, Google CDN profile_picture_url, deduped username) + user_oauth_accounts row + session.

state is a JWT signed with AUTH_OAUTH_STATE_SECRET (falls back to JWT_ACCESS_SECRET), 10-minute TTL. OAuthStateUtil enforces single-use nonces so a verified state cannot be replayed. Reset tokens are 32 random bytes stored as sha256 in users.reset_token; the email carries the unhashed token. Successful reset deletes every auth_sessions row for the user inside the same transaction as the password_reset_completed audit row. requestPasswordReset returns the same generic 200 for matched, unknown, inactive, and OAuth-only users.

Data model — migration 202605181130_add_user_oauth_accounts adds user_oauth_accounts with PRIMARY KEY (provider, provider_user_id), UNIQUE (user_id, provider), ON DELETE CASCADE from users, and a user_id index. Additive only, DATA RISK: none. Existing users.reset_token, users.reset_token_expiry, auth_sessions, and security_events are reused.


QA Test Scenarios

Scenario IDDescriptionStepsInputExpected Result
FEAT-105-01New-user Google sign-in (happy path)Click "Continue with Google" on /login; complete consentFresh Google account not in DBusers + user_oauth_accounts rows created; cookies set; 302 to /dashboard
FEAT-105-02Repeat Google sign-in for a linked accountSign in with Google again for the same subAlready-linked Google subLogs in as the linked user; no duplicate rows
FEAT-105-03Link Google to a verified email/password userSign in with Google using the same email as an existing verified userExisting user, email_verified=trueNew user_oauth_accounts row links Google -> existing user
FEAT-105-04Linking blocked for an unverified emailAs 03 but the existing user is email_verified=falseExisting user, email_verified=falseCallback aborts; 302 to /login?error=oauth_email_unverified; no row created
FEAT-105-05OAuth-only user opens password formLogin as a Google-only user; visit /settings/accounthas_password=falseTitle shows "Set a password"; current-password field hidden
FEAT-105-06OAuth-only user sets a passwordSubmit the set-password formNew password >= 8 charsusers.password populated; has_password=true on next /auth/me; form reverts to "Change password"
FEAT-105-07Disconnect Google with password fallbackUser with Google + password clicks DisconnectAuthenticated cookieuser_oauth_accounts row removed; user can still sign in with password
FEAT-105-08Disconnect blocked when only sign-in methodOAuth-only user attempts to disconnect GoogleAuthenticated cookieBackend returns 400; UI button disabled with tooltip
FEAT-105-09User denies Google consentClick Google button, hit "Cancel" on consent screenn/aReturns to /login?error=oauth_denied; friendly toast shown
FEAT-105-10CSRF — tampered stateReplay callback with a mutated stateInvalid signature on stateCallback returns 400; no session issued
FEAT-105-11CSRF — state replayReuse a previously-consumed state valueAlready-redeemed stateCallback returns 400
FEAT-105-12Forgot-password link visible on /loginOpen /loginn/a"Forgot password?" link rendered; routes to /forgot-password
FEAT-105-13Forgot-password — valid emailPOST /auth/forgot-password with a registered emailActive user with a passwordGeneric 200; email arrives within 30 s; reset_token stored as sha256; expiry = now + 30 min
FEAT-105-14Forgot-password — unknown email (anti-enumeration)POST with a non-existent emailUnregistered emailSame generic 200; no email sent; no DB write
FEAT-105-15Forgot-password — OAuth-only userPOST with a Google-only user's emailusers.password IS NULLSame generic 200; no email sent
FEAT-105-16Forgot-password — rate limitedSend 6 requests within 300 s from one IPn/a6th request returns 429 from AuthThrottlerGuard
FEAT-105-17Reset-password — valid token within 30 minOpen the email link; submit a new passwordValid unhashed token, password >= 8 chars200 success; password updated; all auth_sessions deleted
FEAT-105-18Reset-password — expired tokenSubmit a token whose expiry is in the pastExpired token400 "Link expired"; password unchanged
FEAT-105-19Reset-password — already usedSubmit a token already redeemedSingle-use token, second attempt400 "Invalid token"
FEAT-105-20Reset-password — second request invalidates firstIssue a reset, then issue another before the first expiresTwo consecutive requestPasswordReset callsOnly the second token is valid; the first now returns 400
FEAT-105-21Reset-password — sessions revoked everywhereReset password while logged in on a second deviceAuthenticated cookies on another browserAll auth_sessions rows deleted; second device gets 401 on next protected request
FEAT-105-22Reset-password — weak password rejectedSubmit a password < 8 charsnewPassword.length < 8Inline client validation error; no backend call
FEAT-105-23Reset-password — missing token in URLOpen /reset-password with no ?token=n/aInline error page + "Request a new link" CTA
FEAT-105-24Audit events writtenComplete a forgot -> reset flow end-to-endn/aOne security_events row each for password_reset_requested and password_reset_completed
FEAT-105-25Username collision on OAuth signupGoogle user's email local-part matches an existing usernameExisting username='alex', Google profile alex@gmail.comNew user created with a deduped username (e.g. alex-a1b2)

Edge Cases

  • Email mismatch after linking — keyed by provider_user_id (Google sub); changing the StudyBoost email does not break the link.
  • Two-step linking from Settings?link=1 on an authenticated start route runs the link-only path: no new session, 302 back to /settings/security. A sub already linked elsewhere fails with a clear error.
  • Missing email_verified claim from Google — treated as false; refuses email-match linking, allows new-user creation only.
  • Token-in-URL leakage — mitigated by Referrer-Policy: no-referrer on /reset-password.
  • Clock skew / race at token expiryreset_token_expiry is compared against SQL NOW() inside an atomic WHERE clause, not Node's Date.now().
  • Deleted user mid-reset — token lookup returns null -> standard 400 "Invalid token" path.
  • OAuth callback failure mid-flight — user creation, oauth row creation, and session issuance run in one Prisma transaction, so no half-linked account is left behind.
  • CORS / cross-site cookies — SameSite=lax is already in use; the OAuth callback hop is a top-level navigation.

Notes

  • Feature flags: None — both surfaces ship enabled.
  • Dependencies: FEAT-31 cookie/JWT pipeline (createSessionForUser, CookieAuthGuard, auth_sessions); existing MailModule and AuthThrottlerGuard; users.reset_token + reset_token_expiry; security_events; new npm packages passport-google-oauth20 and @types/passport-google-oauth20.
  • Migration: Single additive migration adding user_oauth_accounts. DATA RISK: none.
  • Known limitations / non-goals: No Apple / Microsoft / GitHub / Facebook OAuth; no 2FA/MFA; no auto-merge of two existing accounts; Google scope is openid email profile only; no magic-link sign-in; no SMS recovery; no GCS mirroring of profile pictures.

Related Docs

  • Implementation-level source of truth: docs/features/FEAT-105-OAuth-Sign-In-Forgot-Password.md
  • Auth/session baseline: /docs/features/31-secure-cookie-auth-session-management
  • Migration convention: /docs/database-schema/migration-convention
  • QA requirements: /docs/features/qa-requirements