結帳頁突然報錯: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 token | JWT exp = 15 min |
| 16:04 | 用原始 JWT 呼叫 API | 401 — 確認過期 |
| 16:04 | 用 refresh token 換新 JWT | 成功 — 取得新 JWT |
| 16:04 | 用新 JWT 呼叫 API | 200 — 正常回應 |
整個 refresh 過程在 interceptor 裡自動完成,使用者完全無感。
回顧:跨平台 SSO 的 Token 設計清單
這次的教訓可以濃縮成一個檢查清單,適用於任何「主應用開網頁」的 SSO 場景:
- 傳遞層:不只傳 access token,refresh token 也要一起帶
- 儲存層:網頁端要有明確的 token 儲存策略(sessionStorage / cookie)
- 續約層:必須有 401 interceptor + 自動 refresh + 重送機制
- 防護層:Completer 模式防並發 refresh、
_retryflag 防無限迴圈 - GraphQL 層:Apollo Client 用
setContext動態取 token,不要寫死
少了任何一層,使用者就會在某個不可預測的時間點被打斷。而「不可預測」是使用者體驗中最糟糕的特質。
