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>
This commit is contained in:
205
AUTH_SETUP_GUIDE.md
Normal file
205
AUTH_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user