1
0
forked from baron/baron-sso

Merge pull request 'fix-test-fixtures' (#606) from fix-test-fixtures into dev

Reviewed-on: baron/baron-sso#606
This commit is contained in:
2026-04-22 09:36:47 +09:00
21 changed files with 326 additions and 31 deletions

View File

@@ -80,9 +80,23 @@ function AppLayout() {
}; };
}, []); }, []);
const { data: profile } = useQuery({ const {
data: profile,
isLoading: isProfileLoading,
error: profileError,
} = useQuery({
queryKey: ["me"], 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: enabled:
(auth.isAuthenticated && !auth.isLoading) || (auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development" || import.meta.env.MODE === "development" ||
@@ -170,7 +184,15 @@ function AppLayout() {
const isTest = const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
console.debug("[AppLayout] Auth state check:", {
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
isTest,
});
if (!auth.isLoading && !auth.isAuthenticated && !isTest) { if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
console.warn("[AppLayout] Not authenticated, redirecting to /login");
navigate("/login"); navigate("/login");
} }
}, [auth.isLoading, auth.isAuthenticated, navigate]); }, [auth.isLoading, auth.isAuthenticated, navigate]);

View File

@@ -8,6 +8,11 @@ function AuthCallbackPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
console.debug("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error,
});
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it // Save token to localStorage for existing API clients that might still use it
const user = auth.user; const user = auth.user;
@@ -21,12 +26,16 @@ function AuthCallbackPage() {
typeof auth.user.state.returnTo === "string" typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo ? auth.user.state.returnTo
: "/"; : "/";
console.info(
"[AuthCallbackPage] Auth successful, navigating to",
returnTo,
);
navigate(returnTo, { replace: true }); navigate(returnTo, { replace: true });
} else if (auth.error) { } else if (auth.error) {
console.error("Auth Error:", auth.error); console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true }); navigate("/login", { replace: true });
} }
}, [auth.isAuthenticated, auth.error, navigate, auth.user]); }, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">

View File

@@ -20,10 +20,19 @@ function LoginPage() {
const shouldAutoLogin = searchParams.get("auto") === "1"; const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => { useEffect(() => {
console.debug("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo,
});
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
console.info(
"[LoginPage] User is authenticated, redirecting to",
returnTo,
);
navigate(returnTo, { replace: true }); navigate(returnTo, { replace: true });
} }
}, [auth.isAuthenticated, navigate, returnTo]); }, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => { useEffect(() => {
if (!shouldAutoLogin) { if (!shouldAutoLogin) {
@@ -64,6 +73,26 @@ function LoginPage() {
</div> </div>
</div> </div>
{auth.error && (
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
<div className="font-bold flex items-center gap-2 mb-1">
<ShieldHalf size={16} />
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
window.location.href =
window.location.origin + window.location.pathname;
}}
>
</Button>
</div>
)}
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl"> <Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2"> <CardTitle className="text-2xl flex items-center gap-2">

View File

@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) 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"), : (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
}); });
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use(async (config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다. // IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다.
const sessionToken = window.localStorage.getItem("admin_session"); const user = await userManager.getUser();
const sessionToken =
user?.access_token || window.localStorage.getItem("admin_session");
if (sessionToken) { if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`; config.headers.Authorization = `Bearer ${sessionToken}`;
} }
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다. // 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant"); const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) { if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId; config.headers["X-Tenant-ID"] = tenantId;
@@ -33,10 +37,29 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.warn(
"[apiClient] 401 Unauthorized detected. Clearing session state.",
);
// 로컬 스토리지의 세션 키 제거
window.localStorage.removeItem("admin_session"); 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); return Promise.reject(error);
}, },

View File

@@ -2,13 +2,13 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context"; import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = { export const oidcConfig: AuthProviderProps = {
authority: authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code", response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false, automaticSilentRenew: false,
}; };

View File

@@ -29,18 +29,32 @@ export function shouldAttemptSlidingSessionRenew({
} }
if (typeof expiresAtSec !== "number") { if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping renew",
);
return false; return false;
} }
const remainingMs = expiresAtSec * 1000 - nowMs; 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; return false;
} }
if (nowMs - lastAttemptAtMs < throttleMs) { if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling renewal attempt");
return false; return false;
} }
console.info(
`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
);
return true; return true;
} }
@@ -60,17 +74,33 @@ export function shouldAttemptUnlimitedSessionRenew({
} }
if (typeof expiresAtSec !== "number") { if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
);
return false; return false;
} }
const remainingMs = expiresAtSec * 1000 - nowMs; 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; return false;
} }
if (nowMs - lastAttemptAtMs < throttleMs) { if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
return false; return false;
} }
console.info(
`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
);
return true; return true;
} }

View File

@@ -687,7 +687,8 @@ func main() {
// Admin User Management // Admin User Management
admin.Get("/users", requireAnyUser, userHandler.ListUsers) 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.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)

View File

@@ -53,6 +53,7 @@ type AuthInfo struct {
RefreshToken *Token RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다. // Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string Subject string
SetCookies []*http.Cookie
} }
// LinkLoginInit는 링크 로그인 초기화 결과입니다. // LinkLoginInit는 링크 로그인 초기화 결과입니다.

View File

@@ -17,6 +17,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"math/rand" "math/rand"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -1641,6 +1642,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
sessionToken := authInfo.SessionToken.JWT sessionToken := authInfo.SessionToken.JWT
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
// Write Kratos session cookies to the response
h.writeAuthCookies(c, authInfo.SetCookies)
sessionID := extractSessionIDFromToken(authInfo.SessionToken) sessionID := extractSessionIDFromToken(authInfo.SessionToken)
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef) 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) c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
// Write Kratos session cookies to the response
h.writeAuthCookies(c, authInfo.SetCookies)
h.RedisService.Delete(prefixLoginCode + lookupLoginID) h.RedisService.Delete(prefixLoginCode + lookupLoginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
@@ -2414,6 +2422,10 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string)
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
// Write Kratos session cookies to the response
h.writeAuthCookies(c, authInfo.SetCookies)
sessionID := extractSessionIDFromToken(authInfo.SessionToken) sessionID := extractSessionIDFromToken(authInfo.SessionToken)
if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" { if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" { 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 { func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
startTime := time.Now() startTime := time.Now()
ale := logger.NewAuditLogEntry(c, "login") 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("user_id", authInfo.Subject)
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
// Write Kratos session cookies to the response
h.writeAuthCookies(c, authInfo.SetCookies)
if req.LoginChallenge == "" { if req.LoginChallenge == "" {
attachAuditClientDetails(c, domain.HydraClient{ attachAuditClientDetails(c, domain.HydraClient{
ClientID: "userfront", 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") return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
} }
logOidcRedirectSummary("password_login", acceptResp.RedirectTo) logOidcRedirectSummary("password_login", acceptResp.RedirectTo)
// IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session.
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"redirectTo": acceptResp.RedirectTo, "redirectTo": acceptResp.RedirectTo,
"status": "ok", "status": "ok",
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"sessionJwt": authInfo.SessionToken.JWT,
"token": authInfo.SessionToken.JWT,
"subject": authInfo.Subject,
}) })
} }
// --- OIDC 로그인 흐름 처리 끝 --- // --- OIDC 로그인 흐름 처리 끝 ---
resp := fiber.Map{ resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT,
"token": authInfo.SessionToken.JWT,
"status": "ok", "status": "ok",
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
} }

View File

@@ -633,8 +633,11 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
if got["redirectTo"] != "http://rp/cb" { if got["redirectTo"] != "http://rp/cb" {
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
} }
if _, ok := got["sessionJwt"]; ok { if got["sessionJwt"] != "valid-jwt" {
t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"]) 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"])
} }
} }

View File

@@ -247,7 +247,8 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
Expiration: result.SessionTokenExpiresAt, Expiration: result.SessionTokenExpiresAt,
SessionID: result.Session.ID, SessionID: result.Session.ID,
}, },
Subject: result.Session.Identity.ID, Subject: result.Session.Identity.ID,
SetCookies: resp.Cookies(),
}, nil }, nil
} }
@@ -693,7 +694,8 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut
Expiration: result.SessionTokenExpiresAt, Expiration: result.SessionTokenExpiresAt,
SessionID: result.Session.ID, SessionID: result.Session.ID,
}, },
Subject: result.Session.Identity.ID, Subject: result.Session.Identity.ID,
SetCookies: resp.Cookies(),
}, nil }, nil
} }

View File

@@ -63,8 +63,8 @@ export default defineConfig({
? undefined ? undefined
: { : {
command: process.env.CI command: process.env.CI
? "npm run build && npm run preview -- --port 5174" ? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run build && npm run preview -- --port 5174"
: "npm run dev -- --port 5174", : "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run dev -- --port 5174",
url: baseURL, url: baseURL,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View File

@@ -9,7 +9,21 @@ export default function AuthGuard() {
} }
if (auth.error) { if (auth.error) {
return <div>Auth Error: {auth.error.message}</div>; return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-red-500">
<h2 className="text-xl font-bold">Authentication Error</h2>
<p>{auth.error.message}</p>
</div>
<button
type="button"
onClick={() => void auth.signinRedirect()}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
Start Login Again
</button>
</div>
);
} }
if (!auth.isAuthenticated) { if (!auth.isAuthenticated) {

View File

@@ -524,16 +524,17 @@ function ClientGeneralPage() {
if (result?.client?.id) { if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`); navigate(`/clients/${result.client.id}/settings`);
} }
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
}, },
onError: (err) => { onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>; const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) { if (axiosError.response?.status === 403) {
alert( toast(
t( t(
"msg.dev.clients.general.save_forbidden", "msg.dev.clients.general.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
), ),
"error",
); );
return; return;
} }
@@ -542,7 +543,7 @@ function ClientGeneralPage() {
axiosError.response?.data?.error ?? axiosError.response?.data?.error ??
(err as Error)?.message ?? (err as Error)?.message ??
t("msg.common.unknown_error", "unknown error"); t("msg.common.unknown_error", "unknown error");
alert( toast(
t( t(
"msg.dev.clients.general.save_error", "msg.dev.clients.general.save_error",
"저장에 실패했습니다: {{error}}", "저장에 실패했습니다: {{error}}",
@@ -550,6 +551,7 @@ function ClientGeneralPage() {
error: errorMessage, error: errorMessage,
}, },
), ),
"error",
); );
}, },
}); });
@@ -558,17 +560,18 @@ function ClientGeneralPage() {
mutationFn: (id: string) => deleteClient(id), mutationFn: (id: string) => deleteClient(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clients"] }); queryClient.invalidateQueries({ queryKey: ["clients"] });
alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다.")); toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
navigate("/clients"); navigate("/clients");
}, },
onError: (err) => { onError: (err) => {
const errorMessage = const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ?? (err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message; (err as Error)?.message;
alert( toast(
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", { t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
error: errorMessage, error: errorMessage,
}), }),
"error",
); );
}, },
}); });

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react"; import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react"; import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@@ -33,7 +34,6 @@ import {
} from "../../lib/devApi"; } from "../../lib/devApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { useAuth } from "react-oidc-context";
import { ClientDetailTabs } from "./ClientDetailTabs"; import { ClientDetailTabs } from "./ClientDetailTabs";
const relationOptions = [ const relationOptions = [

View File

@@ -2,8 +2,7 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context"; import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = { export const oidcConfig: AuthProviderProps = {
authority: authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code", response_type: "code",

View File

@@ -1,8 +1,15 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../services/auth_token_store.dart';
class AuthNotifier extends ChangeNotifier { class AuthNotifier extends ChangeNotifier {
static final AuthNotifier instance = AuthNotifier(); static final AuthNotifier instance = AuthNotifier();
Future<void> onLoginSuccess(String token, {String? provider}) async {
AuthTokenStore.setToken(token, provider: provider);
AuthTokenStore.clearPendingProvider();
notifyListeners();
}
void notify() { void notify() {
notifyListeners(); notifyListeners();
} }

View File

@@ -92,6 +92,32 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> getMe({
String? token,
bool useCookie = true,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'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<int> getSessionStatus({ static Future<int> getSessionStatus({
String? token, String? token,
bool useCookie = false, bool useCookie = false,

View File

@@ -2,6 +2,11 @@ import 'auth_token_store_stub.dart'
if (dart.library.js_interop) 'auth_token_store_web.dart'; if (dart.library.js_interop) 'auth_token_store_web.dart';
class AuthTokenStore { class AuthTokenStore {
static bool hasToken() {
final token = getToken();
return token != null && token.isNotEmpty;
}
static String? getToken() => authTokenStore.getToken(); static String? getToken() => authTokenStore.getToken();
static String? getProvider() => authTokenStore.getProvider(); static String? getProvider() => authTokenStore.getProvider();

View File

@@ -348,8 +348,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
loginChallenge, loginChallenge,
token: token, 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?; final redirectTo = res['redirectTo'] as String?;
if (redirectTo != null && redirectTo.isNotEmpty) { 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'); return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login');
} }
} catch (e) { } catch (e) {
@@ -1294,10 +1305,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
loginChallenge, loginChallenge,
token: token, 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?; final nextRedirectTo = res['redirectTo'] as String?;
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) { if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
loginChallengeLoopGuard.clear(loginChallenge); loginChallengeLoopGuard.clear(loginChallenge);
// Give 50ms delay for localStorage to settle
await Future.delayed(const Duration(milliseconds: 50));
webWindow.redirectTo(nextRedirectTo); // Removed await webWindow.redirectTo(nextRedirectTo); // Removed await
return; return;
} else {} } else {}

View File

@@ -73,6 +73,45 @@ Future<void> _loadBundledFonts() async {
} }
} }
Future<void> _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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy(); usePathUrlStrategy();
@@ -115,6 +154,9 @@ void main() async {
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts(); await _loadBundledFonts();
// 2. Silent Session Recovery (from cookies)
await _silentSessionRecovery();
runApp( runApp(
// URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. // URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
() { () {