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:
@@ -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]);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
|
||||
@@ -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() {
|
||||
</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">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +53,7 @@ type AuthInfo struct {
|
||||
RefreshToken *Token
|
||||
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||
Subject string
|
||||
SetCookies []*http.Cookie
|
||||
}
|
||||
|
||||
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,21 @@ export default function AuthGuard() {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> onLoginSuccess(String token, {String? provider}) async {
|
||||
AuthTokenStore.setToken(token, provider: provider);
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
String? token,
|
||||
bool useCookie = false,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -348,8 +348,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
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<LoginScreen>
|
||||
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 {}
|
||||
|
||||
@@ -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 {
|
||||
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)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
|
||||
() {
|
||||
|
||||
Reference in New Issue
Block a user