From f804f7bafdbd771fc4d03a889c20886f790dcb08 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Mon, 13 Jul 2026 10:58:09 +0000 Subject: [PATCH 1/5] forge: open work branch for codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46 --- ...5657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md diff --git a/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md b/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md new file mode 100644 index 0000000..bb020f7 --- /dev/null +++ b/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md @@ -0,0 +1,3 @@ +# codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46 + +Forge 이슈 작업 브랜치 `forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46`. -- 2.49.1 From 7689c9b968f1679c39ca60095d89965cb122b9cf Mon Sep 17 00:00:00 2001 From: forge-bot Date: Mon, 13 Jul 2026 10:58:39 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=97=85=EB=AC=B4=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B6=88=EB=B3=80=EC=8B=9D=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?(codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth-migration/INVARIANTS.md | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 auth-migration/INVARIANTS.md diff --git a/auth-migration/INVARIANTS.md b/auth-migration/INVARIANTS.md new file mode 100644 index 0000000..326542c --- /dev/null +++ b/auth-migration/INVARIANTS.md @@ -0,0 +1,51 @@ +# Authentication & Authorization Invariants — auth-migration + +## 1. Authentication Rules + +| ID | Rule | Trigger | Expected Behavior | Error Code | +|----|------|---------|-------------------|------------| +| AUTH-001 | JWT must contain `sub` claim | Any protected endpoint | Reject with 401 if `sub` missing/empty | `AUTH_MISSING_SUB` | +| AUTH-002 | JWT `exp` must be in future | Any protected endpoint | Reject with 401 if expired | `AUTH_TOKEN_EXPIRED` | +| AUTH-003 | JWT signature must verify against secret | Any protected endpoint | Reject with 401 if signature invalid | `AUTH_INVALID_SIGNATURE` | +| AUTH-004 | Bearer token required in Authorization header | Any protected endpoint | Reject with 401 if header missing | `AUTH_MISSING_TOKEN` | +| AUTH-005 | Token format must be `Bearer ` | Any protected endpoint | Reject with 401 if malformed | `AUTH_MALFORMED_TOKEN` | + +## 2. Authorization Rules + +| ID | Rule | Scope | Expected Behavior | Error Code | +|----|------|-------|-------------------|------------| +| AUTH-010 | ADMIN role required for user management | `/api/admin/**` | Reject with 403 if not ADMIN | `AUTH_FORBIDDEN_ADMIN` | +| AUTH-011 | Resource owner or ADMIN can modify | `/api/users/{id}/**` | Reject with 403 if neither | `AUTH_FORBIDDEN_RESOURCE` | +| AUTH-012 | Any authenticated user can read public profiles | `/api/users/public/**` | Allow 200 if authenticated | — | +| AUTH-013 | Role hierarchy: ADMIN > MANAGER > USER | All role-gated endpoints | Higher role inherits lower permissions | — | + +## 3. Error Response Schema + +| HTTP Status | Error Code | Response Body Shape | +|-------------|------------|---------------------| +| 401 | `AUTH_MISSING_TOKEN` | `{"error":"AUTH_MISSING_TOKEN","message":"Authorization header required","timestamp":}` | +| 401 | `AUTH_MALFORMED_TOKEN` | `{"error":"AUTH_MALFORMED_TOKEN","message":"Bearer token format required","timestamp":}` | +| 401 | `AUTH_TOKEN_EXPIRED` | `{"error":"AUTH_TOKEN_EXPIRED","message":"Token has expired","timestamp":}` | +| 401 | `AUTH_INVALID_SIGNATURE` | `{"error":"AUTH_INVALID_SIGNATURE","message":"Token signature verification failed","timestamp":}` | +| 401 | `AUTH_MISSING_SUB` | `{"error":"AUTH_MISSING_SUB","message":"Token missing subject claim","timestamp":}` | +| 403 | `AUTH_FORBIDDEN_ADMIN` | `{"error":"AUTH_FORBIDDEN_ADMIN","message":"Admin role required","timestamp":}` | +| 403 | `AUTH_FORBIDDEN_RESOURCE` | `{"error":"AUTH_FORBIDDEN_RESOURCE","message":"Not authorized for this resource","timestamp":}` | + +## 4. Session / Cookie Semantics + +| ID | Rule | Behavior | +|----|------|----------| +| SESS-001 | Session cookie `HttpOnly` | Must be `true` | +| SESS-002 | Session cookie `Secure` | Must be `true` in production | +| SESS-003 | Session cookie `SameSite` | Must be `Lax` or `Strict` | +| SESS-004 | CSRF token required for state-changing ops | POST/PUT/DELETE without CSRF → 403 | + +## 5. Migration Checklist + +- [ ] Spring Security filter chain preserves order: JWT filter before session filter +- [ ] `@PreAuthorize` annotations match existing role-gate logic +- [ ] `AuthenticationEntryPoint` returns exact error schema (AUTH_* codes) +- [ ] `AccessDeniedHandler` returns exact error schema (AUTH_FORBIDDEN_* codes) +- [ ] Cookie attributes (`HttpOnly`, `Secure`, `SameSite`) set via `CookieSecurityConfigurer` +- [ ] CSRF protection enabled for state-changing endpoints +- [ ] Role hierarchy bean `RoleHierarchyImpl` wired with ADMIN > MANAGER > USER -- 2.49.1 From 817bce3478de57221c4459e74abff20da8f6deac Mon Sep 17 00:00:00 2001 From: forge-bot Date: Mon, 13 Jul 2026 10:58:40 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=97=85=EB=AC=B4=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B6=88=EB=B3=80=EC=8B=9D=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?(codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/auth/AuthInvariantTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java diff --git a/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java b/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java new file mode 100644 index 0000000..cc5529b --- /dev/null +++ b/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java @@ -0,0 +1,119 @@ +package com.example.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Verification tests for AUTH-* invariants. + * These tests MUST pass after Spring migration to confirm behavioral parity. + */ +@SpringBootTest +@AutoConfigureMockMvc +public class AuthInvariantTest { + + @Autowired + private MockMvc mockMvc; + + // AUTH-004: Bearer token required + @Test + void missingAuthorizationHeader_returns401WithAUTH_MISSING_TOKEN() throws Exception { + mockMvc.perform(get("/api/protected/resource")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MISSING_TOKEN")) + .andExpect(jsonPath("$.message").value("Authorization header required")) + .andExpect(jsonPath("$.timestamp").exists()); + } + + // AUTH-005: Bearer format required + @Test + void malformedBearerToken_returns401WithAUTH_MALFORMED_TOKEN() throws Exception { + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Basic dXNlcjpwYXNz")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MALFORMED_TOKEN")) + .andExpect(jsonPath("$.message").value("Bearer token format required")); + } + + // AUTH-002: Expired token + @Test + void expiredToken_returns401WithAUTH_TOKEN_EXPIRED() throws Exception { + String expiredToken = TestTokenUtil.generateExpiredToken(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + expiredToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_TOKEN_EXPIRED")) + .andExpect(jsonPath("$.message").value("Token has expired")); + } + + // AUTH-003: Invalid signature + @Test + void invalidSignature_returns401WithAUTH_INVALID_SIGNATURE() throws Exception { + String tamperedToken = TestTokenUtil.generateTamperedToken(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + tamperedToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_INVALID_SIGNATURE")) + .andExpect(jsonPath("$.message").value("Token signature verification failed")); + } + + // AUTH-001: Missing sub claim + @Test + void tokenWithoutSub_returns401WithAUTH_MISSING_SUB() throws Exception { + String noSubToken = TestTokenUtil.generateTokenWithoutSub(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + noSubToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MISSING_SUB")) + .andExpect(jsonPath("$.message").value("Token missing subject claim")); + } + + // AUTH-010: Admin role required + @Test + void nonAdminAccessingAdminEndpoint_returns403WithAUTH_FORBIDDEN_ADMIN() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/admin/users") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_ADMIN")) + .andExpect(jsonPath("$.message").value("Admin role required")); + } + + // AUTH-011: Resource owner check + @Test + void nonOwnerAccessingUserResource_returns403WithAUTH_FORBIDDEN_RESOURCE() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/users/999/profile") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_RESOURCE")) + .andExpect(jsonPath("$.message").value("Not authorized for this resource")); + } + + // AUTH-012: Public profile accessible to authenticated users + @Test + void authenticatedUserCanReadPublicProfile_returns200() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/users/public/johndoe") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()); + } + + // SESS-004: CSRF required for state-changing ops + @Test + void postWithoutCSRF_returns403() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(post("/api/users/profile") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"displayName\":\"test\"}")) + .andExpect(status().isForbidden()); + } +} -- 2.49.1 From e00a6b3be4ea411730fd5114480df1a42c22da76 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Mon, 13 Jul 2026 10:58:41 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=97=85=EB=AC=B4=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B6=88=EB=B3=80=EC=8B=9D=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?(codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/auth/TestTokenUtil.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 auth-migration/src/test/java/com/example/auth/TestTokenUtil.java diff --git a/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java b/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java new file mode 100644 index 0000000..6ece8c4 --- /dev/null +++ b/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java @@ -0,0 +1,71 @@ +package com.example.auth; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Test utility for generating JWT tokens with specific claims. + * Mirrors the token construction used in the legacy auth system. + */ +public final class TestTokenUtil { + + private static final String SECRET = "auth-migration-test-secret-key-must-be-at-least-256-bits-long"; + private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + + private TestTokenUtil() {} + + public static String generateExpiredToken() { + return Jwts.builder() + .setSubject("user123") + .setExpiration(new Date(System.currentTimeMillis() - 60_000)) + .signWith(KEY, SignatureAlgorithm.HS256) + .compact(); + } + + public static String generateTamperedToken() { + // Generate valid structure but sign with different key + SecretKey wrongKey = Keys.hmacShaKeyFor( + "wrong-key-for-tampering-test-must-be-256-bits-minimum".getBytes(StandardCharsets.UTF_8)); + return Jwts.builder() + .setSubject("user123") + .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) + .signWith(wrongKey, SignatureAlgorithm.HS256) + .compact(); + } + + public static String generateTokenWithoutSub() { + return Jwts.builder() + .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) + .signWith(KEY, SignatureAlgorithm.HS256) + .compact(); + } + + public static String generateTokenWithRole(String role) { + return Jwts.builder() + .setSubject("user123") + .claim("roles", List.of(role)) + .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) + .signWith(KEY, SignatureAlgorithm.HS256) + .compact(); + } + + public static String generateAdminToken() { + return generateTokenWithRole("ROLE_ADMIN"); + } + + public static String generateValidToken() { + return Jwts.builder() + .setSubject("user123") + .claim("roles", List.of("ROLE_USER")) + .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) + .signWith(KEY, SignatureAlgorithm.HS256) + .compact(); + } +} -- 2.49.1 From 4f39f1ee17ee18d5f7b0c01caa041177a9098a94 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Mon, 13 Jul 2026 10:58:43 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=97=85=EB=AC=B4=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B6=88=EB=B3=80=EC=8B=9D=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?(codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth-migration/pom.xml | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 auth-migration/pom.xml diff --git a/auth-migration/pom.xml b/auth-migration/pom.xml new file mode 100644 index 0000000..5fab683 --- /dev/null +++ b/auth-migration/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.example + auth-migration + 1.0.0-SNAPSHOT + jar + auth-migration + Authentication and Authorization invariant specification and tests + + + 17 + 0.12.5 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + -- 2.49.1