From cbf9d36681032326925ee78e0f2b2937868d1e24 Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 6 Jul 2026 12:28:06 -0400 Subject: [PATCH] Auth: document the magic-link feature - docs/auth-feature.md mirrors the email-feature doc: shape, login walkthrough, dev log-link trick, and configuration table - AGENTS.md auth row now reflects magic links + Spring Session JDBC Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- docs/auth-feature.md | 91 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 docs/auth-feature.md diff --git a/AGENTS.md b/AGENTS.md index 013a0d6..6db1658 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ patchats/ | Backend build | Maven (`./mvnw`) | | Database | PostgreSQL + Flyway migrations | | Data access | Spring JDBC (plain SQL — no ORM) | -| Auth | Spring Security + OAuth2 (no passwords stored) | +| Auth | Magic links + Spring Session JDBC cookie sessions (no passwords) — see `docs/auth-feature.md` | | API docs | SpringDoc / OpenAPI → `/v3/api-docs` | | Frontend language | TypeScript (strict mode) | | Frontend framework | React 18, functional components + hooks | diff --git a/docs/auth-feature.md b/docs/auth-feature.md new file mode 100644 index 0000000..ebe6562 --- /dev/null +++ b/docs/auth-feature.md @@ -0,0 +1,91 @@ +# Auth feature (magic links) + +How PatChats signs members in: **magic links only** — no passwords, no OAuth. A user enters their +email, receives a single-use link, and clicking it establishes a server-side session delivered as an +httpOnly cookie. + +**Unified signup/login.** Requesting a link never reveals whether an account exists (the response is +always the same). If a link is verified for an unknown email, a minimal **shell** member row is +created (`full_name` null, `profile_completed_at` null) and the frontend routes the user to the +sign-up form to complete their profile. Wiring the sign-up form submission to the backend (and +setting `profile_completed_at`) is a separate ticket; until then shell members stay on `/sign-up`. + +## The shape + +``` +src/main/java/org/patinanetwork/patchats/auth/ + AuthController.java POST /api/auth/request-link | verify | logout, GET /api/session + AuthService.java request-link + verify orchestration + TokenGenerator.java SecureRandom 256-bit raw token + SHA-256 hex digest + MagicLinkEmailComposer.java builds the sign-in email via the EmailSender PORT + RequestLinkRateLimiter.java Bucket4j: 3/email + 10/IP per 15 min, in-memory buckets + AuthProperties.java @ConfigurationProperties("app.auth") → base-url, cookie-secure, magic-link-ttl + repo/ + MagicLinkTokenRepository.java JdbcClient; atomic UPDATE..RETURNING consume + MemberAccountRepository.java auth's minimal view of members (find, race-safe shell insert) + security/ + SecurityConfig.java filter chains, cookie serializer, CSRF rationale (read its javadoc) + AuthenticatedMember.java Serializable session principal (memberId + email) + ApiAuthenticationEntryPoint.java 401s in the ApiResponder envelope + +js/src/features/auth/ + Login.page.tsx /login — email → generic "check your email" panel + Verify.page.tsx /auth/verify?token=... — POSTs the token once on mount + api/ useSession, useRequestLink, useVerifyMagicLink, useLogout, auth.mock.ts +``` + +## How a login works + +1. `POST /api/auth/request-link {email}` — normalizes the email, rate-limits silently + (response is always the generic 200), deletes outstanding tokens for that email, stores a + **SHA-256 digest** of a fresh 256-bit token (raw is never persisted), and emails + `/auth/verify?token=`. Links expire after 15 minutes + (`app.auth.magic-link-ttl`). +2. The link lands on the **frontend** verify page, which POSTs the token. Email scanners only + prefetch GETs, so they cannot burn the single-use token. +3. `POST /api/auth/verify {token}` — consumes the token atomically + (`UPDATE .. WHERE consumed_at IS NULL AND expires_at > now RETURNING email`), resolves or + shell-creates the member, and performs a programmatic Spring Security login. Spring Session JDBC + persists the session (`spring_session` tables) and sets the `patchats_session` cookie + (httpOnly, SameSite=Lax, Secure outside dev, 30-day Max-Age). +4. Sessions expire after 30 days of inactivity (`spring.session.timeout`, sliding) and are purged by + Spring Session's built-in cleanup job. `POST /api/auth/logout` invalidates the session row. + +`GET /api/session` returns the member **fresh from the database** (never stale session state): +`{ id, name, email, isAdmin, profileCompleted }` — `name` null and `profileCompleted` false for +shell accounts; 401 in the envelope when signed out. The frontend `RequireAuth` guard sends +signed-out visitors to `/login` and incomplete profiles to `/sign-up`. + +Why CSRF protection is off, and why that is safe here, is documented on `SecurityConfig` — keep that +javadoc current if the cookie or CORS posture ever changes. + +## Manual test walkthrough (dev) + +```bash +just migrate # needs local Postgres; .env points DATABASE_NAME at the patchats DB +just dev # backend :8080 (dev profile) + frontend :5173 +``` + +1. Open `http://localhost:5173/login`, submit your email. +2. The dev profile does not send real email — `LoggingEmailSender` prints the full body to the + **backend terminal**. Copy the `http://localhost:5173/auth/verify?token=...` URL from the log. +3. Open it: a new email lands on `/sign-up` (shell account); an existing completed member lands on + `/`. Check DevTools → Application → Cookies for `patchats_session` (httpOnly, Lax, not Secure in + dev). +4. Open the same link again → "invalid or expired" (single-use). Requesting a second link + invalidates the first. A 4th rapid request for the same email still shows the generic panel but + sends nothing (rate limit, logged as a warning). +5. Log out from the header; guarded routes now redirect to `/login`. + +## Configuration + +| Property | Env var | Default | Meaning | +| ------------------------ | -------------------- | ----------------------- | ---------------------------------------- | +| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links | +| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie | +| `app.auth.magic-link-ttl`| — | `15m` | Link validity window | +| `spring.session.timeout` | — | `30d` | Session inactivity timeout | + +Schema lives in Flyway (`db/migration/V0004`–`V0006`); `spring.session.jdbc.initialize-schema` is +`never` so the app never races migrations, and runtime Flyway is disabled (migrations stay +out-of-band via `just migrate`).