From cc1b74ffb6fcf38015b23dd8444a2ff6524806c2 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 12 Feb 2026 10:24:25 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/components/layout/AppLayout.tsx | 19 ++-- docs/devfront_auth_flow_explanation.md | 91 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 docs/devfront_auth_flow_explanation.md diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 515fe28e..fbe92580 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,11 +1,9 @@ -import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun, User } from "lucide-react"; +import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; import { t } from "../../lib/i18n"; import { Toaster } from "../ui/toaster"; -import { fetchMe } from "../../features/auth/authApi"; const navItems = [ { @@ -18,11 +16,16 @@ const navItems = [ function AppLayout() { const auth = useAuth(); - const { data: profile } = useQuery({ - queryKey: ["me"], - queryFn: fetchMe, - enabled: auth.isAuthenticated, - }); + + // OIDC ID Token에서 프로필 정보 추출 + // auth.user?.profile에는 OIDC 표준 클레임(sub, email, name 등)이 포함됨 + const profile = auth.user?.profile ? { + id: auth.user.profile.sub, + email: auth.user.profile.email, + name: (auth.user.profile.name as string) || (auth.user.profile.preferred_username as string) || auth.user.profile.email, + tenantId: auth.user.profile.tenant_id as string, + tenant: auth.user.profile.tenant as any, + } : null; const [theme, setTheme] = useState<"light" | "dark">(() => { const stored = window.localStorage.getItem("admin_theme"); diff --git a/docs/devfront_auth_flow_explanation.md b/docs/devfront_auth_flow_explanation.md new file mode 100644 index 00000000..9b9a17fa --- /dev/null +++ b/docs/devfront_auth_flow_explanation.md @@ -0,0 +1,91 @@ +# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고 + +이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다. + +## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow) + +### 시퀀스 다이어그램 +```mermaid +sequenceDiagram + actor User + participant DF as DevFront (RP) + participant HY as Ory Hydra (OP) + participant UF as UserFront (Login UI) + participant KR as Ory Kratos (Identity) + + User->>DF: 로그인 버튼 클릭 + DF->>HY: 인증 요청 (/oauth2/auth) + HY->>UF: 로그인 UI 리다이렉트 + UF->>User: 로그인 페이지 표시 + User->>UF: 자격 증명 입력 (Email/PW) + UF->>KR: 인증 수행 + KR-->>UF: 인증 성공 (Session 생성) + UF->>HY: 로그인 승인 요청 + HY->>User: 권한 동의(Consent) 화면 표시 + User->>HY: '허용' 클릭 + HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...) + DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token) + HY-->>DF: 토큰 발급 + Note over DF: [FIX] 백엔드 /api/me 호출 대신
ID Token에서 프로필 정보 직접 추출 + DF->>User: 대시보드 및 프로필 표시 +``` + +### 단계별 설명 + +1. **인증 요청 (Login Request)**: + * 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다. + * `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다. + * 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다. + +2. **사용자 인증 (Authentication)**: + * Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다. + * 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다. + +3. **권한 동의 (Consent)**: + * 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다. + * 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다. + +4. **인증 코드 전달 및 토큰 교환 (Callback)**: + * Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다. + * `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다. + +5. **사용자 정보 로드 (Profile Recovery)**: + * `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다. + +--- + +## 2. 클라이언트 자동 등록의 원리 + +사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다. + +### `init-rp` 서비스의 역할 +* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다. +* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다. + +### 자동 등록된 `devfront` 명세 +```bash +hydra clients create + --endpoint http://hydra:4445 + --id devfront + --grant-types authorization_code,refresh_token + --response-types code + --scope openid,offline_access,profile,email + --token-endpoint-auth-method none \ # Public Client (PKCE 사용) + --callbacks http://localhost:5174/callback; +``` +이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다. + +--- + +## 3. 무한 루프 문제 해결 분석 + +### 발생 원인 (Problem) +* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다. +* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다. +* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다. +* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다. + +### 해결 방법 (Solution) +* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다. +* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다. +* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer ` 헤더를 사용하여 정당한 권한을 증명합니다.