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

206 lines
8.6 KiB
Markdown

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