# 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 <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](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.
