From 0f79b7635b0f0f6b63ee87bfc24df8f07174784d Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 21 Apr 2026 14:10:27 +0900 Subject: [PATCH 1/8] fix: resolve OIDC session state issue and synchronize portal sessions Details: - Backend: Extract Kratos session cookies and propagate via SetCookies in AuthInfo. - Backend: Include sessionJwt and token during OIDC flows in PasswordLogin. - UserFront: Add _silentSessionRecovery in main.dart to recover session via cookies if localStorage token is missing. - UserFront: Update AuthProxyService, AuthTokenStore, AuthNotifier to support silent recovery and immediate local state update before redirect. - AdminFront/DevFront: Fix OIDC authority to point directly to Gateway proxy and add recovery/error UI components. --- adminfront/src/features/auth/LoginPage.tsx | 20 +++++++ adminfront/src/lib/auth.ts | 3 +- backend/internal/domain/idp_models.go | 1 + backend/internal/handler/auth_handler.go | 56 +++++++++++++++++++ backend/internal/service/ory_service.go | 6 +- devfront/src/features/auth/AuthGuard.tsx | 15 ++++- devfront/src/lib/auth.ts | 2 +- .../lib/core/notifiers/auth_notifier.dart | 7 +++ .../lib/core/services/auth_proxy_service.dart | 26 +++++++++ .../lib/core/services/auth_token_store.dart | 5 ++ .../auth/presentation/login_screen.dart | 22 ++++++++ userfront/lib/main.dart | 41 ++++++++++++++ 12 files changed, 199 insertions(+), 5 deletions(-) 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)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. () { From e7156450ba7053c5075a2bb7dac43f6eb1c25f9e Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 21 Apr 2026 14:40:09 +0900 Subject: [PATCH 2/8] fix: restore missing POST /users route in admin API Details: - The route `admin.Post("/users")` was accidentally merged into a comment line for `admin.Get("/users/export")`. This caused the 405 Method Not Allowed error when trying to create users. - Restored the route on its own line. --- backend/cmd/server/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From ae03fe1475c1370bea992637eb27ba1e44f9139f Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 21 Apr 2026 15:02:53 +0900 Subject: [PATCH 3/8] chore: update playwright fixtures to use real SSO domain Since the OIDC authority was updated to https://sso.hmac.kr/oidc, the Playwright mocks and localStorage seed values must match exactly for tests to pass in the new configuration. --- adminfront/tests/auth.spec.ts | 14 +++++++------- adminfront/tests/bulk_actions.spec.ts | 4 ++-- adminfront/tests/owners.spec.ts | 4 ++-- adminfront/tests/tenants.spec.ts | 4 ++-- adminfront/tests/users.spec.ts | 14 +++++++------- adminfront/tests/users_bulk.spec.ts | 4 ++-- adminfront/tests/users_schema.spec.ts | 14 +++++++------- devfront/tests/helpers/devfront-fixtures.ts | 16 ++++++++-------- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 87e5623b..b74215a5 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -10,7 +10,7 @@ test.describe("Authentication", () => { window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -45,12 +45,12 @@ test.describe("Authentication", () => { if (url.includes(".well-known/openid-configuration")) { await route.fulfill({ json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - jwks_uri: "http://localhost:5000/oidc/jwks", - userinfo_endpoint: "http://localhost:5000/oidc/userinfo", - end_session_endpoint: "http://localhost:5000/oidc/session/end", + issuer: "https://sso.hmac.kr/oidc", + authorization_endpoint: "https://sso.hmac.kr/oidc/auth", + token_endpoint: "https://sso.hmac.kr/oidc/token", + jwks_uri: "https://sso.hmac.kr/oidc/jwks", + userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo", + end_session_endpoint: "https://sso.hmac.kr/oidc/session/end", }, headers: { "Access-Control-Allow-Origin": "*" }, }); diff --git a/adminfront/tests/bulk_actions.spec.ts b/adminfront/tests/bulk_actions.spec.ts index 8b0b5e45..0a3bf8fc 100644 --- a/adminfront/tests/bulk_actions.spec.ts +++ b/adminfront/tests/bulk_actions.spec.ts @@ -9,7 +9,7 @@ test.describe("Bulk Actions and Tree Search", () => { window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -114,7 +114,7 @@ test.describe("Bulk Actions and Tree Search", () => { }); await page.route("**/oidc/**", async (route) => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); }); diff --git a/adminfront/tests/owners.spec.ts b/adminfront/tests/owners.spec.ts index eea69e9c..77269f11 100644 --- a/adminfront/tests/owners.spec.ts +++ b/adminfront/tests/owners.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Tenant Owners Management", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -26,7 +26,7 @@ test.describe("Tenant Owners Management", () => { }); await page.route("**/oidc/**", async (route) => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); await page.route(/.*\/api\/v1\/.*/, async (route) => { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index f2b1488e..9dc9e85a 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -9,7 +9,7 @@ test.describe("Tenants Management", () => { window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -52,7 +52,7 @@ test.describe("Tenants Management", () => { }); await page.route("**/oidc/**", async (route) => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); }); diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index 6633cd5c..ea6fdabb 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("User Management", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -33,15 +33,15 @@ test.describe("User Management", () => { if (route.request().url().includes("/.well-known/openid-configuration")) { return route.fulfill({ json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - userinfo_endpoint: "http://localhost:5000/oidc/userinfo", - jwks_uri: "http://localhost:5000/oidc/jwks", + issuer: "https://sso.hmac.kr/oidc", + authorization_endpoint: "https://sso.hmac.kr/oidc/auth", + token_endpoint: "https://sso.hmac.kr/oidc/token", + userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo", + jwks_uri: "https://sso.hmac.kr/oidc/jwks", }, }); } - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); await page.route(/.*\/api\/v1\/.*/, async (route) => { diff --git a/adminfront/tests/users_bulk.spec.ts b/adminfront/tests/users_bulk.spec.ts index c7aa3a2d..26107f5b 100644 --- a/adminfront/tests/users_bulk.spec.ts +++ b/adminfront/tests/users_bulk.spec.ts @@ -9,7 +9,7 @@ test.describe("Users Bulk Upload", () => { window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -54,7 +54,7 @@ test.describe("Users Bulk Upload", () => { }); await page.route("**/oidc/**", async (route) => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); }); diff --git a/adminfront/tests/users_schema.spec.ts b/adminfront/tests/users_schema.spec.ts index 2f954271..41f32847 100644 --- a/adminfront/tests/users_schema.spec.ts +++ b/adminfront/tests/users_schema.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("User Schema Dynamic Form", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { - const authority = "http://localhost:5000/oidc"; + const authority = "https://sso.hmac.kr/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { @@ -35,15 +35,15 @@ test.describe("User Schema Dynamic Form", () => { if (route.request().url().includes("/.well-known/openid-configuration")) { return route.fulfill({ json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - userinfo_endpoint: "http://localhost:5000/oidc/userinfo", - jwks_uri: "http://localhost:5000/oidc/jwks", + issuer: "https://sso.hmac.kr/oidc", + authorization_endpoint: "https://sso.hmac.kr/oidc/auth", + token_endpoint: "https://sso.hmac.kr/oidc/token", + userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo", + jwks_uri: "https://sso.hmac.kr/oidc/jwks", }, }); } - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + await route.fulfill({ json: { issuer: "https://sso.hmac.kr/oidc" } }); }); await page.route(/.*\/api\/v1\/.*/, async (route) => { diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index e8200f9b..39fd3f36 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -137,11 +137,11 @@ export async function seedAuth(page: Page, role?: string) { }; window.localStorage.setItem( - "oidc.user:http://localhost:5000/oidc:devfront", + "oidc.user:https://sso.hmac.kr/oidc:devfront", JSON.stringify(mockOidcUser), ); window.localStorage.setItem( - "oidc.user:http://localhost:5000/oidc/:devfront", + "oidc.user:https://sso.hmac.kr/oidc/:devfront", JSON.stringify(mockOidcUser), ); window.localStorage.setItem("dev_role", injectedRole || "rp_admin"); @@ -155,12 +155,12 @@ export async function seedAuth(page: Page, role?: string) { if (url.includes(".well-known/openid-configuration")) { await route.fulfill({ json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - jwks_uri: "http://localhost:5000/oidc/jwks", - userinfo_endpoint: "http://localhost:5000/oidc/userinfo", - end_session_endpoint: "http://localhost:5000/oidc/session/end", + issuer: "https://sso.hmac.kr/oidc", + authorization_endpoint: "https://sso.hmac.kr/oidc/auth", + token_endpoint: "https://sso.hmac.kr/oidc/token", + jwks_uri: "https://sso.hmac.kr/oidc/jwks", + userinfo_endpoint: "https://sso.hmac.kr/oidc/userinfo", + end_session_endpoint: "https://sso.hmac.kr/oidc/session/end", }, headers: { "Access-Control-Allow-Origin": "*" }, }); From 4427ab1f85d1a172e0a024d7796953b6887bed58 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 21 Apr 2026 17:06:03 +0900 Subject: [PATCH 4/8] fix: resolve admin session infinite reload loop and sync auth state - Prevent infinite redirection loop by clearing oidc-client user state on 401 errors. - Sync apiClient request interceptor to use userManager.getUser() for reliable token retrieval. - Add extensive console logs for better session issue diagnosis. - Fix TS error in LoginPage by updating button variant. - Revert 'ae03fe1' (updated playwright fixtures to real domain) as requested. --- .../src/components/layout/AppLayout.tsx | 22 ++++++++++++-- .../src/features/auth/AuthCallbackPage.tsx | 10 +++++-- adminfront/src/features/auth/LoginPage.tsx | 12 ++++++-- adminfront/src/lib/apiClient.ts | 29 +++++++++++++++---- adminfront/src/lib/sessionSliding.ts | 24 +++++++++++++-- adminfront/tests/auth.spec.ts | 14 ++++----- adminfront/tests/bulk_actions.spec.ts | 4 +-- adminfront/tests/owners.spec.ts | 4 +-- adminfront/tests/tenants.spec.ts | 4 +-- adminfront/tests/users.spec.ts | 14 ++++----- adminfront/tests/users_bulk.spec.ts | 4 +-- adminfront/tests/users_schema.spec.ts | 14 ++++----- devfront/tests/helpers/devfront-fixtures.ts | 16 +++++----- 13 files changed, 119 insertions(+), 52 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 9f42e384..3cf40297 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -80,9 +80,19 @@ 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 +180,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..b6c711d7 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,13 @@ 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 3c46ac63..42f560ad 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -20,10 +20,16 @@ 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) { @@ -72,8 +78,8 @@ function LoginPage() {

{auth.error.message}