vtrc-mobile — Core pipeline: endpoint, auth, token, RSA
บทนี้ไล่ request path จริงของ vtrc-mobile (branch: master) จาก UI → network → token storage โดยเน้นสี่ธีมที่กระทบทั้งแอป:
- Host config drift 3 ไฟล์
- Auth / login / refresh
- RSA (
makeCsID+ biometric keypair) - AsyncStorage vs sensitive-storage (token ไม่ได้อยู่ที่เดียวกัน)
1. Endpoint resolution — ไม่มี runtime resolver
ทำไมต่างจาก vtrc-web
vtrc-web เลือก GraphQL endpoint ตอน runtime จาก window.location.href ผ่าน checkLinkURL() (ดู canonical truths / Vol 6) — bundle เดียวใช้ได้หลาย environment เพราะเบราว์เซอร์มี origin ให้ตรวจ
vtrc-mobile ไม่มี "URL ที่เปิดอยู่" ในความหมายเดียวกัน ทีมเก็บ endpoint เป็นค่าคงที่ในซอร์ส แล้ว comment/uncomment + rebuild เพื่อสลับ PROD / UAT / local
แผนที่ 3 ไฟล์ที่ประกาศ host ซ้ำ
┌─────────────────────────────────────────────────────────────┐
│ src/config/index.js │
│ Config.API = …/api/graphql ← fetch.js, gql/client│
│ Config.MEETING_API = UAT /meeting ← meeting ทุกเส้น │
├─────────────────────────────────────────────────────────────┤
│ src/constants/HostApi.js │
│ HOST / HOSTGRAHQL(/api) / HOSTWORKFORCE / HOSTRECRUITMENT│
│ ← sso, recruitment, withdraw upload, หลาย container │
├─────────────────────────────────────────────────────────────┤
│ src/utils/hostUrl.js │
│ HOST / UAT_HOST (ไม่มี path) ← Conicle.js เท่านั้น │
└─────────────────────────────────────────────────────────────┘ค่าที่ uncomment อยู่บน master ตอนเขียนเอกสารนี้
| แหล่ง | ตัวแปร | ค่า | หลักฐาน |
|---|---|---|---|
| config | Config.API | https://vtrcapi.redcross.or.th/api/graphql | src/config/index.js:3-4 |
| config | Config.MEETING_API | https://uat-vtrcapi.redcross.or.th/meeting | src/config/index.js:12 |
| config | UAT API (comment) | https://uat-vtrcapi.redcross.or.th/api-uat/graphql | src/config/index.js:6-7 |
| config | Local API (comment) | http://192.168.0.162:8081/api/graphql | src/config/index.js:9-10 |
| HostApi | HOST | https://vtrcapi.redcross.or.th | src/constants/HostApi.js:21 |
| HostApi | HOSTGRAHQL | https://vtrcapi.redcross.or.th/api | src/constants/HostApi.js:23 |
| HostApi | UAT block (comment) | …/api-uat, workforce, recruitment UAT | src/constants/HostApi.js:2-18 |
| hostUrl | HOST | https://vtrcapi.redcross.or.th | src/utils/hostUrl.js:5 |
| hostUrl | UAT_HOST | https://uat-vtrcapi.redcross.or.th | src/utils/hostUrl.js:2 |
Drift ที่พิสูจน์ได้จากซอร์ส (ไม่ใช่สมมติฐาน)
| ข้อ | รายละเอียด | ผลกระทบ |
|---|---|---|
| Path suffix ไม่ตรง | Config.API ลงท้าย /api/graphql แต่ HOSTGRAHQL ลงท้ายแค่ /api | ผู้เรียก HOSTGRAHQL ต้องต่อ /graphql เอง — actions/withdrawSlice.js:271 ทำ ${HOSTGRAHQL}/graphql |
| Meeting ค้าง UAT | MEETING_API ไม่มีบรรทัด prod ให้ uncomment | ฟีเจอร์ meeting ทั้งก้อนใน production build ยิง UAT — CORR-MOBILE-01 |
| hostUrl แยกก้อน | Conicle.js อ่าน HOST จาก hostUrl.js ไม่ใช่ HostApi.js | สลับ env ที่ HostApi / Config อย่างเดียว ไม่ สลับ Conicle download URL |
| Import พังเงียบ | fetchRestFull.js:3 import HOSTAPIDEV แต่ HostApi ไม่ export | CORR-MOBILE-02 |
ใครอ่านไฟล์ไหน
| Consumer | อ่านจาก | หลักฐาน |
|---|---|---|
utils/fetch.js (GraphQL หลัก) | Config.API | src/utils/fetch.js:3,31 |
gql/client.js (Apollo) | Config.API | src/gql/client.js:4,7 |
utils/axios.js + meetingService.js | Config.MEETING_API | src/utils/axios.js:4-5, meetingService.js:327-329 |
actions/ssoSlice.js | HOST จาก HostApi | src/actions/ssoSlice.js:2,19 |
actions/recruitment.js | HOSTRECRUITMENT | src/actions/recruitment.js:2,7 |
actions/withdrawSlice.js (upload) | HOSTGRAHQL | src/actions/withdrawSlice.js:46,271 |
containers/Conicle.js | HOST จาก hostUrl | src/containers/Conicle.js:21,82 |
| หลาย container (PDF/manual URL) | HOST จาก HostApi | เช่น LeaveDetail.js, ManualLeave.js, ManualPMS.js |
จำนวน import จาก constants/HostApi ใน src/ ประมาณ 25 จุด; จาก utils/hostUrl มี 1 จุด (Conicle.js)
2. GraphQL transport — utils/fetch.js ไม่ใช่ Apollo
ทำไมมี Apollo client แล้วยังไม่ใช่ path หลัก
src/gql/client.js สร้าง ApolloClient ชี้ Config.API พร้อม timeout 10 วินาทีและ fetchPolicy: 'no-cache' (client.js:6-25) แต่ thunk ส่วนใหญ่ใน features/* เรียก:
import fetch from '../utils/fetch';
await fetch({ body: JSON.stringify({ query: SOME_MUTATION(...) }) });ตัวอย่าง: login ใน authSlice.js:392-396
Header ที่แนบทุกครั้งผ่าน SSL pinning
fetchSSL(Config.API, {
method: 'POST',
disableAllSecurity: Config.isDevMode,
timeoutInterval: 50000,
sslPinning: { certs: ['digicert2025'] },
...opt,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
//API KEY Production
apiKey: '346155c3-cea8-41f3-a9a0-305d0cc7ccf4',
//API KEY UAT (comment)
...headers, // รวม Authorization ถ้ามี token
},
});หลักฐาน: vtrc-mobile/src/utils/fetch.js:31-47
ลำดับก่อนยิง:
- อ่าน
store.getState().auth.token.accessTokenด้วย ramdapath(fetch.js:21-27) - ถ้ามี token → ใส่
Authorization: Bearer … - ผสม
apiKeyproduction แบบ hardcode (UAT key ถูก comment คู่กัน)
Cert file: android/app/src/main/assets/digicert2025.cer
apiKey ซ้ำสามจุด
| จุด | รูปแบบ | หลักฐาน |
|---|---|---|
utils/fetch.js:43 | inline string ใน header | production |
constants/Authen.js:1-3 | export const API_KEY | ใช้ใน fetchRestFull.js:26, biometric loginByPass |
utils/login.js:49-50 | inline ใน makeCsID | hash เข้า csId |
ค่าเดียวกัน: 346155c3-cea8-41f3-a9a0-305d0cc7ccf4 — UAT คู่ comment: 9d464b27-442d-4cb4-9357-ba16d0482c4f
Error code handling ใน fetch.js
errors[0].extensions.code | พฤติกรรม | บรรทัด |
|---|---|---|
TOKEN_EXPIRED | เรียก REFRESH_TOKEN แล้ว retry request เดิม | fetch.js:54-82 |
TOKEN_INVALID | persist/PURGE + Actions.mainmenu(REPLACE) | fetch.js:83-86 |
SESSION_EXPIRED / SESSION_INVALID / AUTH_LIMIT / PROFILE_NOT_FOUND / INVALID_PASSWORD_5_TIMES | Actions.checklogin(POP_TO) + generateCaptcha() | fetch.js:87-97 |
captcha codes (CAPTCHA_*, AUTH_USER_OR_ORG_WITH_CAPTCHA_INVALID) | เด้ง checklogin + เปิด captcha UI | fetch.js:98-107 |
| อื่น ๆ | resolve(res) กลับไปให้ caller จัดการ | fetch.js:108-110 |
isSuccess = (res) => res.data && !res.errors (fetch.js:120)
Helpers buildQuery / queryfy / queryfy2 / queryfy3 แปลง JS object เป็น GraphQL argument string แบบมือ — ไม่มี variables map ของ Apollo (fetch.js:121-248)
REST twin — fetchRestFull.js
- ใส่
API_KEYจากconstants/Authen.jsเมื่อisApiKey=true(fetchRestFull.js:25-27) - บน 401 /
jwt expiredจะเรียกfetchSSL(จาก fetch.js) เพื่อ refresh แล้ว retry (fetchRestFull.js:42-67) - import
HOSTAPIDEVที่ไม่มีใน HostApi — CORR-MOBILE-02
3. Login flow
Flow ปกติ (รหัสผ่าน)
CheckLogin / Login UI
│ makeCsID() → RSA publicEncrypt({clientId, createdAt, hashSum})
│ `src/utils/login.js:45-74`
│ `src/containers/CheckLogin.js` เรียก makeCsID ก่อน submit
▼
dispatch(login({ …mutation fields รวม csId, empCode, organization, … }))
│ `src/features/authSlice.js:389-426`
▼
fetch({ query: LOGIN_MUTATION(buildQuery(mutation), version) })
│ version = DeviceInfo.getVersion()
│ `gql/mutation/auth.js:21` + `authSlice.js:392-395`
▼
loginSuccess(token fields) → redux auth.token (persist ผ่าน keychain)
fetchProfile(accessToken)
redirectByPass(accessToken) → GET Config.MEETING_API/redirectbypass
queryProfileData(…) → ยิง user detail หลายตัวพร้อมกัน
requestUserPermission(…) → FCM token → UPDATE_DEVICE_TOKEN_MUTATION
▼
Actions.mainmenu(REPLACE) หรือ Actions.courseconicle() ถ้า isTrialorganization ถูก hardcode เป็น UUID คงที่ทั้งใน CheckLogin และ authSlice reducer:
CheckLogin.js:63และตอนสร้าง payload:142,150→3F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00authSlice.js:145ตั้งstate.organizationเป็นค่าเดียวกันตอน loginSuccess path
ไม่พบ UI สลับ organization ใน mobile — UNVERIFIED ว่า backend รองรับหลาย org สำหรับ device key นี้หรือไม่
queryProfileData — fan-out หลัง login
authSlice.js:775-798 dispatch ต่อเนื่อง:
USER_QUERY_TYPE.bankAccount(เรียกซ้ำสองครั้งในโค้ด — บรรทัด 777 และ 780)employeeCompensation,emplGroup,providentFundemployeeTax,employeeTaxDisability,employeeFamily- drafts:
employeeTaxDisabilityDraft,employeeFamilyChildDraft employeeSocialSecurity,detailTaxgetAccordingProfile- ถ้ามี
uuIDจาก AsyncStorage →fetchAuthSessionMoblie
Enum อยู่ที่ src/constants/User.js:17-31
Logout
authSlice.js:428-457:
LOGOUT_MUTATIONพร้อม BearerremoveFCMToken()persist/PURGEActions.mainmenu(REPLACE)requestUserPermission()อีกครั้งหลัง 1 วินาที (ลงทะเบียน device token แบบ public)
ทุก branch ของ error ก็ PURGE + เด้ง mainmenu เช่นกัน
4. Token storage — keychain vs AsyncStorage
Auth token → sensitive storage (ปลอดภัยกว่า)
redux auth.token.{accessToken, refreshToken, …}
→ persistReducer(persistConfig)
→ createSensitiveStorage({ keychainService: 'vtrcKeychain',
sharedPreferencesName: 'vtrcSharedPrefs' })
→ react-native-sensitive-infoหลักฐาน: src/app/store.js:55-65, src/app/redux-persist-sensitive-storage.js:13-37
slip ถูก blacklist ไม่ persist (store.js:64)
นี่ต่างจาก vtrc-web ที่เก็บ token ใน localStorage — mobile ใช้ keychain/encrypted prefs สำหรับ session จริง
AsyncStorage — flags และ biometric private key
src/utils/setting.js ห่อ @react-native-community/async-storage:
| Key | Getter/Setter | เนื้อหา |
|---|---|---|
showSlideTutorial | getSlideTutorial / setSlideTutorial | boolean |
firsttime | getShowIntro / setShowIntro | โชว์ intro หรือไม่ |
FCMToken | getFCMToken / setFCMToken / removeFCMToken | push token |
ID | getUuID / setUuID / removeUuID | auth session id จาก createAuthSession |
Encode | getEncode / setEncode / removeEncode | private key base64 สำหรับ biometric sign |
หลักฐาน: src/utils/setting.js:3-114
เพิ่มในจุดอื่น:
| Key | ที่ใช้ | หลักฐาน |
|---|---|---|
firstRun | App.js onBeforeLift — purge ครั้งแรกหลังติดตั้ง | App.js:195-199 |
showPopUp | MainMenu.js | MainMenu.js:282-297 |
showSlideTutorial | อ่านตรงใน MainMenu ด้วย | MainMenu.js:285 |
ข้อควรจำ: JWT ไม่อยู่ใน AsyncStorage แต่ biometric RSA private key อยู่ ที่ key Encode แบบ plain JSON string — ใครได้ filesystem access / backup ที่ไม่เข้ารหัสระดับแอป อ่าน private key ของ biometric ได้ — SEC-MOBILE-04
5. Token refresh — สองทาง
A) Inline ใน fetch.js (ระหว่างใช้งาน)
เมื่อ response มี TOKEN_EXPIRED:
- อ่าน
refreshToken+accessTokenจาก store - เรียก
fetch({ body: REFRESH_TOKEN(...) })ซ้ำ (recursive) - ถ้าสำเร็จ →
dispatch(refreshTokenSuccess(accessToken))แล้ว retry request เดิม - ถ้าไม่สำเร็จ →
resolve(res)ของ request เดิม (ไม่ PURGE ที่นี่)
หลักฐาน: src/utils/fetch.js:54-82, query ที่ src/gql/query/auth.js:73
B) Cold start ใน authSlice.refreshToken
เรียกจาก PersistGate.onBeforeLift (App.js:210):
refreshToken(token)
├── ถ้า refresh/access ว่าง → intro หรือ decideenter ตาม getShowIntro()
├── ยิง REFRESH_TOKEN
├── สำเร็จ → ssoCheckEmpLogin({username: token.ssoId}, …, true)
│ refreshTokenSuccess → redirectByPass → fetchProfile
│ queryProfileData → mainmenu หรือ courseconicle
└── ล้มเหลว → intro / decideenterหลักฐาน: src/features/authSlice.js:555-623
สังเกต: cold-start refresh เรียก ssoCheckEmpLogin ด้วย isRefreshToken=true (authSlice.js:589) — sync SSO profile แม้ session GraphQL ยังใช้ได้
มี thunk คู่ refreshTokenExpired (authSlice.js:626+) สำหรับเคสหมดอายุระหว่างใช้งานจากจุดอื่น
6. RSA — สองระบบคนละกุญแจ
ระบบ A: makeCsID — PEM คู่ใน login.js (bundle)
const RSA_KEY = {
public: `-----BEGIN PUBLIC KEY----- …`,
private: `-----BEGIN RSA PRIVATE KEY----- …`, // ไม่ควรอยู่ใน client
};
const makeCsID = async () => {
const clientId = DeviceInfo.getUniqueId();
const createdAt = moment().valueOf();
const apiKey = '346155c3-…'; // production
const hashSum = sha256(`${clientId}:${createdAt}:${apiKey}`);
const csIdBuffer = crypto.publicEncrypt(
RSA_KEY.public,
Buffer.from(JSON.stringify({ clientId, createdAt, hashSum })),
);
return csIdBuffer.toString('base64');
};หลักฐาน: src/utils/login.js:6-74
- Client ใช้ public encrypt เพื่อสร้าง
csIdส่งไปกับ login - Private key อยู่ในไฟล์เดียวกัน (บรรทัด 17-42) พร้อม comment ทดสอบ
privateDecryptที่ถูกปิดไว้ (68-71) - กุญแจชุดนี้ถูก copy ไป
new-vtrc-mobileด้วย — SEC-MOBILE-01
Private key ใน bundle = ใครแตก APK/IPA ถอด PEM ได้ทันที และถ้า server ใช้คู่กุญแจนี้ถอด csId / เชื่อถือ signature ที่สมมติว่ามีแค่ server ถือ private อยู่ ความหมายของ cryptography พังทั้งก้อน
ระบบ B: Biometric login — keypair สร้างตอนเปิดใน Setting
เปิดใช้งาน (Setting.js:34-44):
const keys = await RSA.generateKeys(2048); // react-native-rsa-native
let encodePublicKey = Buffer.from(keys.public).toString('base64');
let encodePrivateKey = Buffer.from(keys.private).toString('base64');
dispatch(createAuthSession({ publicKey: encodePublicKey, isEnable: 1 }, accessToken));
setEncode(encodePrivateKey); // AsyncStorage 'Encode'หลักฐาน: src/containers/Setting.js:34-44, mutation CREATE_AUTHEN_SESSION_MUTATION (gql/mutation/auth.js:119)
createAuthSession thunk (authSlice.js:836-870):
- ยิง mutation →
setUuID(res.data.createAuthSession)เก็บ session id ใน AsyncStorageID - เรียก
ReactNativeBiometrics.createKeys('Confirm fingerprint')เพิ่ม (แยกจาก RSA.generateKeys ของ Setting) — publicKey จาก biometrics ไม่ถูกส่งต่อ ใน.then(บรรทัด 854-857 ว่าง)
ปิดใช้งาน: destroyAuthSession + removeUuID + removeEncode + ReactNativeBiometrics.deleteKeys (Setting.js:45-58)
authenBiometric — เซ็นแล้ว loginByPass
getEncode() → decode base64 → PEM private
payload = `${epoch}|${uuid}`
signatureToken = RSA-SHA256 sign(payload) ด้วย private จาก Encode
ReactNativeBiometrics prompt (TouchID / FaceID / Biometrics)
→ dispatch(loginByPass({ asid, apiKey: API_KEY, organization, token: signatureToken, data: payload }))หลักฐาน: src/features/authSlice.js:953-1062
loginByPass ยิง LOGIN_BYPASS_MUTATION แล้วเข้า mainmenu แบบเดียวกับ login ปกติ (authSlice.js:899-923)
เรียกจาก CheckLogin.js:123 เมื่อมี uuID ใน AsyncStorage
7. SSO path (คู่ขนานกับ GraphQL login)
src/actions/ssoSlice.js ไม่ใช่ createSlice — เป็น async helpers:
| ฟังก์ชัน | URL |
|---|---|
ssoCheckEmpLogin | POST ${HOST}/SSOService/checkEmpLogin |
ssoVerify | POST ${HOST}/SSOService/verify |
หลักฐาน: src/actions/ssoSlice.js:18-19,62-63
ผล isVerify หลายค่า (success, checkusername, checkinvalidpassword, errorNameEng, …) นำไป Actions.verifysso() หรือ dispatch fetchSSOProfileSuccess
หลัง GraphQL login สำเร็จยังเรียก redirectByPass ไป meeting UAT host เสมอ — ไม่เกี่ยวกับ SSO โดยตรง แต่ผูก cold-start เดียวกัน
Scenes SSO: verifysso, otpsso, changepasswordsso, settingsecuresso (App.js:949-967)
8. Meeting redirect หลัง auth
export const redirectByPass = async (accessToken) => {
try {
const {data} = await axios.get(`${Config.MEETING_API}/redirectbypass`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
} catch (e) { console.log(e); }
};หลักฐาน: src/services/meetingService.js:327-338
เรียกจาก login และ refreshToken สำเร็จ (authSlice.js:409,591) — error ถูกกลืนด้วย console.log ไม่มี user feedback
เพราะ Config.MEETING_API เป็น UAT → session bridge นี้ใน production app ไปที่ UAT meeting เสมอ (CORR-MOBILE-01)
9. Push permission + device token
requestUserPermission (authSlice.js:802-834):
messaging().registerDeviceForRemoteMessages()requestPermission()→ ถ้า authorized/provisionalgetToken()→ FCM token- ถ้ามี Bearer →
UPDATE_DEVICE_TOKEN_MUTATIONไม่มี →UPDATE_PUBLIC_DEVICE_TOKEN_MUTATION setFCMTokenลง AsyncStorage- retry สูงสุดผ่านตัวแปร module-level
countเริ่มที่ 3
Deep-link จาก notification ดู 03-features-map
10. Jailbreak gate และ update check
| กลไก | พฤติกรรม | หลักฐาน |
|---|---|---|
| JailMonkey | ถ้า jailbreak และ !Config.isDevMode → Alert แล้ว RNExitApp.exitApp() | App.js:202-208 |
fetchCheckUpdateVersion ใน onBeforeLift | comment ปิด | App.js:213-224 |
fetchCheckUpdateVersion ใน CheckLogin mount | ยังเรียกอยู่ | CheckLogin.js useEffect (บรรทัด ~147-152 จาก excerpt) |
Config.isDevMode = process.env.NODE_ENV !== 'production' (config/index.js:14) — release bundle ที่ minify เป็น production จะเปิด jailbreak gate และเปิด SSL pinning
11. ASCII — ภาพรวม auth + storage
┌──────────────┐
│ CheckLogin │
└──────┬───────┘
makeCsID() │ authenBiometric()
(login.js PEM) │ (Encode in AsyncStorage)
▼
login / loginByPass (authSlice)
│
▼
utils/fetch.js → Config.API (vtrc-api GraphQL)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
auth.token FCMToken Encode / ID
(keychain via (AsyncStorage) (AsyncStorage)
redux-persist)
│
├── redirectByPass → MEETING_API (UAT)
└── queryProfileData → user queries12. LOGIN_MUTATION — apiKey ใน header ไม่ใช่ใน body (active)
Mutation ที่ export จริง ไม่ใส่ apiKey ใน GraphQL arguments:
export const LOGIN_MUTATION = (query, version) => `
mutation {
login(
${query}
,clientInfo: {
appVersion: "${version}"
}
) {
appUsername
sessionID
refreshToken
accessToken
notiChangePwdFlag
}
}
`;หลักฐาน: src/gql/mutation/auth.js:21-36
apiKey ถูกส่งทาง HTTP header โดย utils/fetch.js:43 แทน
ในไฟล์เดียวกันยังมีเวอร์ชันเก่าที่ comment ไว้ซึ่งเคยใส่ apiKey: "346155c3-…" และ UAT key ใน body (gql/mutation/auth.js:3-18, 38-50) — string เหล่านี้ยังอยู่ในซอร์สและเป็นจุดหลุด secret เพิ่ม (ดู 04-scorecard หัวข้อ LOGIN_MUTATION archaeology)
LOGIN_BYPASS_MUTATION รับ apiKey จาก argument ที่ authenBiometric ส่งเข้าไปเป็น field ใน mutation (authSlice.js:980-986 ใช้ API_KEY จาก Authen.js) — คนละช่องทางกับ login ปกติ
13. SSL pinning — พฤติกรรมตามโหมด
| เงื่อนไข | disableAllSecurity | Pinning |
|---|---|---|
NODE_ENV === 'production' (release bundle โดยทั่วไป) | false | ใช้ cert ชื่อ digicert2025 |
อื่น ๆ (isDevMode === true) | true | ปิดทั้งหมด |
หลักฐาน: config/index.js:14, fetch.js:33-37, asset android/app/src/main/assets/digicert2025.cer
Timeout ซ้อนสองชั้น: AbortController 8 วินาที (fetch.js:19-20) และ timeoutInterval: 50000 ของ ssl-pinning (fetch.js:34) — ค่าไม่เท่ากัน; request อาจถูก abort จากฝั่ง controller ก่อนที่ pinning timeout จะทำงาน
Apollo path (gql/client.js) ไม่มี SSL pinning — ใช้ createHttpLink ปกติ; ถ้ามีโค้ดใดเรียก client.query โดยตรงจะไม่ได้รับ pin เดียวกับ fetch.js (ณ เวลาเขียน path หลักของ auth/user คือ fetch.js)