Feature: Google OAuth Sign-In & Forgot Password
Metadata
- Issue ID: FEAT-105
- Status: Done
- Owner: Kzu0-afk
- Related PRs: StudyBoostIO/StudyboostV2#114, #117
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
/loginand/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_tokenandusers.reset_token_expirycolumns. Emails a single-use 30-minute reset link viaMailService, revokes all sessions on success, and uses constant-response anti-enumeration. Also adds thechange-passwordendpoint 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_deniedwith 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-referreris enforced viafrontend/app/reset-password/layout.tsxso 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. Forhas_password === falsethe title becomes "Set a password", the "Current password" field is hidden, and the payload omitscurrentPassword. After a successful submit,/auth/meis re-fetched so the form flips to "Change password" without a reload. AuthUseris extended withconnected_accounts?: { google?: { email; linked_at } }andhas_password?: boolean.
Backend Behavior
Routes on auth.controller.ts (class-level AuthThrottlerGuard, existing 5 requests / 300 s per IP):
| Method | Path | Guard | Purpose |
|---|---|---|---|
GET | /auth/oauth/google/start | OptionalCookieAuthGuard | Generates signed state (CSRF nonce + sanitized next + optional link=1 + userId), 302 to Google consent |
GET | /auth/oauth/google/callback | none | Verifies state, exchanges code, resolves user, issues session cookies, 302 to next |
DELETE | /auth/oauth/google/link | CookieAuthGuard | Unlinks the Google identity; blocks if it would leave no sign-in method |
POST | /auth/forgot-password { email } | AuthThrottlerGuard | Always returns generic 200; emails a reset link only for an active user with a password |
POST | /auth/reset-password { token, newPassword } | AuthThrottlerGuard | Verifies token, sets new password, clears reset columns, deletes all auth_sessions, writes security_events |
POST | /auth/change-password { currentPassword?, newPassword } | CookieAuthGuard | OAuth-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 link — user_oauth_accounts lookup by (provider='google', provider_user_id=sub) issues a session; (3) email-link — users 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 ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-105-01 | New-user Google sign-in (happy path) | Click "Continue with Google" on /login; complete consent | Fresh Google account not in DB | users + user_oauth_accounts rows created; cookies set; 302 to /dashboard |
| FEAT-105-02 | Repeat Google sign-in for a linked account | Sign in with Google again for the same sub | Already-linked Google sub | Logs in as the linked user; no duplicate rows |
| FEAT-105-03 | Link Google to a verified email/password user | Sign in with Google using the same email as an existing verified user | Existing user, email_verified=true | New user_oauth_accounts row links Google -> existing user |
| FEAT-105-04 | Linking blocked for an unverified email | As 03 but the existing user is email_verified=false | Existing user, email_verified=false | Callback aborts; 302 to /login?error=oauth_email_unverified; no row created |
| FEAT-105-05 | OAuth-only user opens password form | Login as a Google-only user; visit /settings/account | has_password=false | Title shows "Set a password"; current-password field hidden |
| FEAT-105-06 | OAuth-only user sets a password | Submit the set-password form | New password >= 8 chars | users.password populated; has_password=true on next /auth/me; form reverts to "Change password" |
| FEAT-105-07 | Disconnect Google with password fallback | User with Google + password clicks Disconnect | Authenticated cookie | user_oauth_accounts row removed; user can still sign in with password |
| FEAT-105-08 | Disconnect blocked when only sign-in method | OAuth-only user attempts to disconnect Google | Authenticated cookie | Backend returns 400; UI button disabled with tooltip |
| FEAT-105-09 | User denies Google consent | Click Google button, hit "Cancel" on consent screen | n/a | Returns to /login?error=oauth_denied; friendly toast shown |
| FEAT-105-10 | CSRF — tampered state | Replay callback with a mutated state | Invalid signature on state | Callback returns 400; no session issued |
| FEAT-105-11 | CSRF — state replay | Reuse a previously-consumed state value | Already-redeemed state | Callback returns 400 |
| FEAT-105-12 | Forgot-password link visible on /login | Open /login | n/a | "Forgot password?" link rendered; routes to /forgot-password |
| FEAT-105-13 | Forgot-password — valid email | POST /auth/forgot-password with a registered email | Active user with a password | Generic 200; email arrives within 30 s; reset_token stored as sha256; expiry = now + 30 min |
| FEAT-105-14 | Forgot-password — unknown email (anti-enumeration) | POST with a non-existent email | Unregistered email | Same generic 200; no email sent; no DB write |
| FEAT-105-15 | Forgot-password — OAuth-only user | POST with a Google-only user's email | users.password IS NULL | Same generic 200; no email sent |
| FEAT-105-16 | Forgot-password — rate limited | Send 6 requests within 300 s from one IP | n/a | 6th request returns 429 from AuthThrottlerGuard |
| FEAT-105-17 | Reset-password — valid token within 30 min | Open the email link; submit a new password | Valid unhashed token, password >= 8 chars | 200 success; password updated; all auth_sessions deleted |
| FEAT-105-18 | Reset-password — expired token | Submit a token whose expiry is in the past | Expired token | 400 "Link expired"; password unchanged |
| FEAT-105-19 | Reset-password — already used | Submit a token already redeemed | Single-use token, second attempt | 400 "Invalid token" |
| FEAT-105-20 | Reset-password — second request invalidates first | Issue a reset, then issue another before the first expires | Two consecutive requestPasswordReset calls | Only the second token is valid; the first now returns 400 |
| FEAT-105-21 | Reset-password — sessions revoked everywhere | Reset password while logged in on a second device | Authenticated cookies on another browser | All auth_sessions rows deleted; second device gets 401 on next protected request |
| FEAT-105-22 | Reset-password — weak password rejected | Submit a password < 8 chars | newPassword.length < 8 | Inline client validation error; no backend call |
| FEAT-105-23 | Reset-password — missing token in URL | Open /reset-password with no ?token= | n/a | Inline error page + "Request a new link" CTA |
| FEAT-105-24 | Audit events written | Complete a forgot -> reset flow end-to-end | n/a | One security_events row each for password_reset_requested and password_reset_completed |
| FEAT-105-25 | Username collision on OAuth signup | Google user's email local-part matches an existing username | Existing username='alex', Google profile alex@gmail.com | New user created with a deduped username (e.g. alex-a1b2) |
Edge Cases
- Email mismatch after linking — keyed by
provider_user_id(Googlesub); changing the StudyBoost email does not break the link. - Two-step linking from Settings —
?link=1on an authenticated start route runs the link-only path: no new session, 302 back to/settings/security. Asubalready linked elsewhere fails with a clear error. - Missing
email_verifiedclaim from Google — treated asfalse; refuses email-match linking, allows new-user creation only. - Token-in-URL leakage — mitigated by
Referrer-Policy: no-referreron/reset-password. - Clock skew / race at token expiry —
reset_token_expiryis compared against SQLNOW()inside an atomicWHEREclause, not Node'sDate.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); existingMailModuleandAuthThrottlerGuard;users.reset_token+reset_token_expiry;security_events; new npm packagespassport-google-oauth20and@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 profileonly; 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