diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthService.java b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java
new file mode 100644
index 0000000..fbb5f3c
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java
@@ -0,0 +1,76 @@
+package org.patinanetwork.patchats.auth;
+
+import java.time.Clock;
+import java.util.Locale;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.patinanetwork.patchats.api.member.db.repos.MemberRepo;
+import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken;
+import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository;
+import org.springframework.stereotype.Service;
+
+/**
+ * Orchestrates the magic-link flow: issuing links (request) and exchanging them for a member (verify). Magic links only
+ * sign in existing members — the sign-up form is the sole creator of member rows — and requesting a link never leaks
+ * whether an account exists.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AuthService {
+
+ private final MagicLinkTokenRepository tokens;
+ private final MemberRepo members;
+ private final TokenGenerator tokenGenerator;
+ private final MagicLinkEmailComposer emailComposer;
+ private final RequestLinkRateLimiter rateLimiter;
+ private final AuthProperties properties;
+ private final Clock clock;
+
+ /**
+ * Issues a fresh single-use link and invalidates any outstanding ones for the email. Being rate-limited surfaces as
+ * a visible 429 — the limiter runs before the member-existence check, so the 429 is registration-blind and
+ * reveals nothing. Unregistered emails are skipped silently (the same generic success as a real send), so account
+ * existence stays unobservable.
+ *
+ * @throws TooManyLinkRequestsException when the per-email or per-IP budget is exhausted
+ */
+ public void requestLink(final String rawEmail, final String clientIp) {
+ final String email = normalize(rawEmail);
+ if (!rateLimiter.tryAcquire(email, clientIp)) {
+ log.warn("Rate-limited magic-link request for {} from {}", email, clientIp);
+ throw new TooManyLinkRequestsException();
+ }
+ if (members.getMemberByEmail(email).isEmpty()) {
+ log.info("Skipping magic-link request for unregistered email {}", email);
+ return;
+ }
+ final GeneratedToken token = tokenGenerator.generate();
+ tokens.deleteByEmail(email);
+ tokens.insertToken(
+ UUID.randomUUID(), email, token.hash(), clock.instant().plus(properties.getMagicLinkTtl()));
+ emailComposer.send(email, token.raw());
+ }
+
+ /**
+ * Atomically consumes the presented token and resolves the member behind it.
+ *
+ * @throws InvalidMagicLinkException when the token is unknown, already used, or expired — or when the member no
+ * longer exists (deleted between send and click); the message stays generic either way
+ */
+ public Member verify(final String rawToken) {
+ final String email = tokens.consumeAndReturnEmail(TokenGenerator.hash(rawToken), clock.instant())
+ .orElseThrow(InvalidMagicLinkException::new);
+ return members.getMemberByEmail(email).orElseThrow(InvalidMagicLinkException::new);
+ }
+
+ /**
+ * Lowercases and trims before any lookup or token write. {@code MemberRepo.getMemberByEmail} matches the column
+ * exactly, so this is the single place email casing is reconciled — every path through this service must use it.
+ */
+ private static String normalize(final String email) {
+ return email.trim().toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java
new file mode 100644
index 0000000..cb35685
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java
@@ -0,0 +1,9 @@
+package org.patinanetwork.patchats.auth;
+
+/** Thrown when a presented magic-link token is unknown, already used, or expired. Maps to a 400 failure envelope. */
+public class InvalidMagicLinkException extends RuntimeException {
+
+ public InvalidMagicLinkException() {
+ super("This sign-in link is invalid or has expired. Request a new one.");
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java
new file mode 100644
index 0000000..1a25430
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java
@@ -0,0 +1,53 @@
+package org.patinanetwork.patchats.auth;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.patinanetwork.patchats.email.EmailSender;
+import org.patinanetwork.patchats.email.OutgoingEmail;
+import org.patinanetwork.patchats.email.TemplateRenderer;
+import org.springframework.stereotype.Component;
+
+/**
+ * Builds and delivers the sign-in email. Goes through the {@link EmailSender} port directly (not {@code EmailService},
+ * whose batch request/response shape is for admin-triggered sends), so the dev profile's logging sender prints the full
+ * body — including the link — to the backend console.
+ */
+@Component
+@RequiredArgsConstructor
+public class MagicLinkEmailComposer {
+
+ private static final String SUBJECT = "Your PatChats sign-in link";
+ private static final String BODY_TEMPLATE = """
+ Hi,
+
+ Click this link to sign in to PatChats:
+
+ ${link}
+
+ The link expires in ${ttlMinutes} minutes and can only be used once.
+
+ If you didn't request this, you can safely ignore this email.""";
+
+ private final TemplateRenderer renderer;
+ private final EmailSender sender;
+ private final AuthProperties properties;
+
+ public void send(final String email, final String rawToken) {
+ final String baseUrl = trimTrailingSlash(properties.getBaseUrl());
+ final String link = "%s/auth/verify?token=%s".formatted(baseUrl, rawToken);
+ final String body = renderer.render(
+ BODY_TEMPLATE,
+ Map.of(
+ "link",
+ link,
+ "ttlMinutes",
+ String.valueOf(properties.getMagicLinkTtl().toMinutes())));
+ sender.send(new OutgoingEmail(List.of(email), SUBJECT, body, Optional.empty()));
+ }
+
+ private static String trimTrailingSlash(final String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java
new file mode 100644
index 0000000..c8f760d
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java
@@ -0,0 +1,51 @@
+package org.patinanetwork.patchats.auth;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import io.github.bucket4j.Bucket;
+import java.time.Duration;
+import org.springframework.stereotype.Component;
+
+/**
+ * Guards the request-link endpoint against inbox flooding: a small per-email budget plus a looser per-IP budget, both
+ * refilling over a 15-minute window. Buckets live in memory (bounded by an expire-after-access cache), which is
+ * per-instance and fine for the current single-node deployment; Bucket4j's distributed backends are the upgrade path if
+ * that changes.
+ */
+@Component
+public class RequestLinkRateLimiter {
+
+ private static final int EMAIL_CAPACITY = 3;
+ private static final int IP_CAPACITY = 10;
+ private static final Duration WINDOW = Duration.ofMinutes(15);
+
+ private final LoadingCache emailBuckets = buckets(EMAIL_CAPACITY);
+ private final LoadingCache ipBuckets = buckets(IP_CAPACITY);
+
+ /**
+ * Consumes one request from both budgets; permitted only when neither is exhausted. If the IP budget denies after
+ * the email budget consumed, the email token is returned — a request blocked by one limit must not silently drain
+ * the other.
+ */
+ public boolean tryAcquire(final String email, final String clientIp) {
+ final Bucket emailBucket = emailBuckets.getUnchecked(email);
+ final Bucket ipBucket = ipBuckets.getUnchecked(clientIp);
+ if (!emailBucket.tryConsume(1)) {
+ return false;
+ }
+ if (ipBucket.tryConsume(1)) {
+ return true;
+ }
+ emailBucket.addTokens(1);
+ return false;
+ }
+
+ private static LoadingCache buckets(final int capacity) {
+ return CacheBuilder.newBuilder()
+ .expireAfterAccess(WINDOW.multipliedBy(2))
+ .build(CacheLoader.from(key -> Bucket.builder()
+ .addLimit(limit -> limit.capacity(capacity).refillGreedy(capacity, WINDOW))
+ .build()));
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java
new file mode 100644
index 0000000..f68f84a
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java
@@ -0,0 +1,12 @@
+package org.patinanetwork.patchats.auth;
+
+/**
+ * Thrown when the request-link rate limit is hit. Maps to HTTP 429 with a friendly message. The limiter runs before the
+ * member-existence check, so the 429 is registration-blind — it reveals nothing about whether the email has an account.
+ */
+public class TooManyLinkRequestsException extends RuntimeException {
+
+ public TooManyLinkRequestsException() {
+ super("Too many sign-in requests. Please wait a few minutes and try again.");
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java
new file mode 100644
index 0000000..f708c9d
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java
@@ -0,0 +1,7 @@
+package org.patinanetwork.patchats.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+/** Body of {@code POST /api/auth/request-link}. */
+public record RequestLinkRequest(@NotBlank @Email String email) {}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java
new file mode 100644
index 0000000..c573c5a
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java
@@ -0,0 +1,15 @@
+package org.patinanetwork.patchats.auth.dto;
+
+import org.patinanetwork.patchats.api.member.db.models.Member;
+
+/**
+ * The signed-in member as seen by the frontend. Members always have a complete profile (the sign-up form is the only
+ * way one is created), so {@code name} is always present. {@code isAdmin} is always false until the admin domain lands.
+ */
+public record SessionResponse(String id, String name, String email, boolean isAdmin) {
+
+ public static SessionResponse of(final Member member) {
+ final String name = "%s %s".formatted(member.getFirstName(), member.getLastName());
+ return new SessionResponse(member.getId().toString(), name, member.getEmail(), false);
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java
new file mode 100644
index 0000000..fb3856a
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java
@@ -0,0 +1,6 @@
+package org.patinanetwork.patchats.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** Body of {@code POST /api/auth/verify}: the raw token from the emailed link. */
+public record VerifyRequest(@NotBlank String token) {}
diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
index 7501ad8..a296418 100644
--- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
+++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
@@ -1,6 +1,8 @@
package org.patinanetwork.patchats.common.web;
import java.util.stream.Collectors;
+import org.patinanetwork.patchats.auth.InvalidMagicLinkException;
+import org.patinanetwork.patchats.auth.TooManyLinkRequestsException;
import org.patinanetwork.patchats.common.dto.ApiResponder;
import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException;
import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException;
@@ -34,6 +36,16 @@ public ResponseEntity> handleMemberDuplicate(final MemberDupl
return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage()));
}
+ @ExceptionHandler(InvalidMagicLinkException.class)
+ public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) {
+ return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage()));
+ }
+
+ @ExceptionHandler(TooManyLinkRequestsException.class)
+ public ResponseEntity> handleTooManyLinkRequests(final TooManyLinkRequestsException ex) {
+ return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ApiResponder.failure(ex.getMessage()));
+ }
+
private String formatError(final FieldError error) {
return error.getField() + " " + error.getDefaultMessage();
}
diff --git a/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java
new file mode 100644
index 0000000..638c713
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java
@@ -0,0 +1,124 @@
+package org.patinanetwork.patchats.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.patinanetwork.patchats.api.member.db.repos.MemberRepo;
+import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository;
+
+class AuthServiceTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-03T12:00:00Z");
+
+ private final MagicLinkTokenRepository tokens = mock(MagicLinkTokenRepository.class);
+ private final MemberRepo members = mock(MemberRepo.class);
+ private final MagicLinkEmailComposer emailComposer = mock(MagicLinkEmailComposer.class);
+ private final RequestLinkRateLimiter rateLimiter = mock(RequestLinkRateLimiter.class);
+ private final AuthProperties properties = new AuthProperties();
+
+ private AuthService authService;
+
+ @BeforeEach
+ void setUp() {
+ properties.setBaseUrl("http://localhost:5173");
+ authService = new AuthService(
+ tokens,
+ members,
+ new TokenGenerator(),
+ emailComposer,
+ rateLimiter,
+ properties,
+ Clock.fixed(NOW, ZoneOffset.UTC));
+ }
+
+ @Test
+ void requestLinkNormalizesEmailAndStoresHashNotRaw() {
+ when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true);
+ when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(member("ann@example.com")));
+
+ authService.requestLink(" Ann@Example.COM ", "10.0.0.1");
+
+ verify(tokens).deleteByEmail("ann@example.com");
+ final ArgumentCaptor hash = ArgumentCaptor.forClass(String.class);
+ final ArgumentCaptor expiry = ArgumentCaptor.forClass(Instant.class);
+ verify(tokens).insertToken(any(UUID.class), eq("ann@example.com"), hash.capture(), expiry.capture());
+ final ArgumentCaptor raw = ArgumentCaptor.forClass(String.class);
+ verify(emailComposer).send(eq("ann@example.com"), raw.capture());
+
+ // The emailed value and the stored value must differ, and the stored one is the SHA-256 of the raw.
+ assertNotEquals(raw.getValue(), hash.getValue());
+ assertEquals(TokenGenerator.hash(raw.getValue()), hash.getValue());
+ assertEquals(NOW.plus(properties.getMagicLinkTtl()), expiry.getValue());
+ }
+
+ @Test
+ void requestLinkSurfacesRateLimitAs429() {
+ when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(false);
+
+ assertThrows(TooManyLinkRequestsException.class, () -> authService.requestLink("ann@example.com", "10.0.0.1"));
+
+ verifyNoInteractions(tokens, emailComposer);
+ }
+
+ @Test
+ void requestLinkSkipsUnregisteredEmailSilently() {
+ when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true);
+ when(members.getMemberByEmail("stranger@example.com")).thenReturn(Optional.empty());
+
+ authService.requestLink("stranger@example.com", "10.0.0.1");
+
+ verifyNoInteractions(tokens, emailComposer);
+ }
+
+ @Test
+ void verifyRejectsUnknownOrSpentToken() {
+ when(tokens.consumeAndReturnEmail(anyString(), any())).thenReturn(Optional.empty());
+
+ assertThrows(InvalidMagicLinkException.class, () -> authService.verify("bogus"));
+ }
+
+ @Test
+ void verifyReturnsTheMemberBehindTheToken() {
+ final Member existing = member("ann@example.com");
+ when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW))
+ .thenReturn(Optional.of("ann@example.com"));
+ when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(existing));
+
+ assertEquals(existing, authService.verify("raw-token"));
+ }
+
+ @Test
+ void verifyRejectsTokenWhoseMemberNoLongerExists() {
+ when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW))
+ .thenReturn(Optional.of("gone@example.com"));
+ when(members.getMemberByEmail("gone@example.com")).thenReturn(Optional.empty());
+
+ assertThrows(InvalidMagicLinkException.class, () -> authService.verify("raw-token"));
+ }
+
+ private static Member member(final String email) {
+ return Member.builder()
+ .id(UUID.randomUUID())
+ .email(email)
+ .firstName("Ann")
+ .lastName("Example")
+ .build();
+ }
+}
diff --git a/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java
new file mode 100644
index 0000000..b36432e
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java
@@ -0,0 +1,42 @@
+package org.patinanetwork.patchats.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.patinanetwork.patchats.email.OutgoingEmail;
+import org.patinanetwork.patchats.email.TemplateRenderer;
+
+class MagicLinkEmailComposerTest {
+
+ private final AtomicReference captured = new AtomicReference<>();
+ private final AuthProperties properties = new AuthProperties();
+ private final MagicLinkEmailComposer composer =
+ new MagicLinkEmailComposer(new TemplateRenderer(), captured::set, properties);
+
+ @Test
+ void bodyContainsVerifyLinkAndTtl() {
+ properties.setBaseUrl("https://patchats.example.org");
+
+ composer.send("ann@example.com", "raw-token-123");
+
+ final OutgoingEmail email = captured.get();
+ assertNotNull(email);
+ assertEquals(List.of("ann@example.com"), email.to());
+ assertEquals("Your PatChats sign-in link", email.subject());
+ assertTrue(email.body().contains("https://patchats.example.org/auth/verify?token=raw-token-123"));
+ assertTrue(email.body().contains("expires in 15 minutes"));
+ }
+
+ @Test
+ void trailingSlashInBaseUrlDoesNotDoubleUp() {
+ properties.setBaseUrl("http://localhost:5173/");
+
+ composer.send("ann@example.com", "tok");
+
+ assertTrue(captured.get().body().contains("http://localhost:5173/auth/verify?token=tok"));
+ }
+}
diff --git a/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java
new file mode 100644
index 0000000..4395fe5
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java
@@ -0,0 +1,49 @@
+package org.patinanetwork.patchats.auth;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class RequestLinkRateLimiterTest {
+
+ private final RequestLinkRateLimiter rateLimiter = new RequestLinkRateLimiter();
+
+ @Test
+ void allowsThreeRequestsPerEmailThenDenies() {
+ for (int i = 0; i < 3; i++) {
+ assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1));
+ }
+ assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"));
+ }
+
+ @Test
+ void emailBudgetsAreIndependent() {
+ for (int i = 0; i < 3; i++) {
+ rateLimiter.tryAcquire("ann@example.com", "10.0.0.1");
+ }
+ assertTrue(rateLimiter.tryAcquire("bob@example.com", "10.0.0.2"));
+ }
+
+ @Test
+ void capsRequestsPerIpAcrossEmails() {
+ for (int i = 0; i < 10; i++) {
+ assertTrue(rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9"), "request " + (i + 1));
+ }
+ assertFalse(rateLimiter.tryAcquire("user10@example.com", "10.0.0.9"));
+ }
+
+ @Test
+ void ipDenialDoesNotBurnTheEmailBudget() {
+ // Exhaust the IP budget using other emails.
+ for (int i = 0; i < 10; i++) {
+ rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9");
+ }
+ // Denied by IP — but ann's email budget must be untouched...
+ assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.9"));
+ // ...so all 3 of her requests still succeed from a fresh IP.
+ for (int i = 0; i < 3; i++) {
+ assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1));
+ }
+ }
+}