結帳頁突然報錯:Missing or invalid credentials

使用者在 App 內開啟網頁版結帳頁,輸入折扣碼後收到一個刺眼的錯誤訊息:「Missing or invalid credentials」。

不是每個人都遇到,也不是每次都發生。但只要在頁面上停留超過一段時間再操作,就一定會觸發。

聽起來很熟悉?沒錯,這跟前一篇文章的問題同源——JWT 15 分鐘過期。差別在於,上次是後端的 refresh API 被權限系統擋掉,這次是前端根本沒有呼叫 refresh 的能力

架構背景:App 開網頁的 SSO 流程

我們的 Flutter App 會透過 in-app browser 開啟 Vue 網頁。token 的傳遞方式很直接:

https://web.example.com/checkout/xxx?token=eyJhbG...

Vue 從 URL query param 取得 JWT,存進 sessionStorage,之後的 API 呼叫都帶這個 token。

問題在於:只傳了 JWT,沒有傳 refresh token

JWT 有 15 分鐘的壽命。使用者開啟頁面、慢慢填寫發票資料、比較方案——15 分鐘一晃就過了。接下來任何 API 呼叫都會收到 401,但 Vue 手上沒有 refresh token,無法換新的 JWT。只能眼睜睜看著使用者被擋在門外。

為什麼 Flutter 不需要擔心,但 Vue 必須處理

Flutter App 本身有完整的 token refresh 機制:interceptor 攔截 401、用 refresh token 換新 JWT、重送失敗的 request。整個流程無縫,使用者無感。

但 Vue 網頁是透過 SSO 被「借」來用的——它的認證完全依賴 App 給的那一把 JWT。一旦過期,就像拿著一張過期的通行證,沒有任何補救手段。

這是跨平台 SSO 整合常見的盲點:主應用的認證能力不會自動延伸到被開啟的網頁

修復策略:完整的 Token 續約鏈路

修復分成兩端:Flutter 負責「給」,Vue 負責「用」。

Flutter 端:多傳一個 refreshToken

改動極小,在組裝 URL 時從 TokenService 多取一個 refresh token:

final token = await tokenService.getToken();
final refreshToken = await tokenService.getRefreshToken();

queryParams['token'] = token;
if (refreshToken != null) {
  queryParams['refreshToken'] = refreshToken;
}
// URL: ?token=eyJ...&refreshToken=abc123...

就這樣。Flutter 端的改動只有 3 行。

Vue 端:從零建立 Refresh 機制

Vue 端才是重頭戲,需要補上四個環節。

1. 接收並儲存 refresh token

原本 tokenHandler.js 只處理 token 參數,現在要一併提取 refreshToken

const refreshToken = urlParams.get('refreshToken')
if (refreshToken) {
  sessionStorage.setItem('RefreshToken', refreshToken)
}

2. 集中式的 refresh 邏輯

新增 authService.js,核心是 refreshAccessToken()。這裡有個關鍵設計——Completer 模式防止並發 refresh:

let refreshPromise = null

export const refreshAccessToken = async () => {
  // 如果已經有 refresh 進行中,共用同一個 Promise
  if (refreshPromise) return refreshPromise

  refreshPromise = _doRefresh()
  try {
    return await refreshPromise
  } finally {
    refreshPromise = null
  }
}

為什麼要防並發?想像使用者的頁面同時發出 3 個 API 請求,全部收到 401。如果每個都獨立觸發 refresh,會打出 3 次 /auth/refresh——其中 2 次會因為 refresh token 已經被第一次用掉(one-time use)而失敗。Completer 模式確保同一時刻只有一次 refresh 在執行,其他呼叫者共用結果。

3. Axios interceptor 自動重送

新增 axiosInstance.js,在 response interceptor 攔截 401:

axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true  // 防止無限迴圈
      const newJwt = await refreshAccessToken()
      originalRequest.headers.Authorization = `Bearer ${newJwt}`
      return axiosInstance(originalRequest) // 用新 token 重送
    }
    return Promise.reject(error)
  }
)

_retry flag 是另一個重要細節:如果 refresh 本身也失敗(比如 refresh token 也過期了),不會陷入無限重試。

4. Apollo Client 改為動態取 token

原本的 Apollo Client 在建立時就把 JWT 寫死在 header:

// 之前:JWT 在建立時就固定了,之後 refresh 了也不會更新
const userToken = new ApolloClient({
  uri: GRAPHQL_API_URL,
  headers: { authorization: `Bearer ${JWTtoken}` }
})

改成用 setContext 動態取值,每次 request 都從 sessionStorage 讀最新的 JWT:

const authLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    authorization: sessionStorage.getItem('LoginJWT')
      ? `Bearer ${sessionStorage.getItem('LoginJWT')}` : ''
  }
}))

實測驗證:等 15 分鐘看它自己活過來

在 STG 環境跑了完整的端對端測試:

時間點動作結果
0:00登入取得 JWT + refresh tokenJWT exp = 15 min
16:04用原始 JWT 呼叫 API401 — 確認過期
16:04用 refresh token 換新 JWT成功 — 取得新 JWT
16:04用新 JWT 呼叫 API200 — 正常回應

整個 refresh 過程在 interceptor 裡自動完成,使用者完全無感。

回顧:跨平台 SSO 的 Token 設計清單

這次的教訓可以濃縮成一個檢查清單,適用於任何「主應用開網頁」的 SSO 場景:

  • 傳遞層:不只傳 access token,refresh token 也要一起帶
  • 儲存層:網頁端要有明確的 token 儲存策略(sessionStorage / cookie)
  • 續約層:必須有 401 interceptor + 自動 refresh + 重送機制
  • 防護層:Completer 模式防並發 refresh、_retry flag 防無限迴圈
  • GraphQL 層:Apollo Client 用 setContext 動態取 token,不要寫死

少了任何一層,使用者就會在某個不可預測的時間點被打斷。而「不可預測」是使用者體驗中最糟糕的特質。