10d150ac2b
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
病根(不是金鑰沒同步,是身分沒接住):
oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。
修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
display_name/role/libraries 存進 authorization code → access token。
/portal/login 補回 session_expires_in,access_token TTL 夾成
min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。
新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
GET /portal/data/map、/portal/data/map/:library
GET /portal/data/templates、POST /portal/data/templates
GET /portal/data/records/by-template/:t、GET /portal/data/records/:id
POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。
KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。
沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。
驗證:
mcp tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
cypher vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
kbdb vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
// OAuth 短效認證儲存(KV)。儲存鐵律(leo + wiki):
|
||
// 只有「取得的暫時性認證」可進 KV,且帶 TTL——authorization code、access token 是也。
|
||
// 長效機密(owner secret)走 CF Secrets,不進此處。DCR client 不落地(見 OAUTH.md 相容決策)。
|
||
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
||
import { sha256Hex } from "./crypto.js";
|
||
|
||
/**
|
||
* 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。
|
||
*
|
||
* leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」
|
||
* ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。
|
||
*
|
||
* `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上
|
||
* 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL);
|
||
* access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現
|
||
* 「MCP 還連著、底下 session 早死」的鬼打牆。
|
||
*
|
||
* display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。
|
||
* 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準,
|
||
* 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。
|
||
*/
|
||
export interface PortalIdentity {
|
||
session: string;
|
||
display_name: string;
|
||
role: string;
|
||
libraries: string[];
|
||
}
|
||
|
||
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
||
export interface AuthCodeData {
|
||
client_id: string;
|
||
redirect_uri: string;
|
||
code_challenge: string;
|
||
code_challenge_method: string;
|
||
scope: string;
|
||
/** RFC 8707 resource:綁定 token 的目標 MCP server(audience)。 */
|
||
resource: string;
|
||
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
||
namespace: string;
|
||
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
|
||
portal: PortalIdentity;
|
||
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
|
||
portal_session_expires_in: number;
|
||
}
|
||
|
||
/** access token 綁定的資料。 */
|
||
export interface AccessTokenData {
|
||
namespace: string;
|
||
client_id: string;
|
||
scope: string;
|
||
/** token 的目標受眾(= 本 MCP server 的 canonical URI),驗證時比對。 */
|
||
aud: string;
|
||
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
||
exp: number;
|
||
/**
|
||
* 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined,
|
||
* 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。
|
||
*/
|
||
portal?: PortalIdentity;
|
||
}
|
||
|
||
const CODE_PREFIX = "oauth:code:";
|
||
const TOKEN_PREFIX = "oauth:tok:";
|
||
/** authorization code 存活秒數(一次性、極短效)。 */
|
||
export const AUTH_CODE_TTL_SECONDS = 600;
|
||
|
||
/** 存 authorization code(TTL 極短)。回傳 raw code 給 client。 */
|
||
export async function putAuthCode(kv: KVNamespace, code: string, data: AuthCodeData): Promise<void> {
|
||
const key = CODE_PREFIX + (await sha256Hex(code));
|
||
await kv.put(key, JSON.stringify(data), { expirationTtl: AUTH_CODE_TTL_SECONDS });
|
||
}
|
||
|
||
/** 取出並「消費」authorization code(一次性:讀到即刪,防重放)。找不到回 null。 */
|
||
export async function consumeAuthCode(kv: KVNamespace, code: string): Promise<AuthCodeData | null> {
|
||
const key = CODE_PREFIX + (await sha256Hex(code));
|
||
const raw = await kv.get(key);
|
||
if (!raw) return null;
|
||
await kv.delete(key); // 一次性使用(OAuth 2.1:code 用過必失效)
|
||
try {
|
||
return JSON.parse(raw) as AuthCodeData;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 存 access token(TTL = ttlSeconds)。回傳 raw token 給 client。 */
|
||
export async function putAccessToken(
|
||
kv: KVNamespace,
|
||
token: string,
|
||
data: AccessTokenData,
|
||
ttlSeconds: number,
|
||
): Promise<void> {
|
||
const key = TOKEN_PREFIX + (await sha256Hex(token));
|
||
await kv.put(key, JSON.stringify(data), { expirationTtl: ttlSeconds });
|
||
}
|
||
|
||
/** 查 access token → 綁定資料。找不到 / 過期回 null(KV TTL 到期會自動消失,另做 exp 雙檢)。 */
|
||
export async function getAccessToken(kv: KVNamespace, token: string): Promise<AccessTokenData | null> {
|
||
const key = TOKEN_PREFIX + (await sha256Hex(token));
|
||
const raw = await kv.get(key);
|
||
if (!raw) return null;
|
||
try {
|
||
const data = JSON.parse(raw) as AccessTokenData;
|
||
if (typeof data.exp === "number" && data.exp * 1000 < Date.now()) return null;
|
||
return data;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|