Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
91 changes: 91 additions & 0 deletions docs/auth-feature.md
Original file line number Diff line number Diff line change
@@ -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
`<app.auth.base-url>/auth/verify?token=<raw>`. 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`).