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>
206 lines
8.6 KiB
Markdown
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.
|