推式接入(身份握手 / Push Handoff)
推式接入(身份握手 / Push Handoff)
場景:使用者在 工作空間 → 擴充 頁點擊開發者的擴充應用程式,平台把使用者身份以 ?wsa=<JWT> 推給目標擴充應用程式落地頁。開發者在落地頁消費並在後端校驗後、換成自己的工作階段。
本模式下開發者不需要呼叫平台的簽章端點——那是工作空間前端在使用者點擊時主動呼叫的。開發者只負責「收下並校驗」。
端到端時序
loading...
sequenceDiagram
autonumber
actor User as 使用者存取擴充應用程式
participant GB as GPTBots 平台
participant FE as 擴充應用程式-落地頁
participant BE as 擴充應用程式-後端
User->>GB: 點擊應用程式圖示(POST sign-token)
GB->>GB: 校驗點擊人是該 workspace 成員
GB->>GB: 用金鑰簽 wsa<br/>(5 分鐘 JWT,aud=擴充應用程式 host)
GB->>FE: 打開 app_home_url?wsa=<JWT>
Note over FE: consumeHandoff()<br/>讀 ?wsa=,POST 至擴充應用程式後端
FE->>BE: POST /session/exchange { wsa }
BE->>BE: verifyWsa() → identity
BE->>BE: 建立自有 session
BE-->>FE: 回傳 session(Set-Cookie)
Note over FE: history.replaceState 抹掉 ?wsa=
平台產生跳轉 URL 的規則(開發者無需實作):
- 從註冊資訊裡按
app_home_url精確比對目標擴充應用程式(不在冊的 URL 一律拒簽,防身份外洩)。 - 校驗點擊人確實是該工作空間(
workspace_id)成員。 - 若
app_home_url已帶 query,則用&拼接wsa;wsa值已 URL 編碼,你讀取後無需手動解碼。
前端:消費落地頁的 wsa
使用 SDK(推薦)
import { consumeHandoff } from '@gptbots/workspace-extension-sdk';
try {
const identity = await consumeHandoff({
exchangeUrl: '/session/exchange', // 你自己的後端校驗介面
// search: location.search, // 預設讀 location.search
// paramName: 'wsa', // 預設參數名 wsa
// strip: true, // 預設成功後抹除 URL 中的 wsa
});
bootYourApp(identity);
} catch (e) {
// 沒有 wsa(使用者直接存取)、或後端校驗失敗
showLoginOrError(e);
}
import { consumeHandoff } from '@gptbots/workspace-extension-sdk';
try {
const identity = await consumeHandoff({
exchangeUrl: '/session/exchange', // 你自己的後端校驗介面
// search: location.search, // 預設讀 location.search
// paramName: 'wsa', // 預設參數名 wsa
// strip: true, // 預設成功後抹除 URL 中的 wsa
});
bootYourApp(identity);
} catch (e) {
// 沒有 wsa(使用者直接存取)、或後端校驗失敗
showLoginOrError(e);
}
此代碼塊在浮窗中顯示
consumeHandoff 做四件事:① 讀 ?wsa= ② POST { wsa } 到 exchangeUrl ③ 成功後 history.replaceState 抹掉 ?wsa= ④ 回傳後端回傳的 identity。
抹除時機:令牌只在成功交換後才從 URL 抹除,以便一次瞬時失敗可以重新整理重試。這是一枚 5 分鐘一次性令牌;若開發者希望失敗也立即抹除,可
catch後手動呼叫stripHandoffToken()。
不建工作階段,只讀身份(receive-only)
import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk';
const token = readHandoffToken(); // 純函數,回傳原始 JWT 字串或 null
if (token) {
// 仍建議把 token 發給你後端 verifyWsa 後再信任其內容(不要在前端解析 JWT 當可信身份)
await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) });
}
stripHandoffToken(); // 無論如何抹掉 URL 裡的 wsa
import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk';
const token = readHandoffToken(); // 純函數,回傳原始 JWT 字串或 null
if (token) {
// 仍建議把 token 發給你後端 verifyWsa 後再信任其內容(不要在前端解析 JWT 當可信身份)
await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) });
}
stripHandoffToken(); // 無論如何抹掉 URL 裡的 wsa
此代碼塊在浮窗中顯示
⚠️ 不要在前端解開 JWT 就當可信身份。JWT 的簽章只有用金鑰才能校驗,而金鑰只在後端。前端解析僅能用於「非安全」的顯示佔位,任何授權判斷都必須以後端
verifyWsa的結果為準。
後端:校驗 wsa
3.1 用 SDK(推薦)
import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify';
app.post('/session/exchange', (req, res) => {
try {
const identity = verifyWsa(req.body.wsa, {
secret: process.env.EXTENSION_APP_SECRET, // Tier2 per-app 金鑰 或 Tier1 共享金鑰
audience: 'app.example.com', // 擴充應用程式 host,必須等於 aud
// issuer: 'gptbots-workspace', // 預設
// leewaySeconds: 30, // 時鐘漂移容忍,預設 30s
// algorithms: ['HS256'], // 預設
});
// 多租戶隔離:把請求歸到 identity.workspaceId 名下
const sid = createSession(identity);
res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
res.json(identity);
} catch (e) {
const code = e instanceof WsaVerificationError ? e.code : 'Error';
res.status(401).json({ code });
}
});
import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify';
app.post('/session/exchange', (req, res) => {
try {
const identity = verifyWsa(req.body.wsa, {
secret: process.env.EXTENSION_APP_SECRET, // Tier2 per-app 金鑰 或 Tier1 共享金鑰
audience: 'app.example.com', // 擴充應用程式 host,必須等於 aud
// issuer: 'gptbots-workspace', // 預設
// leewaySeconds: 30, // 時鐘漂移容忍,預設 30s
// algorithms: ['HS256'], // 預設
});
// 多租戶隔離:把請求歸到 identity.workspaceId 名下
const sid = createSession(identity);
res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
res.json(identity);
} catch (e) {
const code = e instanceof WsaVerificationError ? e.code : 'Error';
res.status(401).json({ code });
}
});
此代碼塊在浮窗中顯示
不使用 SDK
wsa 是標準 HS256 JWT,任意語言的 JWT 函式庫都能校驗。Java / Node / Python 範例見 05-令牌校驗與安全 §3。無論用不用 SDK,簽章 / iss / aud / exp 四項都必須校驗。
註冊擴充應用程式(取得金鑰)
工作空間的 OWNER/ADMIN:工作空間 → 空間管理 → 擴充應用程式 → 新增,填寫:
| 欄位 | 說明 |
|---|---|
| 應用程式名稱 | 顯示名,建議 ≤ 12 個漢字避免截斷 |
| 應用程式圖示 | 方形圖示,建議 ≥ 128×128 |
| 應用程式入口 URL | 你的 app_home_url,作為唯一鍵,簽章時按完整字串嚴格比對 |
| 驗證模式 | 選 workspace_account(需要傳遞身份) |
提交後一次性明文顯示 App Secret,立即複製儲存(關閉後僅能輪換)。
落地頁 checklist
- 收到
?wsa=後先 POST 至擴充應用程式後端服務進行校驗,再信任內容 - 校驗通過立刻
history.replaceState抹除wsa(consumeHandoff預設已做) - 換成自有工作階段,後續 XHR/fetch/img 不再透傳
wsa - 處理「使用者直接存取、無
wsa」的分支(引導登入或匿名) - 校驗失敗按
WsaVerificationError.code給出可讀提示
