From 9498380f7f7c4d1635703c9b641eb29a234be89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 11:55:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop+supabase):=20OAuth=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=99=84=EC=A3=BC=20+=20RLS=20=EC=9E=AC?= =?UTF-8?q?=EA=B7=80=20=ED=94=BD=EC=8A=A4=20(=EB=B9=85=EB=B1=85=20Phase=20?= =?UTF-8?q?5=20Part=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빅뱅 Phase 5 실증 중 발견한 3개 버그 해결: 1. implicit flow 응답 무시: - supabase-js가 persistSession:false 일 때 PKCE code_verifier 저장 못해 implicit flow로 fallback, fragment(#access_token=...&refresh_token=...)로 토큰 전달. handleDeepLink는 ?code=만 파싱하고 있어 무시됨. - handleDeepLink에 fragment parser 추가 (access_token + refresh_token) - CloudSyncService.handleAuthTokens() 신규 — setSession() 후 기존 _onAuthenticated SSOT로 수렴 - 에러 query/fragment(error, error_description) 상세 로깅 - 토큰 값은 로그에 안 찍히도록 (query=yes/no, fragment=yes/no 만) 2. Supabase RLS 무한 재귀: - team_members_read_same_team 정책이 자기 테이블 재조회 → Postgres RLS 엔진 무한 재귀 탐지 에러 - meetings / meeting_memos / meeting_documents / team_invites 정책이 team_members 서브쿼리 경유해서 전부 같이 터짐 + Realtime TIMED_OUT - migration 20260411000001: SECURITY DEFINER 함수 2개 (user_team_ids, user_admin_team_ids) 신규 — Supabase 권장 패턴 - 영향 정책: team_members(4) + meetings(2) + meeting_memos(1) + meeting_documents(2) + team_invites(1) 전부 함수 기반으로 재작성 - supabase db push 완료 3. LoginScreen stale 번들: - Phase 1.5에서 import 제거했는데도 vite HMR/cache 어딘가에서 stale state 유지해서 렌더러에 계속 뜸 - LoginScreen.tsx 파일 자체 삭제 (vite 컴파일 대상 제거) 로그인 성공 확인: yunchan8804@gmail.com 으로 Google OAuth 완주 → users/7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93/d3ro.db 생성 → Initial sync 시작. --- apps/desktop/src/main/index.ts | 96 +++++++++++-- .../src/main/services/CloudSyncService.ts | 36 ++++- .../src/renderer/components/LoginScreen.tsx | 113 ---------------- memory/project_status.md | 57 ++++++++ ...1000001_fix_team_members_rls_recursion.sql | 128 ++++++++++++++++++ 5 files changed, 305 insertions(+), 125 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/LoginScreen.tsx create mode 100644 server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d9c839c..bc515ab 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,9 @@ import path from 'path' import { bootstrap } from './bootstrap' import { setupLifecycle } from './lifecycle' import { getMainWindow } from './windows/WindowManager' +import { getLogger } from './services/LoggerService' + +const deepLinkLogger = getLogger('deep-link') // EPIPE 에러 방지: electron-log가 stdout/stderr에 쓸 때 파이프가 끊기면 크래시 방지 process.stdout?.on?.('error', () => { /* ignore EPIPE */ }) @@ -27,21 +30,92 @@ if (process.defaultApp) { /** * deep link URL을 파싱해서 CloudSyncService로 전달. - * d3ro-voice://auth-callback?code=... + * + * Supabase OAuth 응답 방식 (둘 다 지원): + * - **Implicit flow** (현재 기본): `d3ro-voice://auth-callback#access_token=...&refresh_token=...&token_type=bearer` + * - **PKCE flow**: `d3ro-voice://auth-callback?code=...` + * - **에러**: query 또는 fragment에 `error=...&error_description=...` + * + * 우리 supabase-js 클라이언트는 `persistSession: false`로 떠 있어서 PKCE가 + * 자동 동작하지 않는다. 따라서 Supabase가 implicit flow로 떨어져 fragment에 + * access_token/refresh_token을 직접 박아 보낸다. 그 토큰을 client.auth.setSession()에 + * 주입해서 세션 복원. */ async function handleDeepLink(url: string): Promise { + // 토큰/코드가 포함될 수 있으므로 URL 전체 대신 host + path만 로그 + let parsed: URL try { - const parsed = new URL(url) - if (parsed.host === 'auth-callback') { - const code = parsed.searchParams.get('code') - if (code) { - const { getCloudSyncService } = await import('./services/CloudSyncService') - await getCloudSyncService().handleAuthCallback(code) - } - } - } catch { - // 잘못된 URL은 무시 + parsed = new URL(url) + } catch (err) { + deepLinkLogger.error( + `Failed to parse deep link URL: ${err instanceof Error ? err.message : String(err)}` + ) + return } + deepLinkLogger.info( + `Deep link received: ${parsed.protocol}//${parsed.host}${parsed.pathname} (query=${parsed.search.length > 0 ? 'yes' : 'no'}, fragment=${parsed.hash.length > 0 ? 'yes' : 'no'})` + ) + + if (parsed.host !== 'auth-callback') { + deepLinkLogger.warn(`Unknown deep link host: "${parsed.host}" (expected 'auth-callback')`) + return + } + + // fragment (#...) 와 query (?...) 양쪽 파싱 + const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash + const fragmentParams = new URLSearchParams(fragment) + const queryParams = parsed.searchParams + + // 에러 체크 — query/fragment 둘 다 + const error = queryParams.get('error') ?? fragmentParams.get('error') + if (error) { + const desc = + queryParams.get('error_description') ?? + fragmentParams.get('error_description') ?? + '(no description)' + deepLinkLogger.error(`Supabase OAuth returned error: ${error} — ${desc}`) + return + } + + const { getCloudSyncService } = await import('./services/CloudSyncService') + + // Implicit flow: fragment에 access_token + refresh_token + const accessToken = fragmentParams.get('access_token') + const refreshToken = fragmentParams.get('refresh_token') + if (accessToken && refreshToken) { + deepLinkLogger.info('Implicit flow tokens detected, calling handleAuthTokens()') + try { + await getCloudSyncService().handleAuthTokens({ + accessToken, + refreshToken, + }) + deepLinkLogger.info('handleAuthTokens completed successfully') + } catch (err) { + deepLinkLogger.error( + `handleAuthTokens failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + return + } + + // PKCE flow: query에 code + const code = queryParams.get('code') + if (code) { + deepLinkLogger.info('PKCE flow code detected, calling handleAuthCallback()') + try { + await getCloudSyncService().handleAuthCallback(code) + deepLinkLogger.info('handleAuthCallback completed successfully') + } catch (err) { + deepLinkLogger.error( + `handleAuthCallback failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + return + } + + deepLinkLogger.warn( + 'auth-callback deep link had neither access_token (implicit) nor code (PKCE). Ignoring.' + ) } // 단일 인스턴스 잠금 diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index d6002ce..ff2ba20 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -447,7 +447,7 @@ class CloudSyncService extends EventEmitter { } /** - * Deep link 콜백 처리 — code를 session으로 교환. + * Deep link 콜백 처리 — PKCE flow code를 session으로 교환. * 성공 시 _onAuthenticated(단일 진입점)로 수렴. */ async handleAuthCallback(code: string): Promise { @@ -469,6 +469,40 @@ class CloudSyncService extends EventEmitter { await this._onAuthenticated(data.session, { reason: 'signin' }) } + /** + * Deep link 콜백 처리 — Implicit flow로 받은 access/refresh token을 + * client.auth.setSession()에 주입하여 세션 복원. + * + * supabase-js가 `persistSession: false`로 떠 있을 때 PKCE code_verifier 저장이 + * 없어서 PKCE가 제대로 동작하지 않는다. Supabase가 implicit flow로 떨어져 + * fragment(#access_token=...&refresh_token=...)에 토큰을 담아 보내는데, 이때 + * 이 메서드를 사용한다. + */ + async handleAuthTokens(params: { + accessToken: string + refreshToken: string + }): Promise { + if (!this._client) { + throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다') + } + + const { data, error } = await this._client.auth.setSession({ + access_token: params.accessToken, + refresh_token: params.refreshToken, + }) + if (error || !data.session) { + throw new D3ROError( + ErrorCode.LLMProcessingFailed, + `세션 설정 실패: ${error?.message ?? 'unknown'}` + ) + } + + this._saveRefreshToken(data.session.refresh_token) + logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`) + + await this._onAuthenticated(data.session, { reason: 'signin' }) + } + /** * 로그아웃 — 단일 진입점 _onSignOut에서 세션/DB/녹음 모두 안전하게 정리. */ diff --git a/apps/desktop/src/renderer/components/LoginScreen.tsx b/apps/desktop/src/renderer/components/LoginScreen.tsx deleted file mode 100644 index 43e0654..0000000 --- a/apps/desktop/src/renderer/components/LoginScreen.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// src/renderer/components/LoginScreen.tsx -// SaaS 첫 실행 게이트 — OAuth 로그인 안 됐으면 메인 UI 진입 차단. -// 사용자는 Google/GitHub 버튼만 클릭하면 됨. URL/Key 입력 필드 노출 금지. - -import { useState } from 'react' -import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material' -import GoogleIcon from '@mui/icons-material/Google' -import GitHubIcon from '@mui/icons-material/GitHub' -import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roFontMono, typoSx } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' - -export function LoginScreen(): React.ReactElement { - const { t } = useI18n() - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - const [info, setInfo] = useState(null) - - async function handleSignIn(provider: 'google' | 'github'): Promise { - setError(null) - setInfo(null) - setBusy(true) - try { - const r = await window.electronAPI.cloudSync.signIn({ provider }) - if (!r.success) { - setError(r.error.message) - setBusy(false) - return - } - setInfo(t('login.browserPrompt') ?? '브라우저에서 로그인을 완료해주세요...') - // 성공 시 auth-changed 이벤트가 AuthGate로 전달되어 자동 전환됨. - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - setBusy(false) - } - } - - return ( - - - - D3RO VOICE - - {t('login.subtitle') ?? 'AI 음성 어시스턴트'} - - - - - - - - - - {error && ( - - {error} - - )} - {info && !error && ( - - {info} - - )} - - - {t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'} - - - - - ) -} diff --git a/memory/project_status.md b/memory/project_status.md index 7b211a7..cd71ce6 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,63 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [8] OAuth 로그인 완주 + RLS 재귀 픽스 (Phase 5 Part 1, 2026-04-11) + +> **로그인 성공!** 빅뱅 Phase 5 실증 시작. Google OAuth 토큰 교환 성공, 사용자별 DB 생성 확인. +> 그러나 첫 pull에서 Supabase RLS 무한 재귀 + Realtime TIMED_OUT 발생 → 같은 세션에서 즉시 픽스. + +**성공 이벤트 로그 (11:46:52 ~ 55)** +``` +[Opening OAuth URL in external browser: google] +[deep-link] Implicit flow tokens detected, calling handleAuthTokens() +[CloudSyncService] Signed in (implicit): yunchan8804@gmail.com +[auth:signin] DB opened for 7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93: + .../users/7da3dd02-.../d3ro.db (created=true) +[auth:signin] Initial sync starting — push then pull +[deep-link] handleAuthTokens completed successfully +[auth:signin] Initial push done: pushed=0, errors=0 +``` + +**발견된 버그 + 픽스** + +#### Bug 1: Supabase implicit flow 응답을 데스크톱이 무시 +- 증상: 로그인 4회 시도, 매번 `auth-callback deep link had no 'code' parameter` +- 원인: supabase-js가 `persistSession: false`일 때 PKCE code_verifier 저장소가 없어 **implicit flow로 fallback**. Supabase가 fragment(`#access_token=...&refresh_token=...`)로 토큰 직접 전달. 우리 `handleDeepLink`는 `?code=...`(PKCE)만 파싱. +- 픽스: + - `apps/desktop/src/main/index.ts`: `handleDeepLink`가 query(`?code=`)와 fragment(`#access_token=`) 둘 다 파싱. fragment에 토큰 있으면 `CloudSyncService.handleAuthTokens()` 호출. 에러 파라미터(`error=`, `error_description=`) 상세 로깅. + - `CloudSyncService.handleAuthTokens({accessToken, refreshToken})` 신규 — `client.auth.setSession()`으로 세션 복원 후 기존 `_onAuthenticated(session, {reason: 'signin'})` SSOT 진입점으로 수렴. + - 토큰 로깅은 host+path만, 토큰 값은 로그에 안 찍히도록 수정 (`query=yes/no, fragment=yes/no`) + +#### Bug 2: Supabase RLS 무한 재귀 +- 증상: `Pull meetings failed: infinite recursion detected in policy for relation "team_members"` × 3 테이블 + `Realtime 채널 상태: TIMED_OUT` +- 원인 (`20260409000002_rls_policies.sql`): `team_members` SELECT 정책이 **자기 테이블을 재조회**: + ```sql + CREATE POLICY "team_members_read_same_team" ON team_members + FOR SELECT USING ( + team_id IN (SELECT team_id FROM team_members WHERE user_id = auth.uid()) + ); + ``` + 이 정책이 Postgres RLS 엔진에서 무한 재귀 탐지로 에러. `meetings` / `meeting_memos` / `meeting_documents` / `team_invites` 정책이 모두 `team_members` 서브쿼리를 경유해서 **전부 같이 터짐**. Realtime 구독도 같은 이유로 TIMED_OUT. +- 픽스 (`server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql`): + - `SECURITY DEFINER` 함수 2개 신규 — Supabase 공식 권장 패턴으로 재귀 끊기 + - `public.user_team_ids(uid uuid) RETURNS SETOF uuid` + - `public.user_admin_team_ids(uid uuid) RETURNS SETOF uuid` + - `search_path = public, pg_temp` 고정 + `REVOKE ALL` 후 `authenticated, service_role`에만 `GRANT EXECUTE` + - 기존 정책 재작성: `team_id IN (SELECT ... FROM team_members)` → `team_id IN (SELECT public.user_team_ids(auth.uid()))` + - 영향 정책: `team_members` × 4개, `meetings` × 2개, `meeting_memos` × 1개, `meeting_documents` × 2개, `team_invites` × 1개 + - `supabase db push` 적용 완료 + +#### Bug 3: LoginScreen stale 번들 +- 증상: Phase 1.5에서 LoginScreen import 제거했는데도 렌더러 화면에 LoginScreen 계속 뜸. HMR/vite cache 혼동 상태. +- 원인: 불명 (vite HMR이 어딘가에서 stale state 유지한 듯, grep으로는 import 사용처 0개 확인) +- 픽스: **`LoginScreen.tsx` 파일 자체 삭제**. vite가 컴파일할 대상이 아예 사라져서 stale이 불가능해짐. + +**검증** +- desktop `tsc --noEmit` ✅ +- desktop dev 런타임 ✅ (로그인 성공 확인) +- Supabase `db push` ✅ (migration 적용) +- pull / Realtime 재테스트 — 사용자가 Settings → Cloud Sync → Pull 눌러서 확인 필요 + ### SaaS [7] 3 클라이언트 일원화 검증 — **OAuth provider 연결 대기** (Phase 5) > Phase 1~4+3이 로컬/클라우드 feature gate + sync 인프라를 모두 배선했으나, diff --git a/server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql b/server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql new file mode 100644 index 0000000..0e84010 --- /dev/null +++ b/server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql @@ -0,0 +1,128 @@ +-- ============================================================================ +-- 빅뱅 Phase 5 실증 중 발견: team_members RLS 정책이 자기 테이블을 재조회해서 +-- "infinite recursion detected in policy for relation team_members" 에러. +-- +-- meetings / meeting_memos / meeting_documents / team_invites 정책이 +-- team_members 서브쿼리를 경유하면서 재귀가 트리거되어 pull 전부 실패 + +-- Realtime 구독 TIMED_OUT 발생. +-- +-- 해결: SECURITY DEFINER 함수로 team_id 조회를 감싸 RLS 우회 + 재귀 끊기. +-- 이건 Supabase 공식 권장 패턴. +-- ============================================================================ + +-- ── 헬퍼 함수 ──────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.user_team_ids(uid uuid) + RETURNS SETOF uuid + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp +AS $$ + SELECT team_id FROM public.team_members WHERE user_id = uid; +$$; + +CREATE OR REPLACE FUNCTION public.user_admin_team_ids(uid uuid) + RETURNS SETOF uuid + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp +AS $$ + SELECT team_id FROM public.team_members + WHERE user_id = uid AND role IN ('owner', 'admin'); +$$; + +REVOKE ALL ON FUNCTION public.user_team_ids(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.user_admin_team_ids(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.user_team_ids(uuid) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.user_admin_team_ids(uuid) TO authenticated, service_role; + +-- ── team_members: 재귀 제거 ─────────────────────────────────────────────── + +DROP POLICY IF EXISTS "team_members_read_same_team" ON public.team_members; +CREATE POLICY "team_members_read_same_team" ON public.team_members + FOR SELECT USING ( + user_id = auth.uid() + OR team_id IN (SELECT public.user_team_ids(auth.uid())) + ); + +DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members; +CREATE POLICY "team_members_insert_admin" ON public.team_members + FOR INSERT WITH CHECK ( + team_id IN (SELECT public.user_admin_team_ids(auth.uid())) + ); + +DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members; +CREATE POLICY "team_members_update_admin" ON public.team_members + FOR UPDATE USING ( + team_id IN (SELECT public.user_admin_team_ids(auth.uid())) + ); + +DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members; +CREATE POLICY "team_members_delete_admin_or_self" ON public.team_members + FOR DELETE USING ( + user_id = auth.uid() + OR team_id IN (SELECT public.user_admin_team_ids(auth.uid())) + ); + +-- ── meetings: team_members 직접 참조 → 함수 호출로 교체 ─────────────────── + +DROP POLICY IF EXISTS "meetings_read" ON public.meetings; +CREATE POLICY "meetings_read" ON public.meetings + FOR SELECT USING ( + user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid()))) + ); + +DROP POLICY IF EXISTS "meetings_update" ON public.meetings; +CREATE POLICY "meetings_update" ON public.meetings + FOR UPDATE USING ( + user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_admin_team_ids(auth.uid()))) + ); + +-- ── meeting_memos: meetings RLS 경유 + insert 시 team 체크 ─────────────── + +DROP POLICY IF EXISTS "meeting_memos_insert" ON public.meeting_memos; +CREATE POLICY "meeting_memos_insert" ON public.meeting_memos + FOR INSERT WITH CHECK ( + user_id = auth.uid() + AND meeting_id IN ( + SELECT id FROM public.meetings + WHERE user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid()))) + ) + ); + +-- ── meeting_documents: 동일 패턴 ────────────────────────────────────────── + +DROP POLICY IF EXISTS "meeting_documents_insert" ON public.meeting_documents; +CREATE POLICY "meeting_documents_insert" ON public.meeting_documents + FOR INSERT WITH CHECK ( + user_id = auth.uid() + AND meeting_id IN ( + SELECT id FROM public.meetings + WHERE user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid()))) + ) + ); + +DROP POLICY IF EXISTS "meeting_documents_update" ON public.meeting_documents; +CREATE POLICY "meeting_documents_update" ON public.meeting_documents + FOR UPDATE USING ( + user_id = auth.uid() + OR meeting_id IN ( + SELECT id FROM public.meetings + WHERE team_id IN (SELECT public.user_admin_team_ids(auth.uid())) + ) + ); + +-- ── team_invites: 같은 패턴 오염 ────────────────────────────────────────── + +DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites; +CREATE POLICY "team_invites_read" ON public.team_invites + FOR SELECT USING ( + team_id IN (SELECT public.user_team_ids(auth.uid())) + OR lower(email) = lower((SELECT email FROM auth.users WHERE id = auth.uid())) + );