diff --git a/.gitignore b/.gitignore index 743b28c..ecfbad5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ app-example google-services.json GoogleService-Info.plist ios/**/GoogleService-Info.plist + +# agent tooling +.omc/ diff --git a/services/auth-token.service.ts b/services/auth-token.service.ts index 254b401..922f69c 100644 --- a/services/auth-token.service.ts +++ b/services/auth-token.service.ts @@ -61,11 +61,21 @@ export async function issueAccessToken(): Promise { return token; } +let issuePromise: Promise | null = null; + export async function ensureAccessToken(): Promise { const storedToken = await getStoredAccessToken(); if (storedToken) { return storedToken; } - return issueAccessToken(); + // 첫 실행 시 부트스트랩과 웹뷰가 동시에 호출하면 서로 다른 sub/토큰이 발급되어 + // 앱 신원과 웹뷰 신원이 갈린다. 발급은 항상 한 번만 수행한다. + if (!issuePromise) { + issuePromise = issueAccessToken().finally(() => { + issuePromise = null; + }); + } + + return issuePromise; } diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index fd26ad0..be1bac7 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -1,6 +1,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-context'; import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; +import { ensureAccessToken } from '@/services/auth-token.service'; import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; @@ -30,12 +31,50 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const canGoBackRef = useRef(false); const loadFailedRef = useRef(false); const [loaded, setLoaded] = useState(false); + const [studentToken, setStudentToken] = useState(null); + const [tokenResolved, setTokenResolved] = useState(false); const { markLoading, markReady, markFailed } = useHomeWebViewPreloadContext(); const { sessionId, isLoading: sessionLoading } = useMixpanelContext(); const { subscribedClubIds, toggleSubscribe } = useSubscribedClubsContext(); - const url = sessionLoading ? null : appendSessionId(BASE_URL, sessionId); + // 웹의 첫 API 호출 전에 토큰이 준비돼 있어야 하므로, 조회가 끝난 뒤에 웹뷰를 렌더한다. + // 발급에 실패하면 주입 없이 렌더하고 웹이 자체 토큰으로 폴백한다. + useEffect(() => { + let cancelled = false; + ensureAccessToken() + .then((token) => { + if (!cancelled) setStudentToken(token); + }) + .catch(() => { + if (!cancelled) setStudentToken(null); + }) + .finally(() => { + if (!cancelled) setTokenResolved(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + const url = + sessionLoading || !tokenResolved ? null : appendSessionId(BASE_URL, sessionId); + + // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, + // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. + // origin 비교는 웹뷰 안에서 한다. RN 의 URL 폴리필은 호스트 대소문자와 기본 포트를 + // 정규화하지 않아 window.location.origin 과 어긋날 수 있다. 파싱에 실패하면 주입하지 않는다. + const injectedToken = studentToken + ? `(function(){ + try { + if (new URL(${JSON.stringify(BASE_URL)}).origin !== window.location.origin) return; + } catch (e) { + return; + } + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; + })(); true;` + : undefined; useEffect(() => { if (url) { @@ -195,6 +234,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { style={{ flex: 1 }} source={{ uri: url }} userAgent={USER_AGENT} + injectedJavaScriptBeforeContentLoaded={injectedToken} onMessage={handleMessage} onLoadEnd={handleLoadEnd} onNavigationStateChange={handleNavigationStateChange}