From 9e8c73060ccbfe971f13f805f4bf936212e040cc Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Fri, 14 Aug 2026 18:41:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=99=88=20=EC=9B=B9=EB=B7=B0?= =?UTF-8?q?=EC=97=90=20=ED=95=99=EC=83=9D=20=ED=86=A0=ED=81=B0=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=ED=95=B4=20=EC=9A=B0=EC=B2=B4=ED=86=B5=20=EB=8B=B5?= =?UTF-8?q?=EC=9E=A5=20=EC=95=8C=EB=A6=BC=20=EB=8C=80=EC=83=81=20=EC=8B=9D?= =?UTF-8?q?=EB=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹뷰가 앱의 @access_token 을 읽을 수 없어 POST /auth/student 로 자기만의 studentId 를 따로 발급했다. 백엔드는 Feedback.studentId -> StudentUser 로 푸시 대상을 찾는데 웹뷰 신원에는 StudentUser 가 없어 답장 알림이 나가지 않았다. - injectedJavaScriptBeforeContentLoaded 로 window.__MOADONG_STUDENT_TOKEN__ 주입. 웹의 첫 API 호출 시점에 토큰이 있어야 하므로 onLoad 이후가 아닌 content load 이전에 주입한다. - 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로 origin 가드를 둔다. 없으면 외부 사이트 이동 시 베어러 토큰이 노출된다. - 토큰 조회가 끝난 뒤 웹뷰를 렌더한다. 발급 실패 시에는 주입 없이 렌더해 웹이 자체 토큰으로 폴백하도록 두고, 홈이 막히지 않게 한다. - ensureAccessToken 에 single-flight 가드 추가. 홈 웹뷰는 스플래시 아래에서 부트스트랩과 동시에 마운트되는데, 신규 설치처럼 저장된 토큰이 없으면 두 호출이 각각 sub 를 생성해 studentId 가 갈렸다. 그러면 FCM 등록 신원과 주입 신원이 달라져 주입 자체가 무의미해진다. Co-Authored-By: Claude Opus 5 (1M context) --- services/auth-token.service.ts | 12 +++++++++- ui/home/home-webview-screen.tsx | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) 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..96a9136 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'; @@ -18,6 +19,9 @@ import styled from 'styled-components/native'; const BASE_URL = `${(process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com').replace(/\/$/, '')}/webview/main`; const USER_AGENT = getWebViewUserAgent(); +// new URL(...) 은 EXPO_PUBLIC_WEBVIEW_URL 이 잘못되면 모듈 로드 시점에 throw 하므로, +// 주입을 건너뛰고 넘어갈 수 있도록 직접 파싱한다. +const WEB_ORIGIN = BASE_URL.match(/^https?:\/\/[^/]+/i)?.[0] ?? null; interface HomeWebViewScreenProps { onError: () => void; @@ -30,12 +34,45 @@ 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 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. + const injectedToken = + studentToken && WEB_ORIGIN + ? `(function(){ + if (window.location.origin !== ${JSON.stringify(WEB_ORIGIN)}) return; + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; + })(); true;` + : undefined; useEffect(() => { if (url) { @@ -195,6 +232,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { style={{ flex: 1 }} source={{ uri: url }} userAgent={USER_AGENT} + injectedJavaScriptBeforeContentLoaded={injectedToken} onMessage={handleMessage} onLoadEnd={handleLoadEnd} onNavigationStateChange={handleNavigationStateChange} From 9e22f8189b05ea3e150c209cc286503d26c3230f Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Fri, 14 Aug 2026 18:44:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20.omc/=20=EB=A5=BC=20gitignore=20?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 44f5a9720e043bba88ed743a1ba9539bc27fc8a8 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Fri, 14 Aug 2026 21:47:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20origin=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A5=BC=20=EC=9B=B9=EB=B7=B0=20=EC=95=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=88=98=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RN 의 URL 폴리필은 origin 을 정규식으로만 뽑아 호스트 대소문자와 기본 포트를 정규화하지 않는다. 반면 window.location.origin 은 정규화된 값이라 EXPO_PUBLIC_WEBVIEW_URL 이 https://MOADONG.com:443 같은 형태면 비교가 어긋나 토큰이 주입되지 않는다. 앱에서 origin 문자열을 만들어 넘기는 대신 BASE_URL 을 그대로 넘기고 웹뷰 안에서 브라우저의 URL 구현으로 비교한다. 양쪽 모두 정규화된 값이 된다. 파싱 실패 시에는 주입하지 않는다(fail closed). Co-Authored-By: Claude Opus 5 (1M context) --- ui/home/home-webview-screen.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 96a9136..be1bac7 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -19,9 +19,6 @@ import styled from 'styled-components/native'; const BASE_URL = `${(process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com').replace(/\/$/, '')}/webview/main`; const USER_AGENT = getWebViewUserAgent(); -// new URL(...) 은 EXPO_PUBLIC_WEBVIEW_URL 이 잘못되면 모듈 로드 시점에 throw 하므로, -// 주입을 건너뛰고 넘어갈 수 있도록 직접 파싱한다. -const WEB_ORIGIN = BASE_URL.match(/^https?:\/\/[^/]+/i)?.[0] ?? null; interface HomeWebViewScreenProps { onError: () => void; @@ -66,13 +63,18 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. - const injectedToken = - studentToken && WEB_ORIGIN - ? `(function(){ - if (window.location.origin !== ${JSON.stringify(WEB_ORIGIN)}) return; - window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; - })(); true;` - : undefined; + // 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) {