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/app.json b/app.json
index a4116c6..a20dee2 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "모아동",
"slug": "moadong-app",
- "version": "1.6.0",
+ "version": "1.7.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "moadongapp",
@@ -10,7 +10,7 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": false,
- "buildNumber": "16",
+ "buildNumber": "17",
"googleServicesFile": "./GoogleService-Info.plist",
"bundleIdentifier": "com.moadong.moadong",
"associatedDomains": [
@@ -26,7 +26,7 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 16,
+ "versionCode": 17,
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj
index 98d23bc..82e08c2 100644
--- a/ios/app.xcodeproj/project.pbxproj
+++ b/ios/app.xcodeproj/project.pbxproj
@@ -417,7 +417,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
- CURRENT_PROJECT_VERSION = 16;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 2QMK9GBWN6;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -430,7 +430,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.6.0;
+ MARKETING_VERSION = 1.7.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -454,7 +454,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
- CURRENT_PROJECT_VERSION = 16;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 2QMK9GBWN6;
INFOPLIST_FILE = app/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -462,7 +462,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.6.0;
+ MARKETING_VERSION = 1.7.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/ios/app/Info.plist b/ios/app/Info.plist
index 71a5bfa..bde57a7 100644
--- a/ios/app/Info.plist
+++ b/ios/app/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.6.0
+ 1.7.0
CFBundleSignature
????
CFBundleURLTypes
@@ -39,7 +39,7 @@
CFBundleVersion
- 16
+ 17
LSMinimumSystemVersion
12.0
LSRequiresIPhoneOS
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}