diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index bc6d780d..3c46ac63 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -64,6 +64,26 @@ function LoginPage() { + {auth.error && ( +
+
+ + 인증 오류가 발생했습니다 +
+

{auth.error.message}

+ +
+ )} + diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index aab02a2b..0d4a7f43 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -3,12 +3,13 @@ import type { AuthProviderProps } from "react-oidc-context"; export const oidcConfig: AuthProviderProps = { authority: - import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL + import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", redirect_uri: `${window.location.origin}/auth/callback`, response_type: "code", scope: "openid offline_access profile email", // offline_access for refresh token post_logout_redirect_uri: window.location.origin, + popup_redirect_uri: `${window.location.origin}/auth/callback`, userStore: new WebStorageStateStore({ store: window.localStorage }), automaticSilentRenew: false, }; diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index e22b9392..fd9b9168 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -53,6 +53,7 @@ type AuthInfo struct { RefreshToken *Token // Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다. Subject string + SetCookies []*http.Cookie } // LinkLoginInit는 링크 로그인 초기화 결과입니다. diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f7aed790..3c3ea26f 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -17,6 +17,7 @@ import ( "io" "log/slog" "math/rand" + "net" "net/http" "net/url" "os" @@ -1641,6 +1642,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { sessionToken := authInfo.SessionToken.JWT c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + + // Write Kratos session cookies to the response + h.writeAuthCookies(c, authInfo.SetCookies) + sessionID := extractSessionIDFromToken(authInfo.SessionToken) slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef) @@ -1752,6 +1757,9 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { c.Locals("login_id", lookupLoginID) setSessionIDLocal(c, authInfo.SessionToken) + // Write Kratos session cookies to the response + h.writeAuthCookies(c, authInfo.SetCookies) + h.RedisService.Delete(prefixLoginCode + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) @@ -2414,6 +2422,10 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + + // Write Kratos session cookies to the response + h.writeAuthCookies(c, authInfo.SetCookies) + sessionID := extractSessionIDFromToken(authInfo.SessionToken) if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" { if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" { @@ -2784,6 +2796,40 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error { }) } +func (h *AuthHandler) writeAuthCookies(c *fiber.Ctx, cookies []*http.Cookie) { + if len(cookies) == 0 { + return + } + + host := c.Hostname() + domain := "" + + // IP address or localhost check + if ip := net.ParseIP(host); ip != nil || host == "localhost" { + domain = host + } else { + // Extract root domain (e.g., .hmac.kr from sso.hmac.kr) + parts := strings.Split(host, ".") + if len(parts) >= 2 { + domain = "." + strings.Join(parts[len(parts)-2:], ".") + } + } + + for _, cookie := range cookies { + c.Cookie(&fiber.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + Path: "/", + Domain: domain, + MaxAge: cookie.MaxAge, + Expires: cookie.Expires, + Secure: true, + HTTPOnly: true, + SameSite: "Lax", + }) + } +} + func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "login") @@ -2832,6 +2878,10 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + + // Write Kratos session cookies to the response + h.writeAuthCookies(c, authInfo.SetCookies) + if req.LoginChallenge == "" { attachAuditClientDetails(c, domain.HydraClient{ ClientID: "userfront", @@ -2864,16 +2914,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } logOidcRedirectSummary("password_login", acceptResp.RedirectTo) + + // IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session. return c.JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, "status": "ok", "provider": h.IdpProvider.Name(), + "sessionJwt": authInfo.SessionToken.JWT, + "token": authInfo.SessionToken.JWT, + "subject": authInfo.Subject, }) } // --- OIDC 로그인 흐름 처리 끝 --- resp := fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, + "token": authInfo.SessionToken.JWT, "status": "ok", "provider": h.IdpProvider.Name(), } diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 0eb08d68..1cb13ab6 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -247,7 +247,8 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) Expiration: result.SessionTokenExpiresAt, SessionID: result.Session.ID, }, - Subject: result.Session.Identity.ID, + Subject: result.Session.Identity.ID, + SetCookies: resp.Cookies(), }, nil } @@ -693,7 +694,8 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut Expiration: result.SessionTokenExpiresAt, SessionID: result.Session.ID, }, - Subject: result.Session.Identity.ID, + Subject: result.Session.Identity.ID, + SetCookies: resp.Cookies(), }, nil } diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx index 26069583..e204b150 100644 --- a/devfront/src/features/auth/AuthGuard.tsx +++ b/devfront/src/features/auth/AuthGuard.tsx @@ -9,7 +9,20 @@ export default function AuthGuard() { } if (auth.error) { - return
Auth Error: {auth.error.message}
; + return ( +
+
+

Authentication Error

+

{auth.error.message}

+
+ +
+ ); } if (!auth.isAuthenticated) { diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index d0f0772e..210b507d 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -3,7 +3,7 @@ import type { AuthProviderProps } from "react-oidc-context"; export const oidcConfig: AuthProviderProps = { authority: - import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL + import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", redirect_uri: `${window.location.origin}/auth/callback`, response_type: "code", diff --git a/userfront/lib/core/notifiers/auth_notifier.dart b/userfront/lib/core/notifiers/auth_notifier.dart index 24282ca5..2591960a 100644 --- a/userfront/lib/core/notifiers/auth_notifier.dart +++ b/userfront/lib/core/notifiers/auth_notifier.dart @@ -1,8 +1,15 @@ import 'package:flutter/foundation.dart'; +import '../services/auth_token_store.dart'; class AuthNotifier extends ChangeNotifier { static final AuthNotifier instance = AuthNotifier(); + Future onLoginSuccess(String token, {String? provider}) async { + AuthTokenStore.setToken(token, provider: provider); + AuthTokenStore.clearPendingProvider(); + notifyListeners(); + } + void notify() { notifyListeners(); } diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 252fdf32..433c003d 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -92,6 +92,32 @@ class AuthProxyService { } } + static Future> getMe({String? token, bool useCookie = true}) async { + final url = Uri.parse('$_baseUrl/api/v1/user/me'); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.get( + url, + headers: headers, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + throw _error( + 'err.userfront.auth_proxy.profile_load', + '프로필을 불러오지 못했습니다: {{error}}', + detail: response.body, + ); + } finally { + client.close(); + } + } + static Future getSessionStatus({ String? token, bool useCookie = false, diff --git a/userfront/lib/core/services/auth_token_store.dart b/userfront/lib/core/services/auth_token_store.dart index 253ff99d..c5133150 100644 --- a/userfront/lib/core/services/auth_token_store.dart +++ b/userfront/lib/core/services/auth_token_store.dart @@ -2,6 +2,11 @@ import 'auth_token_store_stub.dart' if (dart.library.js_interop) 'auth_token_store_web.dart'; class AuthTokenStore { + static bool hasToken() { + final token = getToken(); + return token != null && token.isNotEmpty; + } + static String? getToken() => authTokenStore.getToken(); static String? getProvider() => authTokenStore.getProvider(); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index bcb02973..218d76e7 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -348,8 +348,18 @@ class _LoginScreenState extends ConsumerState loginChallenge, token: token, ); + + // IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state. + final jwt = res['sessionJwt'] ?? res['token'] ?? token; + if (jwt != null && jwt.isNotEmpty) { + final provider = res['provider'] as String? ?? AuthTokenStore.getProvider(); + await AuthNotifier.instance.onLoginSuccess(jwt, provider: provider); + } + final redirectTo = res['redirectTo'] as String?; if (redirectTo != null && redirectTo.isNotEmpty) { + // Give 50ms delay for localStorage to settle + await Future.delayed(const Duration(milliseconds: 50)); return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login'); } } catch (e) { @@ -1294,10 +1304,22 @@ class _LoginScreenState extends ConsumerState loginChallenge, token: token, ); + + // IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state. + final jwt = res['sessionJwt'] ?? res['token'] ?? token; + if (jwt != null && jwt.isNotEmpty) { + await AuthNotifier.instance.onLoginSuccess( + jwt, + provider: res['provider'] as String? ?? providerName, + ); + } + final nextRedirectTo = res['redirectTo'] as String?; if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) { loginChallengeLoopGuard.clear(loginChallenge); + // Give 50ms delay for localStorage to settle + await Future.delayed(const Duration(milliseconds: 50)); webWindow.redirectTo(nextRedirectTo); // Removed await return; } else {} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 774ecb66..0d204b3b 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -73,6 +73,44 @@ Future _loadBundledFonts() async { } } +Future _silentSessionRecovery() async { + _log.info("[SessionRecovery] Starting silent session recovery check..."); + + // 1. Local token check + final hasLocalToken = AuthTokenStore.hasToken(); + if (hasLocalToken) { + _log.info("[SessionRecovery] Local token found. Skipping recovery."); + return; + } + + _log.info( + "[SessionRecovery] Local token missing. Checking for browser cookies...", + ); + + try { + // 2. Try fetching user info (backend will use cookies if present) + final userInfo = await AuthProxyService.getMe(); + final subject = userInfo['id'] ?? userInfo['identity_id'] ?? ''; + + if (subject.isNotEmpty) { + _log.info( + "[SessionRecovery] Valid session found via cookies. Recovering login state...", + ); + // For cookie-based auth, we don't necessarily have a JWT in local storage, + // but AuthNotifier needs to know we are logged in. + final jwt = userInfo['sessionJwt'] ?? userInfo['token'] ?? 'cookie-session'; + await AuthNotifier.instance.onLoginSuccess(jwt); + _log.info("[SessionRecovery] Recovery complete. Subject: $subject"); + } else { + _log.warning("[SessionRecovery] Session found but subject is empty."); + } + } catch (e) { + _log.info( + "[SessionRecovery] No valid cookie session found or request failed: $e", + ); + } +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); @@ -115,6 +153,9 @@ void main() async { // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); + // 2. Silent Session Recovery (from cookies) + await _silentSessionRecovery(); + runApp( // URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. () {