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>
8.6 KiB
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.
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 outNULLfor every existing row, so the migration applies cleanly. Duplicates only surface when those old rows are backfilled.
Verify the whole thing end to end:
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)
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+ port587→ STARTTLS (most providers).SMTP_SECURE=true+ port465→ 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.
-
Google Cloud Console → create or pick a project.
-
APIs & Services → OAuth consent screen → External → fill in app name, support email, and the privacy policy / terms URLs (
/privacy,/terms). -
APIs & Services → Credentials → Create credentials → OAuth client ID → Application type Web application.
-
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 -
Copy the credentials into the environment:
GOOGLE_CLIENT_ID=…apps.googleusercontent.com GOOGLE_CLIENT_SECRET=… NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URLis what builds the redirect URI. If the app is served on a different port than the value configured here, Google rejects the handshake withredirect_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:
- Email confirmation. Signup issues no session. The account is inert until the emailed link is opened, so unconfirmed rows are worthless to an abuser.
- Alias-resistant uniqueness.
users.email_keynormalises the address before the unique index sees it:t.i.mo+throwaway7@googlemail.comandtimo@gmail.comcollapse to the same key. Gmail dots are dropped and+tagsare stripped on every provider. - 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_DOMAINSinsrc/lib/auth/email.tsor swap in a maintained feed. - 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/receiptsstill take a client-supplied?userId=, which any caller can set to any value. Auth exists but nothing consumes it yet — wiringgetCurrentUser()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.