跳至主要内容

接入跨環境 Session 協調

本頁適用的配對能力:Web 19.0.0-alpha.138/Server 4.0.0-alpha.39。先完成兩端組裝與同 Cookie scope 的全部分頁升級,再啟用產品。Mock 使用 opaque Access,正式 Server 使用簽章 JWT;產品驗證與授權仍由應用擁有。

建立一份協調器與產品 client​

import { createSessionCoordinator, createHttpClient, ErrorResponse } from '@appfuse/appfuse-web/utils'
import type { HttpClient } from '@appfuse/appfuse-web/utils'

type Session = { sessionId: string; accessToken: string }
declare const apiBase: string
declare const cookiePath: string
declare function runtime(): 'mock' | 'server'
let current: Session | null = null

const coordinator = createSessionCoordinator({
scope: () => ({ apiOrigin: new URL(apiBase).origin, cookieName: 'APP_SERVER_REFRESH', cookiePath }),
mode: runtime,
protocol: '1',
})
const api: HttpClient = createHttpClient({
baseURL: apiBase,
sessionCoordinator: coordinator,
auth: {
getSession: () => current, // 真實產品由一次 store 讀取取得 ID 與 Token
refresh: async (_signal, lease) => {
if (!lease) throw new Error('Coordinated refresh requires a lease')
const { data } = await api.post<Session>('/api/v1/auth/refresh', {}, { authMode: 'none', sessionLease: lease })
if (data.sessionId !== lease.expectedFamilyId) {
throw ErrorResponse.fromResponse(409, { errorCode: 'session-changed' })
}
lease.confirmSession(data.sessionId) // 先驗證作業仍有效,再更新應用狀態
current = data
return data
},
},
})

scope 的 origin、Cookie 名稱、path 必須與 Native/正式 HTTP 殼相同。部署在 servlet context-path 時,Host Cookie path 含該前綴;Vite 使用 /api/v1/auth。runtime 與 scope 在排隊時凍結,取得鎖後改變即拒絕舊作業。同一 family 的 reconcile 不換 generation,其他分頁進行中的一般 GET/POST 可正常完成;expectation 實際改變才換 generation。

安全來源與非開發模式要求 Web Locks;所有模式均要求可讀寫 localStorage。storage 只保存 protocol、generation、pending operation 與 family expectation;不保存 Access、Refresh、OTP、Email 或使用者資料。明示 crossTab: 'single-context' 僅適用確定沒有其他 context 的環境,不能用它宣稱多分頁安全。

開發模式自動相容區網 HTTP(Web 19.0.0-alpha.138 起)​

應用以開發模式啟動時,框架自動啟用區網 HTTP 相容政策,無須增加 appConfig 或環境旗標。 建立 coordinator 時保持一般寫法:

const coordinator = createSessionCoordinator({
scope: () => ({ apiOrigin: new URL(apiBase).origin, cookieName: 'APP_SERVER_REFRESH', cookiePath }),
mode: runtime,
protocol: '1',
})
console.info(coordinator.diagnostics)

模式由消費端建置工具的 process.env.NODE_ENV === 'development' 決定,例如一般 vite dev。 框架 npm 套件保留此判斷,讓應用 dev server/正式建置各自處理,避免被框架發布時的模式固定。 一般 vite build/preview、SIT/UAT/生產及無法判定模式的 runtime 維持 strict。 app.env 標示、IP、HTTP 或 mock/server 都不會單獨觸發降級。

安全來源(包含可信 localhost)一律保留 Web Locks;只有開發模式且 isSecureContext === false 的 HTTP 頁面才使用同 JavaScript context 的操作佇列。 跨分頁互斥不受保證,可能因競爭要求重新登入;完整跨分頁可靠性仍在 HTTPS SIT/UAT 驗證。 取消/逾時後 drain、family fence、localStorage 與認證規則均保留。

alpha.137 的 browserCompatibility 型別與選項保留,舊呼叫端可以移除該設定。 { mode: 'strict' } 可收緊開發模式;舊 development-http 宣告亦受開發模式限制, 不能在非開發模式強制降級。與 legacy crossTab 同時設定仍拒絕;既有 crossTab 明示策略 保留執行機制,不被自動政策覆蓋,一般 HTTP 相容無須設定它。

diagnostics 是唯讀建立時快照,含 policy、secureContext、coordination、uuidSource、 degraded、limitations;降級時每份協調器記錄一次英文 warning。 policy='development-http' 表示選定開發政策,安全來源仍可能 degraded=false。 舊 single-context 的 policy='strict' 只表示未選定自動 HTTP 政策;實際互斥保證應讀 coordination 與 degraded。建立後模式或能力改變不會自動切換策略。

從 /utils 匯入公開 randomUUID(),統一採原生 UUID → getRandomValues() 的安全亂數路徑; 不使用 Math.random,兩者皆不可用或 API 失敗時明確報錯。此替代保留亂數品質,不需要啟用鎖降級。

import { randomUUID, BrowserCapabilityError, getApiErrorMessage } from '@appfuse/appfuse-web/utils'

try {
console.info(randomUUID())
} catch (error) {
if (error instanceof BrowserCapabilityError) {
console.error(error.capability, getApiErrorMessage(error))
} else throw error
}

能力錯誤具有 errorCode: 'browser-capability-unavailable',capability 區分 web-locks、 secure-random、persistent-storage 與 http-compatibility-policy,不帶 HTTP status。 原以 session-coordination-unavailable/503 處理能力不足者須更新分流;metadata 損壞仍維持原協調錯誤。 本擴充不變更認證或 Cookie 的 Secure/SameSite/HttpOnly,也不提供 MSW 的 Service Worker 替代。 區網 HTTP 基本流程使用原本可用的後端/代理配置;不可用的 Cookie 或 Service Worker 會繼續受瀏覽器限制。

包住每個登入與登出意圖​

登入以 mutate('issue', ...) 包住整筆產品 API;業務 proof 成功後才由 Native/正式引擎發行。產品成功回應通過檢查後,先 lease.confirmSession(data.sessionId),再更新應用 store。確認同一結果可冪等呼叫;不同結果或已逾時的 lease 會拒絕。

登出、refresh、identity-change 必須傳入操作當下原子登入快照的 expectedFamilyId,不能從另一分頁最新寫入的 ID 偷換原意圖:

const original = current
if (original) {
await coordinator.mutate('revoke', async lease => {
await api.post('/api/v1/auth/logout', {}, { authMode: 'none', sessionLease: lease })
lease.confirmSignedOut()
current = null
}, { expectedFamilyId: original.sessionId })
}

leased request 明示 authMode: 'none' 或 'session-no-refresh',default session 會在送出前拒絕,以免重入同一鎖。正常 session request 不能手工覆寫另一枚 Bearer。HTTP client 會在送出、回應與 401 重放前核對 family;A 的舊請求不能改用 B 的 Token 重送。

取消傳給 mutate 的 options.signal;responseTimeoutMs 是 UI 等待期限。底層 transport 不因 UI 取消提早終止或釋鎖,已啟動的產品/Native 請求與 body 必須 drain。取消後禁止新 Cookie child;取消排隊作業不送請求。leased Axios request 不另使用 timeout/cancelToken/signal 中止 transport,也不接受無法確認 body drain 的 stream response。

Mock 的整份 handler 加入同一 lease​

Vite 啟用 mockAuthPlugin({ accessSessions: { enabled: true } });Host 的對應 factory 與正式組裝見 Server 指南。瀏覽器只從 /utils 引入 runtime:

import { createMockAccessSessionClient } from '@appfuse/appfuse-web/utils'
const native = createMockAccessSessionClient({ baseURL: () => apiBase, sessionCoordinator: coordinator })

MSW mutation handler 在任何 await、proof 消耗或業務副作用前檢查 X-Appfuse-Session-Protocol: 1,再以 native.withOperation(operationId, async () => { ... }) 登記整份 handler。operationId 取產品請求的 X-Appfuse-Session-Operation;Native issue/refresh/revoke 原樣加入它,不再呼叫 mutate。已結束 lease 的晚到 handler 在副作用前被拒絕。

handler 完成產品驗證後,呼叫 native.issue({requestId, subject, claims, accessTtlSeconds, sessionTtlSeconds, remembered:false}, operationId);刷新帶 lease 對應的 expectedFamilyId。保護資源以 native.inspect(bearer) 得到 principal 後,再判定產品 owner/eligibility;不能解析 opaque Access 授權。inspect 不依 Cookie、不 refresh。Native 成功本身不設定應用已登入,仍須產品流程成功及有效 lease 確認。

本輪實作審閱補正持有證明:replacement 與 Cookie-only logout 都以結構合法 Cookie 內的高熵秘密 selector 定位 family(legacy 無 selector 時要求完整 credential hash 相符)。因此目前 verifier、已輪替的舊 verifier、同秘密 selector 但未見過的 verifier,在這兩種操作都證明持有同一 family;expectedFamilyId 只做 fence,公開 family UUID 本身不是證明。新 proof 完成且新 family 建立成功後才撤舊,失敗須 rollback。refresh 另要求 current verifier;帶同秘密 selector 的非目前 verifier 仍走 replay 拒絕/撤銷,不能取得新 Access。

Mock requestId 只去重同一次技術提交。重放只回 {state:'issued', replayed:true},沒有 Token 或新 Cookie;產品不得把摘要當作新的登入成功。不同登入意圖產生新 requestId。

reload 與中斷後恢復​

呼叫 coordinator.reconcile,delegate 用傳入 lease 執行產品 refresh,再以同一 lease、session-no-refresh 查 /me,都成功才回 {state:'authenticated',sessionId,value}。已知 A 的 lease 必須 fenced refresh,只採 A;不能在 409 後無 fence 再試一次 B。完整可執行的三環境組裝在 appfuse-web/tests/session-contract/main.ts。

結果應用行為
authenticated採用已確認 family/使用者
signed-out無 Session 可恢復
reauthentication-required保留操作意圖,要求新的 proof;不宣稱已安全登出
upgrade-required提示更新目前 context,不清認證、不反覆 bootstrap/OTP/reload
transport/5xx顯示可重試的可用性問題,保留既有 expectation

只有從未建立 expectation、沒有 orphan 的乾淨狀態可做一次 unfenced Cookie bootstrap。signed-out/unconfirmed/無已知 family 的 orphan 不盲目採用 Cookie,但仍允許新的 issue。殘留 Cookie 可能仍有效;刪除 site storage 後可能恢復。共享裝置須完成已知 family 的正式 logout,或由受保護帳號管理執行 principal revoke。

配對升級與驗證​

同 Cookie scope 全部 context 使用 active protocol 1。正式 HTTP 殼在 proof 前 gate protocol,CORS 允許 protocol/family header;operation header 只送 mock。舊 bundle 收到 409 後未必會顯示更新提示,需人工 reload 或先部署過渡 bundle;不能假稱已自動完成全分頁升級。

從 appfuse-web 執行 npm run build && npx playwright test --config playwright.session.config.ts,會以編譯後套件啟動 test-only Host/JWT HTTP server、Vite dev 與正式建置 preview,跑三環境的 Cookie、reload、替換、固定期限、多分頁、late response、drain、outage、protocol 與 orphan 案例。這是框架契約證據;產品 OTP、安全儲存、郵件、業務授權及產品 UAT 另依應用規格驗證。