diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 9f42e384..85eafa13 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -80,9 +80,23 @@ function AppLayout() { }; }, []); - const { data: profile } = useQuery({ + const { + data: profile, + isLoading: isProfileLoading, + error: profileError, + } = useQuery({ queryKey: ["me"], - queryFn: fetchMe, + queryFn: async () => { + console.debug("[AppLayout] Fetching profile..."); + try { + const data = await fetchMe(); + console.debug("[AppLayout] Profile fetched successfully:", data.email); + return data; + } catch (err) { + console.error("[AppLayout] Failed to fetch profile:", err); + throw err; + } + }, enabled: (auth.isAuthenticated && !auth.isLoading) || import.meta.env.MODE === "development" || @@ -170,7 +184,15 @@ function AppLayout() { const isTest = (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) ._IS_TEST_MODE === true; + + console.debug("[AppLayout] Auth state check:", { + isLoading: auth.isLoading, + isAuthenticated: auth.isAuthenticated, + isTest, + }); + if (!auth.isLoading && !auth.isAuthenticated && !isTest) { + console.warn("[AppLayout] Not authenticated, redirecting to /login"); navigate("/login"); } }, [auth.isLoading, auth.isAuthenticated, navigate]); diff --git a/adminfront/src/features/auth/AuthCallbackPage.tsx b/adminfront/src/features/auth/AuthCallbackPage.tsx index ed1e0630..b7754b0a 100644 --- a/adminfront/src/features/auth/AuthCallbackPage.tsx +++ b/adminfront/src/features/auth/AuthCallbackPage.tsx @@ -8,6 +8,11 @@ function AuthCallbackPage() { const navigate = useNavigate(); useEffect(() => { + console.debug("[AuthCallbackPage] State:", { + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + error: auth.error, + }); if (auth.isAuthenticated) { // Save token to localStorage for existing API clients that might still use it const user = auth.user; @@ -21,12 +26,16 @@ function AuthCallbackPage() { typeof auth.user.state.returnTo === "string" ? auth.user.state.returnTo : "/"; + console.info( + "[AuthCallbackPage] Auth successful, navigating to", + returnTo, + ); navigate(returnTo, { replace: true }); } else if (auth.error) { - console.error("Auth Error:", auth.error); + console.error("[AuthCallbackPage] Auth Error:", auth.error); navigate("/login", { replace: true }); } - }, [auth.isAuthenticated, auth.error, navigate, auth.user]); + }, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]); return (
diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index bc6d780d..cdd807ab 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -20,10 +20,19 @@ function LoginPage() { const shouldAutoLogin = searchParams.get("auto") === "1"; useEffect(() => { + console.debug("[LoginPage] Auth state check:", { + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + returnTo, + }); if (auth.isAuthenticated) { + console.info( + "[LoginPage] User is authenticated, redirecting to", + returnTo, + ); navigate(returnTo, { replace: true }); } - }, [auth.isAuthenticated, navigate, returnTo]); + }, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]); useEffect(() => { if (!shouldAutoLogin) { @@ -64,6 +73,26 @@ function LoginPage() {
+ {auth.error && ( +
+
+ + 인증 오류가 발생했습니다 +
+

{auth.error.message}

+ +
+ )} + diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts index aae7b63b..10782e7f 100644 --- a/adminfront/src/lib/apiClient.ts +++ b/adminfront/src/lib/apiClient.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { userManager } from "./auth"; const apiClient = axios.create({ baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) @@ -7,14 +8,17 @@ const apiClient = axios.create({ : (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"), }); -apiClient.interceptors.request.use((config) => { - // TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다. - const sessionToken = window.localStorage.getItem("admin_session"); +apiClient.interceptors.request.use(async (config) => { + // IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다. + const user = await userManager.getUser(); + const sessionToken = + user?.access_token || window.localStorage.getItem("admin_session"); + if (sessionToken) { config.headers.Authorization = `Bearer ${sessionToken}`; } - // TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다. + // 테넌트 선택 값을 보관하고 헤더로 전달한다. const tenantId = window.localStorage.getItem("admin_tenant"); if (tenantId) { config.headers["X-Tenant-ID"] = tenantId; @@ -33,10 +37,29 @@ apiClient.interceptors.request.use((config) => { apiClient.interceptors.response.use( (response) => response, - (error) => { + async (error) => { if (error.response?.status === 401) { + console.warn( + "[apiClient] 401 Unauthorized detected. Clearing session state.", + ); + + // 로컬 스토리지의 세션 키 제거 window.localStorage.removeItem("admin_session"); - window.location.href = "/login"; + + // oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다. + // 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다. + await userManager.removeUser(); + + const isAuthPath = window.location.pathname.startsWith("/auth/callback"); + const isLoginPath = window.location.pathname === "/login"; + + if (!isAuthPath && !isLoginPath) { + console.info( + "[apiClient] Redirecting to /login from", + window.location.pathname, + ); + window.location.href = "/login"; + } } return Promise.reject(error); }, diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index aab02a2b..6bf02cd7 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -2,13 +2,13 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; 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 + authority: 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/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index be152778..9caff6cd 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -29,18 +29,32 @@ export function shouldAttemptSlidingSessionRenew({ } if (typeof expiresAtSec !== "number") { + console.debug( + "[sessionSliding] expiresAtSec is not a number, skipping renew", + ); return false; } const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { + const remainingMin = Math.floor(remainingMs / 1000 / 60); + + if (remainingMs <= 0) { + console.debug("[sessionSliding] Session already expired, skipping renew"); + return false; + } + + if (remainingMs > thresholdMs) { return false; } if (nowMs - lastAttemptAtMs < throttleMs) { + console.debug("[sessionSliding] Throttling renewal attempt"); return false; } + console.info( + `[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`, + ); return true; } @@ -60,17 +74,33 @@ export function shouldAttemptUnlimitedSessionRenew({ } if (typeof expiresAtSec !== "number") { + console.debug( + "[sessionSliding] expiresAtSec is not a number, skipping unlimited renew", + ); return false; } const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { + const remainingMin = Math.floor(remainingMs / 1000 / 60); + + if (remainingMs <= 0) { + console.debug( + "[sessionSliding] Session already expired, skipping unlimited renew", + ); + return false; + } + + if (remainingMs > thresholdMs) { return false; } if (nowMs - lastAttemptAtMs < throttleMs) { + console.debug("[sessionSliding] Throttling unlimited renewal attempt"); return false; } + console.info( + `[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`, + ); return true; } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c2aedead..f82ff3af 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -687,7 +687,8 @@ func main() { // Admin User Management admin.Get("/users", requireAnyUser, userHandler.ListUsers) - admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param admin.Post("/users", requireAdmin, userHandler.CreateUser) + admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param + admin.Post("/users", requireAdmin, userHandler.CreateUser) admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) 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/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 08d06e2b..64833990 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -633,8 +633,11 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { if got["redirectTo"] != "http://rp/cb" { t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) } - if _, ok := got["sessionJwt"]; ok { - t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"]) + if got["sessionJwt"] != "valid-jwt" { + t.Errorf("expected sessionJwt to be valid-jwt, got %v", got["sessionJwt"]) + } + if got["token"] != "valid-jwt" { + t.Errorf("expected token to be valid-jwt, got %v", got["token"]) } } 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/playwright.config.ts b/devfront/playwright.config.ts index 1910fa4c..c4ad1449 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -63,8 +63,8 @@ export default defineConfig({ ? undefined : { command: process.env.CI - ? "npm run build && npm run preview -- --port 5174" - : "npm run dev -- --port 5174", + ? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run build && npm run preview -- --port 5174" + : "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run dev -- --port 5174", url: baseURL, reuseExistingServer: !process.env.CI, }, diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx index 26069583..a0791fba 100644 --- a/devfront/src/features/auth/AuthGuard.tsx +++ b/devfront/src/features/auth/AuthGuard.tsx @@ -9,7 +9,21 @@ 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/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index a19ee5ed..91590fba 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -524,16 +524,17 @@ function ClientGeneralPage() { if (result?.client?.id) { navigate(`/clients/${result.client.id}/settings`); } - alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); + toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); }, onError: (err) => { const axiosError = err as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { - alert( + toast( t( "msg.dev.clients.general.save_forbidden", "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", ), + "error", ); return; } @@ -542,7 +543,7 @@ function ClientGeneralPage() { axiosError.response?.data?.error ?? (err as Error)?.message ?? t("msg.common.unknown_error", "unknown error"); - alert( + toast( t( "msg.dev.clients.general.save_error", "저장에 실패했습니다: {{error}}", @@ -550,6 +551,7 @@ function ClientGeneralPage() { error: errorMessage, }, ), + "error", ); }, }); @@ -558,17 +560,18 @@ function ClientGeneralPage() { mutationFn: (id: string) => deleteClient(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["clients"] }); - alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다.")); + toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다.")); navigate("/clients"); }, onError: (err) => { const errorMessage = (err as AxiosError<{ error?: string }>).response?.data?.error ?? (err as Error)?.message; - alert( + toast( t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", { error: errorMessage, }), + "error", ); }, }); diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index 23be2dfb..e93e9260 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react"; import { useDeferredValue, useMemo, useState } from "react"; +import { useAuth } from "react-oidc-context"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -33,7 +34,6 @@ import { } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; -import { useAuth } from "react-oidc-context"; import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index d0f0772e..f0e7ea3a 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -2,8 +2,7 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; 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 + authority: 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..59f3f0d5 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..b3824dc0 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -348,8 +348,19 @@ 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 +1305,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..4d0967d7 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -73,6 +73,45 @@ 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 +154,9 @@ void main() async { // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); + // 2. Silent Session Recovery (from cookies) + await _silentSessionRecovery(); + runApp( // URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. () {