diff --git a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
deleted file mode 100644
index 3b9bab1..0000000
--- a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.patinanetwork.patchats.api.auth.security;
-
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Profile;
-import org.springframework.http.HttpMethod;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.web.SecurityFilterChain;
-
-@Configuration
-public class SecurityConfig {
-
- /**
- * Default/production chain. Sending email is admin-only.
- *
- *
NOTE: authentication (OAuth2 login / roles) is not yet wired, so this rule currently fails closed — every
- * caller is denied because no security context is populated. Once the auth domain authenticates admins, the rule
- * begins to discriminate admins from other users. Other endpoints remain open for now, matching prior behaviour.
- */
- @Bean
- @Profile("!dev")
- SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
- return http.csrf(csrf -> csrf.disable())
- .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**")
- .hasRole("ADMIN")
- .anyRequest()
- .permitAll())
- .build();
- }
-
- /** Local-dev chain: everything is permitted so the email endpoint can be exercised without auth. */
- @Bean
- @Profile("dev")
- SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception {
- return http.csrf(csrf -> csrf.disable())
- .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
- .build();
- }
-}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java
new file mode 100644
index 0000000..64f774e
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java
@@ -0,0 +1,22 @@
+package org.patinanetwork.patchats.auth;
+
+import java.time.Duration;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/** Auth configuration, bound from {@code app.auth.*}. */
+@ConfigurationProperties(prefix = "app.auth")
+@Getter
+@Setter
+public class AuthProperties {
+
+ /** Public origin of the SPA; magic links point at {@code /auth/verify?token=...}. */
+ private String baseUrl;
+
+ /** Whether the session cookie carries the {@code Secure} flag. Off only for plain-HTTP dev. */
+ private boolean cookieSecure = true;
+
+ /** How long an emailed magic link stays valid. */
+ private Duration magicLinkTtl = Duration.ofMinutes(15);
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java
new file mode 100644
index 0000000..c449a62
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java
@@ -0,0 +1,41 @@
+package org.patinanetwork.patchats.auth;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.HexFormat;
+import org.springframework.stereotype.Component;
+
+/**
+ * Mints the opaque magic-link tokens. The raw token goes into the emailed link and is never persisted; only its SHA-256
+ * hex digest is stored, so a database leak cannot be replayed as a login.
+ */
+@Component
+public class TokenGenerator {
+
+ private static final int TOKEN_BYTES = 32;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ /** A freshly minted token: {@code raw} for the email link, {@code hash} for the database. */
+ public record GeneratedToken(String raw, String hash) {}
+
+ public GeneratedToken generate() {
+ final byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ final String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ return new GeneratedToken(raw, hash(raw));
+ }
+
+ /** SHA-256 hex digest of a raw token, used to look up what the client presents. */
+ public static String hash(final String rawToken) {
+ try {
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)));
+ } catch (final NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 is unavailable", ex);
+ }
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java
new file mode 100644
index 0000000..4e8ea62
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java
@@ -0,0 +1,48 @@
+package org.patinanetwork.patchats.auth.repo;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+/** Plain-SQL access to {@code magic_link_tokens}. Only token hashes ever touch this table. */
+@Repository
+@RequiredArgsConstructor
+public class MagicLinkTokenRepository {
+
+ private final JdbcClient jdbc;
+
+ /** Invalidates every outstanding link for an email; called before issuing a new one. */
+ public void deleteByEmail(final String email) {
+ jdbc.sql("DELETE FROM magic_link_tokens WHERE email = :email")
+ .param("email", email)
+ .update();
+ }
+
+ public void insert(final UUID id, final String email, final String tokenHash, final Instant expiresAt) {
+ jdbc.sql("INSERT INTO magic_link_tokens (id, email, token_hash, expires_at)"
+ + " VALUES (:id, :email, :tokenHash, :expiresAt)")
+ .param("id", id)
+ .param("email", email)
+ .param("tokenHash", tokenHash)
+ .param("expiresAt", expiresAt.atOffset(ZoneOffset.UTC))
+ .update();
+ }
+
+ /**
+ * Atomically consumes an unexpired, unused token and returns its email. The single UPDATE guarantees a token can
+ * only ever log in one caller, even under concurrent requests.
+ */
+ public Optional consume(final String tokenHash, final Instant now) {
+ return jdbc.sql("UPDATE magic_link_tokens SET consumed_at = :now"
+ + " WHERE token_hash = :tokenHash AND consumed_at IS NULL AND expires_at > :now"
+ + " RETURNING email")
+ .param("tokenHash", tokenHash)
+ .param("now", now.atOffset(ZoneOffset.UTC))
+ .query(String.class)
+ .optional();
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java
new file mode 100644
index 0000000..00bf53d
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java
@@ -0,0 +1,15 @@
+package org.patinanetwork.patchats.auth.repo;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * The auth domain's minimal view of a member: just enough to identify who logged in. A "shell" account (created by
+ * verifying a magic link before filling in the sign-up form) has no name and no {@code profileCompletedAt}.
+ */
+public record MemberAccount(UUID id, String email, String fullName, OffsetDateTime profileCompletedAt) {
+
+ public boolean profileCompleted() {
+ return profileCompletedAt != null;
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java
new file mode 100644
index 0000000..7b67c8b
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java
@@ -0,0 +1,57 @@
+package org.patinanetwork.patchats.auth.repo;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+/**
+ * The auth domain's plain-SQL view of {@code members}. Lives inside {@code auth} until a second consumer needs member
+ * data (the same graduation rule the frontend uses for shared code).
+ */
+@Repository
+@RequiredArgsConstructor
+public class MemberAccountRepository {
+
+ private static final String SELECT = "SELECT id, email, full_name, profile_completed_at FROM members";
+
+ private final JdbcClient jdbc;
+
+ /** Case-insensitive lookup: legacy rows may hold mixed-case emails while auth normalizes to lowercase. */
+ public Optional findByEmail(final String email) {
+ return jdbc.sql(SELECT + " WHERE LOWER(email) = :email")
+ .param("email", email)
+ .query(MemberAccountRepository::map)
+ .optional();
+ }
+
+ public Optional findById(final UUID id) {
+ return jdbc.sql(SELECT + " WHERE id = :id")
+ .param("id", id)
+ .query(MemberAccountRepository::map)
+ .optional();
+ }
+
+ /**
+ * Creates a shell account for a first-time login. {@code ON CONFLICT DO NOTHING} makes this safe against a
+ * concurrent insert of the same email; callers re-select afterwards.
+ */
+ public void insertShell(final UUID id, final String email) {
+ jdbc.sql("INSERT INTO members (id, email) VALUES (:id, :email) ON CONFLICT (email) DO NOTHING")
+ .param("id", id)
+ .param("email", email)
+ .update();
+ }
+
+ private static MemberAccount map(final ResultSet rs, final int rowNum) throws SQLException {
+ return new MemberAccount(
+ rs.getObject("id", UUID.class),
+ rs.getString("email"),
+ rs.getString("full_name"),
+ rs.getObject("profile_completed_at", OffsetDateTime.class));
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java
new file mode 100644
index 0000000..83de3bc
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java
@@ -0,0 +1,15 @@
+package org.patinanetwork.patchats.common.config;
+
+import java.time.Clock;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/** Provides the single injectable {@link Clock} so services never call {@code Instant.now()} directly. */
+@Configuration
+public class ClockConfig {
+
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+}
diff --git a/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java
new file mode 100644
index 0000000..f68a6d2
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.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.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken;
+
+class TokenGeneratorTest {
+
+ private final TokenGenerator generator = new TokenGenerator();
+
+ @Test
+ void rawTokenIsUrlSafeAnd256Bits() {
+ final GeneratedToken token = generator.generate();
+
+ // 32 bytes base64url without padding -> 43 chars, no characters needing URL encoding.
+ assertEquals(43, token.raw().length());
+ assertTrue(token.raw().matches("[A-Za-z0-9_-]+"));
+ }
+
+ @Test
+ void hashMatchesSha256HexOfRaw() {
+ final GeneratedToken token = generator.generate();
+
+ assertEquals(TokenGenerator.hash(token.raw()), token.hash());
+ assertEquals(64, token.hash().length());
+ assertTrue(token.hash().matches("[0-9a-f]+"));
+ }
+
+ @Test
+ void generatedTokensAreUnique() {
+ assertNotEquals(generator.generate().raw(), generator.generate().raw());
+ }
+
+ @Test
+ void hashIsDeterministic() {
+ assertEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abc"));
+ assertNotEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abd"));
+ }
+}