Files
scan-receipts/AUTH_SETUP_GUIDE.md
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

8.6 KiB

Auth Setup Guide

Email + password accounts, Google sign-in, and SMTP confirmation links.

Guest mode needs none of this — it stays local-first in IndexedDB. Everything below only applies to real accounts.


1. Database (required)

Auth is the one part of the app that cannot run without Postgres.

docker compose up -d postgres
npm run db:push

db:push applies drizzle/0001_thin_blur.sql, which adds:

Table / column Purpose
users.email_key + uq_users_email_key One account per inbox (unique index)
users.password_hash scrypt digest
users.email_verified_at Null until the link is opened
users.name Display name
sessions Server-side sessions, token stored hashed
oauth_accounts Google identity ↔ local user
email_verification_tokens Single-use, expiring confirmation links
password_reset_tokens (0002) Single-use, 1-hour reset links

Existing database with duplicate emails? The unique index is created over email_key, which starts out NULL for every existing row, so the migration applies cleanly. Duplicates only surface when those old rows are backfilled.

Verify the whole thing end to end:

npm run test:auth

That suite creates a throwaway account, tries twenty aliased duplicates, walks the confirmation-link and password-reset lifecycles, links a Google identity, and cleans up after itself. Without a database it reports SKIPPED and exits 0.


2. SMTP (required in production)

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=no-reply@yourdomain.com
SMTP_PASSWORD=your_smtp_password
SMTP_SECURE=false
MAIL_FROM=ScanReceipts <no-reply@yourdomain.com>
  • SMTP_SECURE=false + port 587 → STARTTLS (most providers).
  • SMTP_SECURE=true + port 465 → implicit TLS.

Development without SMTP: signup still works and the confirmation link is printed to the server console and shown in the UI, so the flow is testable with no mail provider.

Production without SMTP: signup is refused with mail_failed before any row is written. An account that can never be confirmed must not be created.

Deliverability matters here — confirmation mail that lands in spam looks like a broken product. Set SPF, DKIM and DMARC on the sending domain.


3. Google sign-in (optional)

The Google button only renders when both variables are set; /api/auth/providers tells the client what is available.

  1. Google Cloud Console → create or pick a project.

  2. APIs & Services → OAuth consent screen → External → fill in app name, support email, and the privacy policy / terms URLs (/privacy, /terms).

  3. APIs & Services → Credentials → Create credentials → OAuth client ID → Application type Web application.

  4. Add the Authorised redirect URI — it must match byte for byte:

    http://localhost:3000/api/auth/google/callback     # development
    https://yourdomain.com/api/auth/google/callback    # production
    
  5. Copy the credentials into the environment:

    GOOGLE_CLIENT_ID=…apps.googleusercontent.com
    GOOGLE_CLIENT_SECRET=NEXT_PUBLIC_APP_URL=http://localhost:3000
    

NEXT_PUBLIC_APP_URL is what builds the redirect URI. If the app is served on a different port than the value configured here, Google rejects the handshake with redirect_uri_mismatch.

While the consent screen is in Testing, only accounts listed under Test users can sign in. Publish it before real users arrive.


4. What stops one person from making twenty accounts

Four layers, in order of how much they matter:

  1. Email confirmation. Signup issues no session. The account is inert until the emailed link is opened, so unconfirmed rows are worthless to an abuser.
  2. Alias-resistant uniqueness. users.email_key normalises the address before the unique index sees it: t.i.mo+throwaway7@googlemail.com and timo@gmail.com collapse to the same key. Gmail dots are dropped and +tags are stripped on every provider.
  3. Disposable domains. A built-in list rejects the common throwaway providers (mailinator, guerrillamail, yopmail, …). Deliberately short — an exhaustive list is a losing race. Extend DISPOSABLE_DOMAINS in src/lib/auth/email.ts or swap in a maintained feed.
  4. Rate limits. Signup 5/h per IP and 3/h per address; resend 5/h per IP and 3/h per address; login 20/15min per IP and 10/15min per address.

If +tag stripping ever bites a legitimate user, flip STRIP_PLUS_TAGS_EVERYWHERE to false in src/lib/auth/email.ts — Gmail handling stays intact either way.

Rate-limit scope: the counters live in process memory, so each replica gets its own budget. Move them to Redis or a table before scaling horizontally.


5. Endpoints

Route Method Behaviour
/api/auth/signup POST Creates an unverified account, mails the link. No session.
/api/auth/login POST Session cookie on success. 403 email_not_verified until confirmed.
/api/auth/logout POST Deletes the session row and the cookie.
/api/auth/session GET { user } or { user: null }. Never errors.
/api/auth/verify GET Target of the emailed link → redirects to /auth/verified.
/api/auth/resend-verification POST Always answers "sent" — no account-existence oracle.
/api/auth/forgot-password POST Mails a reset link. Always answers "sent".
/api/auth/reset-password POST Spends the token, installs the password, kills all sessions.
/api/auth/google GET Redirect to Google with state + PKCE.
/api/auth/google/callback GET Links or creates the account, sets the session.
/api/auth/providers GET Booleans telling the UI which paths are wired up.

Errors are stable machine codes (email_taken, use_google, …) defined in src/lib/auth/errors.ts, which also holds the German and English wording.


6. Password reset

/auth/forgot-password → email → /auth/reset-password?token=… → sign in.

  • Links last 1 hour and work once. Requesting a new one invalidates the previous link immediately.
  • The token is checked before the form renders, so an expired or used link says so up front instead of after the user has typed a new password.
  • A successful reset does three things together: consumes the token, deletes every session of that account (an attacker who was already signed in loses access), and marks the address verified — opening the link proved inbox control.
  • No session is created by the reset itself. The user signs in with the new password.
  • Google-only accounts can use it too. It adds a local password alongside Google sign-in rather than dead-ending someone whose account has no password.

The request endpoint answers reset_sent for unknown addresses as well, so it cannot be used to test which addresses are registered. Rate limits: 3/h per address, 5/h per IP; the completion endpoint allows 10/h per IP.


7. Security notes

  • Passwords: scrypt (N=16384, r=8, p=1), per-password salt, digests are self-describing so the cost can be raised later without invalidating old hashes.
  • Sessions & links: the raw token exists only in the cookie or the email; the database holds a SHA-256 digest. A dump cannot be replayed as a login.
  • Google: authorization-code flow with PKCE (S256) and a state cookie compared in constant time. An address Google itself has not verified is refused.
  • Account linking: signing in with Google for an address that already has a password account links the two rather than creating a second account.
  • Timing: an unknown address burns the same scrypt cost as a real one, so login cannot be used to enumerate registered addresses.
  • Enumeration trade-off: signup does reveal that an address is taken — that is unavoidable when the product promises one account per email. The resend endpoint stays silent precisely because it needs no credentials.

8. Not built yet

  • Session-aware app surface. The dashboard and /api/receipts still take a client-supplied ?userId=, which any caller can set to any value. Auth exists but nothing consumes it yet — wiring getCurrentUser() into those routes is the next step, and the real fix for that hole.
  • Email change. Once an address is set there is no flow to move an account to a different one. It would need the same double-confirmation pattern (confirm the new address before releasing the old email_key).
  • Multi-instance rate limiting. See §4 — the counters are per-process.