From 36be2d6e9ed187f659e024762a9df2032a3a976c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:19:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20IOS=20=EB=94=A5=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EC=84=B8=EC=85=98=20=EC=BF=A0=ED=82=A4=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=B8=8C=EB=A6=BF?= =?UTF-8?q?=EC=A7=80=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bridge/NativeSessionBridgeService.java | 75 +++++++++++++++++++ .../controller/NativeSessionController.java | 59 +++++++++++++++ .../handler/OAuth2LoginSuccessHandler.java | 24 +++++- 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java create mode 100644 src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java new file mode 100644 index 00000000..1b6f3b2d --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java @@ -0,0 +1,75 @@ +package gg.agit.konect.global.auth.bridge; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; + +@Service +public class NativeSessionBridgeService { + + private static final int TOKEN_BYTES = 32; + private static final String KEY_PREFIX = "native:session-bridge:"; + private static final long MIN_TTL_SECONDS = 30L; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Nullable + private final StringRedisTemplate redis; + + private final Duration ttl; + + public NativeSessionBridgeService( + @Nullable StringRedisTemplate redis, + @Value("${app.native.session-bridge-token-ttl-seconds:120}") long ttlSeconds + ) { + this.redis = redis; + this.ttl = Duration.ofSeconds(Math.max(MIN_TTL_SECONDS, ttlSeconds)); + } + + public String issue(Integer userId) { + if (userId == null) { + throw new IllegalArgumentException("userId is required"); + } + + if (redis == null) { + throw new IllegalStateException("Redis is required for native session bridge token storage."); + } + + String token = generateToken(); + redis.opsForValue().set(KEY_PREFIX + token, userId.toString(), ttl); + return token; + } + + public Optional consume(@Nullable String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + + if (redis == null) { + throw new IllegalStateException("Redis is required for native session bridge token storage."); + } + + String value = redis.opsForValue().getAndDelete(KEY_PREFIX + token); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + + try { + return Optional.of(Integer.parseInt(value)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java new file mode 100644 index 00000000..f2801197 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java @@ -0,0 +1,59 @@ +package gg.agit.konect.global.auth.controller; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.global.auth.bridge.NativeSessionBridgeService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class NativeSessionController { + + @Value("${app.frontend.base-url}") + private String frontendBaseUrl; + + private final NativeSessionBridgeService nativeSessionBridgeService; + + @PublicApi + @GetMapping("/native/session/bridge") + public void bridge( + @RequestParam(name = "bridge_token", required = false) String bridgeToken, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + response.setHeader("Cache-Control", "no-store"); + + if (!StringUtils.hasText(bridgeToken)) { + response.sendError(HttpStatus.UNAUTHORIZED.value()); + return; + } + + Integer userId = nativeSessionBridgeService.consume(bridgeToken).orElse(null); + + if (userId == null) { + response.sendError(HttpStatus.UNAUTHORIZED.value()); + return; + } + + HttpSession existing = request.getSession(false); + if (existing != null) { + existing.invalidate(); + } + + HttpSession session = request.getSession(true); + session.setAttribute("userId", userId); + + response.sendRedirect(frontendBaseUrl + "/home"); + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java index 95b28dbe..be62a676 100644 --- a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java @@ -18,6 +18,7 @@ import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.auth.bridge.NativeSessionBridgeService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.config.SecurityProperties; import gg.agit.konect.global.exception.CustomException; @@ -38,6 +39,7 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final UserRepository userRepository; private final UnRegisteredUserRepository unRegisteredUserRepository; private final SecurityProperties securityProperties; + private final NativeSessionBridgeService nativeSessionBridgeService; @Override public void onAuthenticationSuccess( @@ -110,7 +112,27 @@ private void sendLoginSuccessResponse( String redirectUri = (String)session.getAttribute("redirect_uri"); session.removeAttribute("redirect_uri"); - response.sendRedirect(resolveSafeRedirect(redirectUri)); + String safeRedirect = resolveSafeRedirect(redirectUri); + + if (isAppleOauthCallback(safeRedirect)) { + String bridgeToken = nativeSessionBridgeService.issue(user.getId()); + safeRedirect = appendBridgeToken(safeRedirect, bridgeToken); + } + + response.sendRedirect(safeRedirect); + } + + private boolean isAppleOauthCallback(String redirectUri) { + return redirectUri != null && redirectUri.startsWith("konect://oauth/callback"); + } + + private String appendBridgeToken(String redirectUri, String bridgeToken) { + if (redirectUri.contains("bridge_token=")) { + return redirectUri; + } + + char joiner = redirectUri.contains("?") ? '&' : '?'; + return redirectUri + joiner + "bridge_token=" + bridgeToken; } private String extractEmail(OAuth2User oauthUser, Provider provider) { From e4f8a49e45bb4349da8168df9e2e3732fe664ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:23:11 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/{controller => bridge}/NativeSessionController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename src/main/java/gg/agit/konect/global/auth/{controller => bridge}/NativeSessionController.java (93%) diff --git a/src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java similarity index 93% rename from src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java rename to src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java index f2801197..a5c15e68 100644 --- a/src/main/java/gg/agit/konect/global/auth/controller/NativeSessionController.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java @@ -1,4 +1,4 @@ -package gg.agit.konect.global.auth.controller; +package gg.agit.konect.global.auth.bridge; import java.io.IOException; @@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.RestController; import gg.agit.konect.global.auth.annotation.PublicApi; -import gg.agit.konect.global.auth.bridge.NativeSessionBridgeService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; From 43487d06314409900e0d3179586b73c2f8f5125a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:33:08 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20nullable=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bridge/NativeSessionBridgeService.java | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java index 1b6f3b2d..9b512f63 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java @@ -5,44 +5,32 @@ import java.util.Base64; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; + @Service +@RequiredArgsConstructor public class NativeSessionBridgeService { private static final int TOKEN_BYTES = 32; private static final String KEY_PREFIX = "native:session-bridge:"; - private static final long MIN_TTL_SECONDS = 30L; + private static final Duration TTL = Duration.ofSeconds(30); private final SecureRandom secureRandom = new SecureRandom(); - @Nullable private final StringRedisTemplate redis; - private final Duration ttl; - - public NativeSessionBridgeService( - @Nullable StringRedisTemplate redis, - @Value("${app.native.session-bridge-token-ttl-seconds:120}") long ttlSeconds - ) { - this.redis = redis; - this.ttl = Duration.ofSeconds(Math.max(MIN_TTL_SECONDS, ttlSeconds)); - } - public String issue(Integer userId) { if (userId == null) { throw new IllegalArgumentException("userId is required"); } - if (redis == null) { - throw new IllegalStateException("Redis is required for native session bridge token storage."); - } - String token = generateToken(); - redis.opsForValue().set(KEY_PREFIX + token, userId.toString(), ttl); + redis.opsForValue().set(KEY_PREFIX + token, userId.toString(), TTL); + return token; } From a90f9c14d51f8c596b69e51e4a9d23c871e3b781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:35:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=EB=AF=B8=ED=98=B8=ED=99=98=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bridge/NativeSessionBridgeService.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java index 9b512f63..adea6d4f 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java @@ -3,9 +3,11 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.Base64; +import java.util.List; import java.util.Optional; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; @@ -18,6 +20,13 @@ public class NativeSessionBridgeService { private static final int TOKEN_BYTES = 32; private static final String KEY_PREFIX = "native:session-bridge:"; private static final Duration TTL = Duration.ofSeconds(30); + private static final DefaultRedisScript GET_DEL_SCRIPT = + new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); " + + "if v then redis.call('DEL', KEYS[1]); end; " + + "return v;", + String.class + ); private final SecureRandom secureRandom = new SecureRandom(); @@ -35,18 +44,12 @@ public String issue(Integer userId) { } public Optional consume(@Nullable String token) { - if (token == null || token.isBlank()) { - return Optional.empty(); - } + if (token == null || token.isBlank()) return Optional.empty(); - if (redis == null) { - throw new IllegalStateException("Redis is required for native session bridge token storage."); - } + String key = KEY_PREFIX + token; + String value = redis.execute(GET_DEL_SCRIPT, List.of(key)); - String value = redis.opsForValue().getAndDelete(KEY_PREFIX + token); - if (value == null || value.isBlank()) { - return Optional.empty(); - } + if (value == null || value.isBlank()) return Optional.empty(); try { return Optional.of(Integer.parseInt(value)); @@ -55,6 +58,7 @@ public Optional consume(@Nullable String token) { } } + private String generateToken() { byte[] bytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(bytes); From 8850ccb628aed13936b7432ff7c5bd39b5e0c34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:39:28 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=B1=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=EC=B1=85=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/global/auth/bridge/NativeSessionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java index a5c15e68..854b7bd7 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java @@ -31,7 +31,7 @@ public void bridge( HttpServletRequest request, HttpServletResponse response ) throws IOException { - response.setHeader("Cache-Control", "no-store"); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); if (!StringUtils.hasText(bridgeToken)) { response.sendError(HttpStatus.UNAUTHORIZED.value()); From 894f1739d57824ef8ec016a1de3f7fb561047fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:44:47 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/bridge/NativeSessionBridgeService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java index adea6d4f..810c9801 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java @@ -44,12 +44,16 @@ public String issue(Integer userId) { } public Optional consume(@Nullable String token) { - if (token == null || token.isBlank()) return Optional.empty(); + if (token == null || token.isBlank()) { + return Optional.empty(); + } String key = KEY_PREFIX + token; String value = redis.execute(GET_DEL_SCRIPT, List.of(key)); - if (value == null || value.isBlank()) return Optional.empty(); + if (value == null || value.isBlank()) { + return Optional.empty(); + } try { return Optional.of(Integer.parseInt(value)); @@ -58,7 +62,6 @@ public Optional consume(@Nullable String token) { } } - private String generateToken() { byte[] bytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(bytes); From bc0a638b81099afc7a82736544a3fcbcc030983c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 21:52:35 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=9D=80=20=EB=B9=88=20=EC=83=9D=EC=84=B1=20=EB=AC=B4?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/bridge/NativeSessionBridgeService.java | 2 ++ .../global/auth/bridge/NativeSessionController.java | 2 ++ .../auth/handler/OAuth2LoginSuccessHandler.java | 11 ++++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java index 810c9801..0353872d 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionBridgeService.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.context.annotation.Profile; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.lang.Nullable; @@ -13,6 +14,7 @@ import lombok.RequiredArgsConstructor; +@Profile("!local") @Service @RequiredArgsConstructor public class NativeSessionBridgeService { diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java index 854b7bd7..f4a1d42e 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java @@ -3,6 +3,7 @@ import java.io.IOException; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -15,6 +16,7 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +@Profile("!local") @RestController @RequiredArgsConstructor public class NativeSessionController { diff --git a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java index be62a676..801c53ed 100644 --- a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -39,7 +40,7 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final UserRepository userRepository; private final UnRegisteredUserRepository unRegisteredUserRepository; private final SecurityProperties securityProperties; - private final NativeSessionBridgeService nativeSessionBridgeService; + private final ObjectProvider nativeSessionBridgeService; @Override public void onAuthenticationSuccess( @@ -115,8 +116,12 @@ private void sendLoginSuccessResponse( String safeRedirect = resolveSafeRedirect(redirectUri); if (isAppleOauthCallback(safeRedirect)) { - String bridgeToken = nativeSessionBridgeService.issue(user.getId()); - safeRedirect = appendBridgeToken(safeRedirect, bridgeToken); + NativeSessionBridgeService svc = nativeSessionBridgeService.getIfAvailable(); + + if (svc != null) { + String bridgeToken = svc.issue(user.getId()); + safeRedirect = appendBridgeToken(safeRedirect, bridgeToken); + } } response.sendRedirect(safeRedirect);