Skip to content

feat(auth): Go-native Better Auth port wired into the server - #2

Merged
jayleaton merged 9 commits into
mainfrom
feat/auth
Oct 22, 2026
Merged

jayleaton merged 9 commits into
mainfrom
feat/auth

Conversation

@jayleaton

Copy link
Copy Markdown
Owner

Problem

The foundation (#1) shipped with a dev-only auth fake (devauth, which signed every request in as a seeded user) and a stub "user" table (authstub). The template had no real sign-up, sign-in, sessions, email verification or password reset. Nothing authenticated the mobile app either.

How

packages/auth is a Go port of the Better Auth 1.7 core. It keeps Better Auth's table names and columns, endpoint paths, JSON shapes, error codes, cookie name and signing format, so data from the original app carries over, the Expo client keeps working, and later features (2FA, passkeys, OAuth, organisations) can be ported as sub-packages. This PR also wires it into the server.

server migrate:  auth.Migrate (goose_auth) ─▶ database.Migrate (goose_app)

request ─▶ CrossOriginProtection ─▶ auth.Middleware ─┬─▶ /api/auth/*   auth.Handler()  (Better Auth shapes)
                    (session from cookie or bearer)  ├─▶ /api/core/v1  api.Authenticated(server.CallerFrom)
                                                     └─▶ /healthz, UI
shutdown:        srv.Shutdown ─▶ auth.Shutdown (flush queued emails)
  • Auth flows:
    • Email + password sign-up, sign-in, sign-out and get-session.
    • Passwords are hashed with argon2id, compared in constant time, and checked against a dummy hash for unknown emails, so timing doesn't reveal which accounts exist.
    • Better Auth scrypt hashes imported from the original app still verify, and are rehashed to argon2id on the next sign-in.
  • Email links:
    • Email verification: 24 h links that sign the user in, with resend via send-verification-email.
    • Password reset: 1 h single-use tokens. A reset revokes every session and every other outstanding reset link.
  • Sessions are database rows: 7-day lifetime, extended once a day on use, revocable immediately (no cookie cache).
    • The cookie is HttpOnly, SameSite=Lax, Secure on https, and signed with AUTH_SECRET.
    • Authorization: Bearer works on the same session rows for mobile.
  • Request protection:
    • Better Auth's trusted-origin CSRF check and open-redirect checks on callback URLs.
    • Per-IP rate limits (in-memory by default, pluggable).
  • Mail: the dev mailer logs links. The Resend mailer sends the original email wording over plain HTTPS.
  • Wiring:
    • cmd/server builds *auth.Auth from config. It uses Resend when RESEND_API_KEY is set, and email verification follows config.Features.
    • Trusted origins are PUBLIC_URL, TRUSTED_ORIGINS and the Expo origins (exp:// only outside production).
    • server.CallerFrom adapts auth.UserFrom to api.Caller.
    • dbtest and sqlc read packages/auth/migrations. devauth, authstub and Features.DevAuth are deleted.
  • Hardening:
    • sqlc omit_unused_structs keeps credential tables out of packages/database.
    • depguard plus a layering probe keep packages/auth from importing packages/database.
    • make ci now runs the layering proof on its own. Under make -j, its temporary probe packages had made check-generated fail.
  • Docs: README, AGENTS.md, CONTEXT.md and .env.example are updated. packages/auth/README.md covers the endpoints, how to port a Better Auth feature, and the security notes.

Contract additions (vs BUILD-PLAN; no signature changed)

  • auth.Config gained optional fields: MinPasswordLength, MaxPasswordLength, LoginPath, RateLimiter, ClientIPHeader, Logger and MailTimeout.
  • New methods: SendVerificationEmail, used by the original's resend button, and Shutdown.
  • New package-level items:
    • MobileTrustedOrigins(production) and Migrations;
    • NewDevMailer / NewResendMailer, NewMemoryRateLimiter;
    • the Error type and Err* values.
  • New endpoints: POST /send-verification-email, GET /reset-password/{token} (the link in the email) and GET /ok.
  • /api/auth/* returns Better Auth response shapes ({"code","message"} on error), not the envelope, because the original did and the Expo client expects them. RequireUser and the API still use the envelope.
  • Config: CLIENT_IP_HEADER (set X-Real-IP on Railway). Features.DevAuth became Features.MobileDevOrigins.

Evidence

Before (main @ a6562bd): POST /api/auth/sign-in/email answers 501 {"error":"auth is not wired yet (dev auth fake)"}. In development every request is the seeded dev@example.com.

After: make ci is green locally:

  • build and generated-code diff (unchanged sqlc output);
  • golangci-lint: 0 issues;
  • layering proof: 7/7 probes rejected;
  • go test -race ./...: all packages pass, packages/auth coverage 89.7% (measured on the last pre-rebase run; only Migrations and a loop changed since);
  • 3 fuzz targets ran for 10–15 s each with no findings.

The auth tests run against real Postgres and cover every flow plus its reverse state: sign-out and reset revoke sessions, expired and used tokens are rejected, wrong password, unverified sign-in, bearer, origin check, rate limits, concurrent reset-token use, and a squatted sign-up reclaimed by the address owner. The new server.TestAuthWiring drives sign-up, cookie GET/PATCH /api/core/v1/users, bearer, sign-out and the 401 through server.New.

Real server (make db-up && make migrate && make dev, curl):

migrate                          goose_auth=1, goose_app=1; tables user, session, account, verification
1 sign-up                        200 {"token":"eo9f…","user":{…"emailVerified":false…}}
2 send-verification-email        {"status":true}; dev mailer logged http://…/api/auth/verify-email?token=eyJ…
3 follow link                    302 -> /auth/verify-email/success; emailVerified=true
4 sign-in                        200, cookie better-auth.session_token
5 GET  /api/core/v1/users        200 {"data":{"user":{…"email":"ada@example.com"…}}}
6 PATCH /api/core/v1/users       200 "name":"Ada Lovelace"
8 sign-out                       {"success":true}
9 GET users after sign-out       401 {"data":null,"error":"Unauthorized"}
10-13 bearer GET/PATCH/sign-out  200 / "name":"Ada M" / {"success":true} / then 401
14 cross-site PATCH              403
SIGTERM                          "shutting down", clean exit

Known residual risk (from packages/auth/README.md)

  • Squatted sign-up: if a squatter signs up again after the real owner, the owner's newest verification email is for the squatter's password. Clicking it verifies the account under a password the owner doesn't know. A password reset recovers it.
  • Session tokens are stored in plaintext, like Better Auth, so database backups are credential-equivalent.
  • GET verification links that sign the user in allow login CSRF into the attacker's own account (inherited from Better Auth).
  • Imported scrypt users: until their first sign-in, a wrong-password attempt takes scrypt time instead of argon2id time.
  • Rate limits are in-memory per instance. Plug in a shared RateLimiter before running several instances. Behind a proxy without CLIENT_IP_HEADER, every client shares one bucket.
  • Not ported yet: change-email, rememberMe, cookie cache.

Merge Danger

Door: two-way for code. The auth migration creates the user, session, account and verification tables and the goose_auth table. Nothing is deployed yet, so a revert costs nothing now. Once real users sign up, rolling back the schema would drop their accounts.

Blast Radius: platform. Every authenticated route, the API caller, migrations, sqlc input, dbtest templates and make ci change. apps/web/ui (feat/web-ui) should call the auth service methods and SetSessionCookie. Its HTML form routes need their own CSRF protection; http.CrossOriginProtection already covers them.

🤖 Generated with Claude Code

jayleaton and others added 9 commits October 22, 2026 13:41
Adds pgx, goose and x/crypto. The user, session, account and verification
tables keep Better Auth's names and columns; Migrate runs them under their
own advisory lock so app migrations never contend with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-up, sign-in, sign-out, get-session, email verification and password
reset at Better Auth's paths and response shapes. argon2id hashing with a
dummy hash for unknown emails, Better Auth scrypt hashes accepted and
upgraded, signed better-call cookies and bearer tokens on the same session
rows, the trusted-origin CSRF check and per-IP rate limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each run creates and drops its own database. Covers every flow and its
reverse state: sign-out and reset revoke sessions, expired and used tokens,
wrong passwords, unverified sign-in, bearer, origin and rate limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd stale reset links

Fixes from a security review:
- verification links carry a fingerprint of the password hash they were
  issued for; a duplicate sign-up of an unverified account replaces its
  password, so the address owner reclaims a squatted account
- verifying revokes other sessions; a reset revokes every other reset link
- client IPs are normalised (IPv6 /64, IPv4-mapped) before rate limiting
- host-only wildcard origins only match http(s) URLs
- Origin: null is accepted with Sec-Fetch-Site: same-origin, as in Better Auth
- sign-out with bearer and cookie revokes both sessions
- request-password-reset requires redirectTo
- Shutdown no longer races sendMail on the WaitGroup
- expired verification cleanup runs off the request path, indexed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- server migrate runs auth.Migrate (goose_auth) before app migrations;
  dbtest templates and sqlc read packages/auth/migrations
- serve builds *auth.Auth from config: Resend when RESEND_API_KEY is set,
  otherwise the dev mailer; email verification from config.Features;
  PUBLIC_URL, TRUSTED_ORIGINS and the Expo origins (exp:// outside
  production) as trusted origins; CLIENT_IP_HEADER for rate limits
- server.CallerFrom adapts auth.UserFrom to api.Caller
- graceful shutdown waits for queued auth emails
- devauth, authstub and Features.DevAuth are gone
- sqlc omits unused structs so credential tables stay out of packages/database
- depguard keeps packages/auth from importing packages/database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under make -j its temporary probe packages showed up as untracked Go files
in check-generated (and would break lint/test). Lint and tests still run in
parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton marked this pull request as ready for review October 22, 2026 06:53
@jayleaton
jayleaton merged commit c7cf5c5 into main Oct 22, 2026
8 checks passed
@jayleaton
jayleaton deleted the feat/auth branch October 22, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant