# 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. ```bash 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: ```bash 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) ```env 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 ``` - `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](https://console.cloud.google.com/) → 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: ```env 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.