Mimic iOS Feeld app on auth + version bump to 9.4.3
Captured a real iOS Feeld token-refresh request — our outbound headers were unmistakably "not the iOS app." Aligning so requests fingerprint identically. - APP_VERSION 8.11.0 → 9.4.3 in constants.ts, server/index.js, vite.config.ts - Bundle id corrected to com.3nder.threender (was com.3nder.ios) - REQUEST_HEADERS User-Agent now the realistic Alamofire iOS UA, not 'feeld-mobile' - server/index.js refreshAccessToken now sends the full Firebase iOS header set (FirebaseAuth.iOS UA, X-Client-Version, X-Firebase-AppCheck fallback, X-Firebase-GMPID, X-Ios-Bundle-Identifier) and uses camelCase body keys. Response parsing accepts both camelCase and snake_case for resilience. - vite proxy /api/firebase now applies the same iOS headers in dev mode - vite proxy /api/graphql strips browser sec-* fingerprint headers and sets the realistic Alamofire UA unconditionally (was a conditional 'feeld-mobile') Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+36
-9
@@ -1255,8 +1255,14 @@ app.put('/api/okcupid/token', (req, res) => {
|
|||||||
const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA';
|
const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA';
|
||||||
const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
|
const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
|
||||||
const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql';
|
const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql';
|
||||||
const APP_VERSION = '8.11.0';
|
const APP_VERSION = '9.4.3';
|
||||||
const OS_VERSION = '26.2.1';
|
const OS_VERSION = '26.2.1';
|
||||||
|
// iOS app identifiers (mirror of src/config/constants.ts)
|
||||||
|
const IOS_BUNDLE_ID = 'com.3nder.threender';
|
||||||
|
const IOS_DEVICE_MODEL = 'iPhone14_4';
|
||||||
|
const FIREBASE_SDK_VERSION = '11.5.0';
|
||||||
|
const FIREBASE_GMP_ID = '1:594152761603:ios:f52cf15efff827861b2136';
|
||||||
|
const FIREBASE_APPCHECK_FALLBACK = 'eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==';
|
||||||
const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json');
|
const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json');
|
||||||
const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json');
|
const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json');
|
||||||
const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json');
|
const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json');
|
||||||
@@ -1333,12 +1339,27 @@ class FeeldAPIClient {
|
|||||||
throw new Error('No refresh token available — seed from browser first');
|
throw new Error('No refresh token available — seed from browser first');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror the headers the real iOS Feeld app sends on token refresh —
|
||||||
|
// anything missing here flags the request as "not the iOS app."
|
||||||
const response = await fetch(FIREBASE_REFRESH_URL, {
|
const response = await fetch(FIREBASE_REFRESH_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Accept-Language': 'en',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Host': 'securetoken.googleapis.com',
|
||||||
|
'User-Agent': `FirebaseAuth.iOS/${FIREBASE_SDK_VERSION} ${IOS_BUNDLE_ID}/${APP_VERSION} iPhone/${OS_VERSION} hw/${IOS_DEVICE_MODEL}`,
|
||||||
|
'X-Client-Version': `iOS/FirebaseSDK/${FIREBASE_SDK_VERSION}/FirebaseCore-iOS`,
|
||||||
|
'X-Firebase-AppCheck': FIREBASE_APPCHECK_FALLBACK,
|
||||||
|
'X-Firebase-GMPID': FIREBASE_GMP_ID,
|
||||||
|
'X-Ios-Bundle-Identifier': IOS_BUNDLE_ID,
|
||||||
|
},
|
||||||
|
// iOS app uses camelCase keys — Firebase accepts both, but the iOS shape is what we want.
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
grant_type: 'refresh_token',
|
grantType: 'refresh_token',
|
||||||
refresh_token: this.refreshToken,
|
refreshToken: this.refreshToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1348,16 +1369,22 @@ class FeeldAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.accessToken = data.access_token;
|
// Firebase returns camelCase keys when the request body is camelCase, snake_case when
|
||||||
this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000;
|
// it's snake_case — accept either so a future body shape change doesn't silently break.
|
||||||
|
const accessToken = data.accessToken ?? data.access_token;
|
||||||
|
const expiresIn = data.expiresIn ?? data.expires_in;
|
||||||
|
const newRefreshToken = data.refreshToken ?? data.refresh_token;
|
||||||
|
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.expiresAt = Date.now() + parseInt(expiresIn) * 1000;
|
||||||
|
|
||||||
// Update stored refresh token (Firebase rotates them)
|
// Update stored refresh token (Firebase rotates them)
|
||||||
if (data.refresh_token && data.refresh_token !== this.refreshToken) {
|
if (newRefreshToken && newRefreshToken !== this.refreshToken) {
|
||||||
this.refreshToken = data.refresh_token;
|
this.refreshToken = newRefreshToken;
|
||||||
this.saveCredentials(this.profileId, this.refreshToken, this.analyticsId);
|
this.saveCredentials(this.profileId, this.refreshToken, this.analyticsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[FeeldAPI] Token refreshed, expires in', data.expires_in, 'seconds');
|
console.log('[FeeldAPI] Token refreshed, expires in', expiresIn, 'seconds');
|
||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
// ⚠️ When updating, also update vite.config.ts proxy headers (search for APP_VERSION)
|
// ⚠️ When updating, also update vite.config.ts proxy headers and server/index.js APP_VERSION
|
||||||
export const APP_VERSION = '8.11.0';
|
export const APP_VERSION = '9.4.3';
|
||||||
export const OS_VERSION = '26.2.1';
|
export const OS_VERSION = '26.2.1';
|
||||||
|
// iOS app identifiers captured from a real iPhone — used by the proxy + server to mimic the iOS app.
|
||||||
|
export const IOS_BUNDLE_ID = 'com.3nder.threender';
|
||||||
|
export const IOS_DEVICE_MODEL = 'iPhone14_4';
|
||||||
|
export const FIREBASE_SDK_VERSION = '11.5.0';
|
||||||
|
export const FIREBASE_GMP_ID = '1:594152761603:ios:f52cf15efff827861b2136';
|
||||||
|
// Sent by the iOS app when on-device AppCheck fails — Firebase still accepts this fallback.
|
||||||
|
export const FIREBASE_APPCHECK_FALLBACK = 'eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==';
|
||||||
|
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
// Use Vite proxy to bypass CORS
|
// Use Vite proxy to bypass CORS
|
||||||
@@ -16,7 +23,8 @@ export const REQUEST_HEADERS = {
|
|||||||
'x-device-os': 'ios',
|
'x-device-os': 'ios',
|
||||||
'x-app-version': APP_VERSION,
|
'x-app-version': APP_VERSION,
|
||||||
'x-os-version': OS_VERSION,
|
'x-os-version': OS_VERSION,
|
||||||
'User-Agent': 'feeld-mobile',
|
// Realistic iOS Alamofire UA — matches what the real Feeld app sends.
|
||||||
|
'User-Agent': `Feeld/${APP_VERSION} (${IOS_BUNDLE_ID}; build:1; iOS ${OS_VERSION}) Alamofire/5.9.1`,
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
|||||||
+32
-6
@@ -37,10 +37,14 @@ export default defineConfig({
|
|||||||
// Remove browser headers that reveal this is a web client
|
// Remove browser headers that reveal this is a web client
|
||||||
proxyReq.removeHeader('origin');
|
proxyReq.removeHeader('origin');
|
||||||
proxyReq.removeHeader('referer');
|
proxyReq.removeHeader('referer');
|
||||||
// Ensure mobile app headers are preserved
|
proxyReq.removeHeader('sec-fetch-dest');
|
||||||
if (!proxyReq.getHeader('user-agent')?.includes('feeld')) {
|
proxyReq.removeHeader('sec-fetch-mode');
|
||||||
proxyReq.setHeader('User-Agent', 'feeld-mobile');
|
proxyReq.removeHeader('sec-fetch-site');
|
||||||
}
|
proxyReq.removeHeader('sec-ch-ua');
|
||||||
|
proxyReq.removeHeader('sec-ch-ua-mobile');
|
||||||
|
proxyReq.removeHeader('sec-ch-ua-platform');
|
||||||
|
// Match the real iOS app's Alamofire UA. Keep APP_VERSION in sync with constants.ts.
|
||||||
|
proxyReq.setHeader('User-Agent', 'Feeld/9.4.3 (com.3nder.threender; build:1; iOS 26.2.1) Alamofire/5.9.1');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -49,6 +53,28 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api\/firebase/, ''),
|
rewrite: (path) => path.replace(/^\/api\/firebase/, ''),
|
||||||
secure: false,
|
secure: false,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
// Strip browser fingerprint headers
|
||||||
|
proxyReq.removeHeader('origin');
|
||||||
|
proxyReq.removeHeader('referer');
|
||||||
|
proxyReq.removeHeader('sec-fetch-dest');
|
||||||
|
proxyReq.removeHeader('sec-fetch-mode');
|
||||||
|
proxyReq.removeHeader('sec-fetch-site');
|
||||||
|
proxyReq.removeHeader('sec-ch-ua');
|
||||||
|
proxyReq.removeHeader('sec-ch-ua-mobile');
|
||||||
|
proxyReq.removeHeader('sec-ch-ua-platform');
|
||||||
|
// Mirror the headers a real iOS Feeld app sends on token refresh.
|
||||||
|
proxyReq.setHeader('User-Agent', 'FirebaseAuth.iOS/11.5.0 com.3nder.threender/9.4.3 iPhone/26.2.1 hw/iPhone14_4');
|
||||||
|
proxyReq.setHeader('Accept', '*/*');
|
||||||
|
proxyReq.setHeader('Accept-Language', 'en');
|
||||||
|
proxyReq.setHeader('Accept-Encoding', 'gzip, deflate, br');
|
||||||
|
proxyReq.setHeader('X-Client-Version', 'iOS/FirebaseSDK/11.5.0/FirebaseCore-iOS');
|
||||||
|
proxyReq.setHeader('X-Firebase-AppCheck', 'eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==');
|
||||||
|
proxyReq.setHeader('X-Firebase-GMPID', '1:594152761603:ios:f52cf15efff827861b2136');
|
||||||
|
proxyReq.setHeader('X-Ios-Bundle-Identifier', 'com.3nder.threender');
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'/api/images': {
|
'/api/images': {
|
||||||
target: 'https://res.cloudinary.com',
|
target: 'https://res.cloudinary.com',
|
||||||
@@ -84,10 +110,10 @@ export default defineConfig({
|
|||||||
proxyReq.removeHeader('sec-ch-ua-platform');
|
proxyReq.removeHeader('sec-ch-ua-platform');
|
||||||
// Set mobile app headers to match iOS app
|
// Set mobile app headers to match iOS app
|
||||||
// APP_VERSION: keep in sync with src/config/constants.ts APP_VERSION
|
// APP_VERSION: keep in sync with src/config/constants.ts APP_VERSION
|
||||||
proxyReq.setHeader('User-Agent', 'Feeld/8.11.0 (com.3nder.ios; build:1; iOS 26.2.1) Alamofire/5.9.1');
|
proxyReq.setHeader('User-Agent', 'Feeld/9.4.3 (com.3nder.threender; build:1; iOS 26.2.1) Alamofire/5.9.1');
|
||||||
proxyReq.setHeader('Accept', '*/*');
|
proxyReq.setHeader('Accept', '*/*');
|
||||||
proxyReq.setHeader('Accept-Language', 'en-US,en;q=0.9');
|
proxyReq.setHeader('Accept-Language', 'en-US,en;q=0.9');
|
||||||
proxyReq.setHeader('x-app-version', '8.11.0');
|
proxyReq.setHeader('x-app-version', '9.4.3');
|
||||||
proxyReq.setHeader('x-device-os', 'ios');
|
proxyReq.setHeader('x-device-os', 'ios');
|
||||||
proxyReq.setHeader('x-os-version', '26.2.1');
|
proxyReq.setHeader('x-os-version', '26.2.1');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user