Happy House - Ecommerce Docs
Developer Resourcesauth

Auth Module Backend Documentation

Internal auth architecture with split admin/customer controllers and Google OAuth customer-only behavior.

Auth - Backend Documentation

1. Module Scope

Shared AuthModule provides auth primitives used by two HTTP surfaces:

  • Admin controller: /api/auth/*
  • Mobile customer controller: /api/mobile/auth/*

Core responsibilities:

  • email/password auth
  • session/token issuance + refresh/logout
  • email/phone verification flows
  • password reset flows
  • customer Google OAuth login/linking/signup

2. Composition

2.1 Controllers

Admin / backoffice, under apps/api/src/modules/auth/:

  • auth-session.controller.ts, auth-password.controller.ts, auth-verification.controller.ts, auth-account-deletion.controller.ts
  • No OAuth endpoints on this surface at all.

Customer / mobile, under apps/api/src/modules/mobile/auth/:

  • auth-registration.controller.ts — registration only
  • auth-oauth.controller.ts — the six public OAuth routes (start / callback / exchange, for both Google and Facebook)
  • auth-oauth-link.controller.ts — the two authenticated link routes. A separate controller because the guard tier differs: everything in auth-oauth.controller.ts is @Public().
  • auth-session, auth-password, auth-verification, auth-account-deletion controllers

2.2 Strategies and guards

  • LocalStrategy + LocalAuthGuard for admin email login
  • JwtStrategy + JwtAuthGuard for protected routes
  • GoogleStrategy + GoogleAuthGuard, FacebookStrategy + FacebookAuthGuard

Both provider guards are thin and delegate to buildOAuthAuthenticateOptions (guards/oauth-auth.guard.ts). A shared base class is not possible here: AuthGuard(name) bakes the passport strategy name into the class it returns, so one parent cannot dispatch to two strategies.

That shared helper does two things the previous Google-only guard did not:

  • Detects the callback by path only. The old guard also treated any request carrying a code query parameter as a callback, which meant GET /auth/google?code=x&redirect_uri=https://evil skipped createState — the only place redirect_uri is checked against the allowlist. code is attacker-supplied; the path is not.
  • Issues the nonce cookie on the start leg, which is what binds the flow to one browser.

2.3 Core services

  • AuthService — thin facade; delegates to the collaborators below
  • AuthRegistrationService — registration + registration OTP
  • AuthPasswordService — password reset/set flows
  • AuthLoginService — credential validation, login, and handleOAuthLogin for every provider
  • AccountDeletionService, AuthSessionService, AuthUserQueryService, AuthTokenService, AuthEmailService, VerificationTokenService

OAuth is three services, split because they change for different reasons:

  • OAuthProviderRegistry — resolves per-provider config once at construction into a frozen record: allowlist, state secret, TTLs, Redis prefix, error-code set, display label, assertsVerifiedEmail and the display-name fallback. A registry rather than a switch, so adding a provider means adding a row.
  • OAuthStateService — owns the state contract: what is signed, what is asserted, browser binding.
  • OAuthHandoffService — owns the Redis-backed one-time code.

Plus, in mobile/auth/services/: OAuthCallbackService (the shared callback pipeline) and OAuthLinkService (link/unlink).


3. OAuth Execution Path (Customer Only)

Identical for both providers; {provider} is google or facebook.

  1. GET /api/mobile/auth/{provider}?redirect_uri=<frontend_callback> — the guard validates the redirect against the provider's allowlist, mints a signed state, sets __Host-hs_oauth_nonce, and passport redirects to the provider.
  2. The provider calls GET /api/mobile/auth/{provider}/callback.
  3. The strategy's validate() returns the profile. Google's also carries emailVerified, read from the OIDC email_verified claim; Facebook's cannot, because the profile has no such signal.
  4. OAuthCallbackService.resolveCallback() verifies the state and the nonce cookie, then branches on the sealed intent (login or link).
  5. For a login, AuthLoginService.handleOAuthLogin() applies the identity rule — see the API doc's §6. The provider subject id is the identity; the email is never a join key.
  6. A session is issued, then the { user, tokens } payload is stored in Redis under a single-use 192-bit code with a short TTL.
  7. The browser is redirected to the allowlisted redirect_uri with the code — as ?code= or #code= depending on OAUTH_CODE_DELIVERY.
  8. The storefront calls POST /api/mobile/auth/{provider}/exchange and receives { user, tokens }.

Ordering at step 6 is load-bearing. The session must exist before the code can carry it, so if issuing the code fails the session is explicitly revoked via AuthSessionService.deleteSession. Without that a Redis failure leaves a credential valid for the full refresh-token lifetime that no client ever received, and every retry leaks another. The revocation logs success and failure separately — swallowing the failure and logging "revoked" regardless would report the leak as a cleanup.

This Redis key is not a cache. It is the only link between a committed database write and the client's session, so a failure raises AUTH_OAUTH_HANDOFF_UNAVAILABLE (503) rather than degrading to a miss.


4. Data Model Effects

Tables touched:

  • customers
  • admin_users (lookup check only)
  • account
  • customer_sessions

Write behavior:

  • Subject id already linked: touches updated_at only. account_id is never rewritten — it is the identity, and re-keying it is what let an attacker evict a victim's link.
  • Verifying provider adopting an existing customer: inserts the provider link; sets customers.email_verified = true; if the address had not previously been proved, deletes every link belonging to a non-asserting provider in the same transaction.
  • Non-verifying provider, email already held: no write. Rejected with ..._ACCOUNT_LINK_REQUIRED.
  • New customer: inserts customers + account in one transaction, with email_verified set from whether the provider proved the address.
  • Provider access/refresh/id tokens are written as null. Nothing reads them.

Constraints this relies on (migration 0017)

The identity rule needs the database to enforce it; a lookup-then-branch is a race.

ConstraintPrevents
account_actor_provider_account_unique (actor_type, provider_id, account_id)One provider subject id owning two customers. actor_type is in the key because for provider_id = 'email' the account_id is the email address, and one person may legitimately hold both an admin and a customer account on it.
account_customer_provider_unique (customer_id, provider_id) partialA second link for the same provider on one customer
account_admin_provider_unique (admin_id, provider_id) partialThe same, for admins
account_actor_matches_target CHECKactor_type = 'admin' with customer_id populated — legal before, and invisible to every lookup
account_provider_id_valid CHECKA typo such as 'Facebook', which inserted cleanly and was then unreachable
customers_email_unique made partial on deleted_at IS NULLA soft-deleted customer's address blocking signup permanently with an unmapped 23505

Each was confirmed representable before the migration and refused after, by a 17-case constraint probe run in both directions against a real database.


5. Behavior Clarifications

5.1 New user via Google

Customer is auto-signed-up (customer row created), then logged in immediately.

5.2 Existing customer with pending email verification

Successful Google login on same email upgrades account to verified and logs user in.

5.3 Admin account attempting Google login

Rejected with unauthorized response; admin must use email/password flow.


6. Configuration

Google config keys:

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET
  • GOOGLE_CALLBACK_URL
  • GOOGLE_OAUTH_REDIRECT_ALLOWLIST
  • GOOGLE_OAUTH_STATE_SECRET
  • GOOGLE_OAUTH_STATE_TTL_SECONDS
  • GOOGLE_OAUTH_CODE_TTL_SECONDS

Expected callback route:

  • http://<host>:<port>/api/mobile/auth/google/callback

7. Security and Operational Controls

  • DTO validation via global ValidationPipe
  • endpoint throttling via IpThrottlerGuard + @IpThrottle
  • banned-user protection via user-query checks
  • token/session rotation centralized in session service
  • signed/expiring state verification for OAuth callback integrity
  • one-time exchange codes stored in Redis and atomically consumed once

This keeps Google flow aligned with the same session issuance policy as email login.


8. Registration Duplicate-Email Handling

AuthService.register() never creates a second customers row for an email that already has one. The duplicate-email check runs inside the same database transaction as the insert, in this fixed precedence order:

  1. Existing account has status = "pending_deletion"409 ACCOUNT_PENDING_DELETION (unchanged, pre-existing behavior).
  2. Existing account has emailVerified = false409 AUTH_EMAIL_VERIFICATION_PENDING.
  3. Existing account has emailVerified = true409 AUTH_EMAIL_ALREADY_EXISTS.

No new row is inserted and no session/tokens are issued in any of these three cases — the transaction throws before the insert statement runs. Registration does not automatically re-trigger the verification email on a duplicate attempt; that remains the separate, explicit POST /api/mobile/auth/email/verify/resend endpoint's responsibility.


9. Account Deletion & Recovery

Both auth surfaces expose the same account-deletion/recovery operations, all delegated by AuthService to AccountDeletionService (extracted in Subphase 7 to remove a circular module dependency between AuthModule and the mobile user module — no behavior change, same endpoints, same logic):

  • Request account deletion (POST .../account-deletion/request, JWT) — schedules permanent deletion after a 7-day window, bans the account immediately, revokes active sessions.
  • Recover account via OTP (POST .../account-deletion/recover, Public) — sends a one-time recovery code to the account's email; available only while the account is in pending_deletion status.
  • Confirm account recovery (POST .../account-deletion/recover/confirm, Public) — consumes the OTP, re-activates the account and applies a 48-hour cooldown before another deletion request can be made.
  • Cancel account deletion (DELETE .../account-deletion/cancel, JWT) — available only before the 7-day window elapses; re-activates the account without requiring OTP verification (the requester is already authenticated).
  • Get account deletion status (GET .../account-deletion/status, JWT) — returns current status, deletionScheduledAt, and deleteBlockUntil for the authenticated user.

See Auth API Reference for exact routes and request/response shapes.