Skip to content

vtrc-mobile — Core pipeline: endpoint, auth, token, RSA

บทนี้ไล่ request path จริงของ vtrc-mobile (branch: master) จาก UI → network → token storage โดยเน้นสี่ธีมที่กระทบทั้งแอป:

  1. Host config drift 3 ไฟล์
  2. Auth / login / refresh
  3. RSA (makeCsID + biometric keypair)
  4. 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 ตอนเขียนเอกสารนี้

แหล่งตัวแปรค่าหลักฐาน
configConfig.APIhttps://vtrcapi.redcross.or.th/api/graphqlsrc/config/index.js:3-4
configConfig.MEETING_APIhttps://uat-vtrcapi.redcross.or.th/meetingsrc/config/index.js:12
configUAT API (comment)https://uat-vtrcapi.redcross.or.th/api-uat/graphqlsrc/config/index.js:6-7
configLocal API (comment)http://192.168.0.162:8081/api/graphqlsrc/config/index.js:9-10
HostApiHOSThttps://vtrcapi.redcross.or.thsrc/constants/HostApi.js:21
HostApiHOSTGRAHQLhttps://vtrcapi.redcross.or.th/apisrc/constants/HostApi.js:23
HostApiUAT block (comment)…/api-uat, workforce, recruitment UATsrc/constants/HostApi.js:2-18
hostUrlHOSThttps://vtrcapi.redcross.or.thsrc/utils/hostUrl.js:5
hostUrlUAT_HOSThttps://uat-vtrcapi.redcross.or.thsrc/utils/hostUrl.js:2

Drift ที่พิสูจน์ได้จากซอร์ส (ไม่ใช่สมมติฐาน)

ข้อรายละเอียดผลกระทบ
Path suffix ไม่ตรงConfig.API ลงท้าย /api/graphql แต่ HOSTGRAHQL ลงท้ายแค่ /apiผู้เรียก HOSTGRAHQL ต้องต่อ /graphql เอง — actions/withdrawSlice.js:271 ทำ ${HOSTGRAHQL}/graphql
Meeting ค้าง UATMEETING_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 ไม่ exportCORR-MOBILE-02

ใครอ่านไฟล์ไหน

Consumerอ่านจากหลักฐาน
utils/fetch.js (GraphQL หลัก)Config.APIsrc/utils/fetch.js:3,31
gql/client.js (Apollo)Config.APIsrc/gql/client.js:4,7
utils/axios.js + meetingService.jsConfig.MEETING_APIsrc/utils/axios.js:4-5, meetingService.js:327-329
actions/ssoSlice.jsHOST จาก HostApisrc/actions/ssoSlice.js:2,19
actions/recruitment.jsHOSTRECRUITMENTsrc/actions/recruitment.js:2,7
actions/withdrawSlice.js (upload)HOSTGRAHQLsrc/actions/withdrawSlice.js:46,271
containers/Conicle.jsHOST จาก hostUrlsrc/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/* เรียก:

javascript
import fetch from '../utils/fetch';
await fetch({ body: JSON.stringify({ query: SOME_MUTATION(...) }) });

ตัวอย่าง: login ใน authSlice.js:392-396

Header ที่แนบทุกครั้งผ่าน SSL pinning

javascript
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

ลำดับก่อนยิง:

  1. อ่าน store.getState().auth.token.accessToken ด้วย ramda path (fetch.js:21-27)
  2. ถ้ามี token → ใส่ Authorization: Bearer …
  3. ผสม apiKey production แบบ hardcode (UAT key ถูก comment คู่กัน)

Cert file: android/app/src/main/assets/digicert2025.cer

apiKey ซ้ำสามจุด

จุดรูปแบบหลักฐาน
utils/fetch.js:43inline string ใน headerproduction
constants/Authen.js:1-3export const API_KEYใช้ใน fetchRestFull.js:26, biometric loginByPass
utils/login.js:49-50inline ใน makeCsIDhash เข้า csId

ค่าเดียวกัน: 346155c3-cea8-41f3-a9a0-305d0cc7ccf4 — UAT คู่ comment: 9d464b27-442d-4cb4-9357-ba16d0482c4f

ดู SEC-MOBILE-03

Error code handling ใน fetch.js

errors[0].extensions.codeพฤติกรรมบรรทัด
TOKEN_EXPIREDเรียก REFRESH_TOKEN แล้ว retry request เดิมfetch.js:54-82
TOKEN_INVALIDpersist/PURGE + Actions.mainmenu(REPLACE)fetch.js:83-86
SESSION_EXPIRED / SESSION_INVALID / AUTH_LIMIT / PROFILE_NOT_FOUND / INVALID_PASSWORD_5_TIMESActions.checklogin(POP_TO) + generateCaptcha()fetch.js:87-97
captcha codes (CAPTCHA_*, AUTH_USER_OR_ORG_WITH_CAPTCHA_INVALID)เด้ง checklogin + เปิด captcha UIfetch.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() ถ้า isTrial

organization ถูก hardcode เป็น UUID คงที่ทั้งใน CheckLogin และ authSlice reducer:

  • CheckLogin.js:63 และตอนสร้าง payload :142,1503F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00
  • authSlice.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, providentFund
  • employeeTax, employeeTaxDisability, employeeFamily
  • drafts: employeeTaxDisabilityDraft, employeeFamilyChildDraft
  • employeeSocialSecurity, detailTax
  • getAccordingProfile
  • ถ้ามี uuID จาก AsyncStorage → fetchAuthSessionMoblie

Enum อยู่ที่ src/constants/User.js:17-31

Logout

authSlice.js:428-457:

  1. LOGOUT_MUTATION พร้อม Bearer
  2. removeFCMToken()
  3. persist/PURGE
  4. Actions.mainmenu(REPLACE)
  5. 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:

KeyGetter/Setterเนื้อหา
showSlideTutorialgetSlideTutorial / setSlideTutorialboolean
firsttimegetShowIntro / setShowIntroโชว์ intro หรือไม่
FCMTokengetFCMToken / setFCMToken / removeFCMTokenpush token
IDgetUuID / setUuID / removeUuIDauth session id จาก createAuthSession
EncodegetEncode / setEncode / removeEncodeprivate key base64 สำหรับ biometric sign

หลักฐาน: src/utils/setting.js:3-114

เพิ่มในจุดอื่น:

Keyที่ใช้หลักฐาน
firstRunApp.js onBeforeLift — purge ครั้งแรกหลังติดตั้งApp.js:195-199
showPopUpMainMenu.jsMainMenu.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:

  1. อ่าน refreshToken + accessToken จาก store
  2. เรียก fetch({ body: REFRESH_TOKEN(...) }) ซ้ำ (recursive)
  3. ถ้าสำเร็จ → dispatch(refreshTokenSuccess(accessToken)) แล้ว retry request เดิม
  4. ถ้าไม่สำเร็จ → 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)

javascript
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):

javascript
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 ใน AsyncStorage ID
  • เรียก 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
ssoCheckEmpLoginPOST ${HOST}/SSOService/checkEmpLogin
ssoVerifyPOST ${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

javascript
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):

  1. messaging().registerDeviceForRemoteMessages()
  2. requestPermission() → ถ้า authorized/provisional
  3. getToken() → FCM token
  4. ถ้ามี Bearer → UPDATE_DEVICE_TOKEN_MUTATION ไม่มี → UPDATE_PUBLIC_DEVICE_TOKEN_MUTATION
  5. setFCMToken ลง AsyncStorage
  6. 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 ใน onBeforeLiftcomment ปิด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 queries

12. LOGIN_MUTATION — apiKey ใน header ไม่ใช่ใน body (active)

Mutation ที่ export จริง ไม่ใส่ apiKey ใน GraphQL arguments:

javascript
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 — พฤติกรรมตามโหมด

เงื่อนไขdisableAllSecurityPinning
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)


ขั้นตอนถัดไป

ไป 03 · Features map — cluster ตาม domain