access-token-authz.mjs
208 lines 7.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Scope-aware REST authorization for Hub access tokens.
3 *
4 * Web-session JWTs (no `type: mcp_access`) identify the caller by `sub`; role/scopes are
5 * derived elsewhere (`roleForSub` → `scopesForRole`). MCP OAuth access tokens carry an
6 * explicit `scopes` claim and must not be elevated by role lookup (confused-deputy /
7 * scope-elevation guard — docs/DURABLE-AGENT-AUTH-SPEC.md §8).
8 *
9 * SEC-KN-3 / Pass 2 P6: `resolveHostedActorRole` must cap mcp_access roles by token scopes
10 * and must never apply the HUB_ADMIN_USER_IDS allowlist override to agent tokens.
11 *
12 * Phase C: `type: agent_access` uses the same no-allowlist posture plus propose-path rules
13 * (docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md).
14 */
15
16 import { agentScopesPermitMethod } from '../lib/agent-credential-core.mjs';
17
18 /**
19 * HTTP methods that never mutate resource state.
20 * @param {string} method
21 * @returns {boolean}
22 */
23 export function isSafeHttpMethod(method) {
24 const m = String(method || 'GET').toUpperCase();
25 return m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
26 }
27
28 /**
29 * Whether a verified JWT payload is an MCP / agent access token.
30 * @param {object|null|undefined} payload
31 * @returns {boolean}
32 */
33 export function isMcpAccessPayload(payload) {
34 return Boolean(payload && typeof payload === 'object' && payload.type === 'mcp_access');
35 }
36
37 /**
38 * Phase C — scoped REST agent access JWT (`type: agent_access`).
39 * @param {object|null|undefined} payload
40 * @returns {boolean}
41 */
42 export function isAgentAccessPayload(payload) {
43 return Boolean(payload && typeof payload === 'object' && payload.type === 'agent_access');
44 }
45
46 /**
47 * SEC-SEAM-1 / S1 — classify a verified (or candidate) access-token payload.
48 *
49 * @param {object|null|undefined} payload
50 * @returns {'session'|'mcp_access'|'agent_access'|'legacy_session'|'unknown'}
51 */
52 export function resolveActorTokenClass(payload) {
53 if (!payload || typeof payload !== 'object') return 'unknown';
54 if (isMcpAccessPayload(payload)) return 'mcp_access';
55 if (isAgentAccessPayload(payload)) return 'agent_access';
56 if (payload.type === 'session') return 'session';
57 const sub = typeof payload.sub === 'string' ? payload.sub.trim() : '';
58 if (sub && payload.type == null) return 'legacy_session';
59 return 'unknown';
60 }
61
62 /**
63 * SEC-SEAM-1 / S1.3 — true only for mint-stamped learner sessions (`type: 'session'`).
64 * `null` / non-object / legacy / mcp_access / unknown → false (V11).
65 *
66 * @param {object|null|undefined} payload
67 * @returns {boolean}
68 */
69 export function isSessionBoundActor(payload) {
70 return resolveActorTokenClass(payload) === 'session';
71 }
72
73 /**
74 * Map MCP access-token scopes to a Hub role. Never consults sub / admin allowlist.
75 * Only explicit admin scopes elevate; vault:write alone stays member.
76 *
77 * @param {unknown} scopes
78 * @returns {'admin'|'member'}
79 */
80 export function roleFromMcpAccessScopes(scopes) {
81 const list = Array.isArray(scopes) ? scopes.map((s) => String(s)) : [];
82 if (list.includes('admin') || list.includes('vault:admin')) return 'admin';
83 return 'member';
84 }
85
86 /**
87 * Resolve hosted proposal RBAC role from a verified access-token payload.
88 *
89 * - mcp_access: role is capped by `scopes` only (never roleForSub / allowlist).
90 * - web session: `payload.role` or `roleForSub(sub)`.
91 *
92 * @param {object|null|undefined} payload
93 * @param {(sub: string|null|undefined) => string} roleForSub
94 * @returns {{ role: string, isMcpAccess: boolean }}
95 */
96 export function roleFromVerifiedAccessPayload(payload, roleForSub) {
97 if (!payload || typeof payload !== 'object') {
98 return { role: 'member', isMcpAccess: false, isAgentAccess: false };
99 }
100 if (isMcpAccessPayload(payload)) {
101 return { role: roleFromMcpAccessScopes(payload.scopes), isMcpAccess: true, isAgentAccess: false };
102 }
103 if (isAgentAccessPayload(payload)) {
104 return { role: roleFromMcpAccessScopes(payload.scopes), isMcpAccess: false, isAgentAccess: true };
105 }
106 const fromClaim = typeof payload.role === 'string' && payload.role.trim() ? payload.role.trim() : '';
107 const fromSub =
108 typeof roleForSub === 'function' ? String(roleForSub(payload.sub) || '').trim() : '';
109 return { role: fromClaim || fromSub || 'member', isMcpAccess: false, isAgentAccess: false };
110 }
111
112 /**
113 * Whether HUB_ADMIN_USER_IDS may elevate this actor to admin.
114 * Forbidden for mcp_access and agent_access (Pass 2 P6 / SEC-KN-3 / Phase C).
115 *
116 * @param {object|null|undefined} payload
117 * @returns {boolean}
118 */
119 export function mayApplyAdminAllowlistOverride(payload) {
120 return !isMcpAccessPayload(payload) && !isAgentAccessPayload(payload);
121 }
122
123 /**
124 * Pre-fix allowlist inheritance (Pass 2 P6) — used only by security-tier regression tests.
125 * Elevates any actor whose sub is on the admin allowlist, including mcp_access.
126 *
127 * @param {string} role
128 * @param {string|null|undefined} actorSub
129 * @param {(sub: string|null|undefined) => string} roleForSub
130 * @returns {string}
131 */
132 export function applyAdminAllowlistOverrideLegacy(role, actorSub, roleForSub) {
133 let next = role;
134 if (actorSub && next !== 'admin' && roleForSub(actorSub) === 'admin') {
135 next = 'admin';
136 }
137 return next;
138 }
139
140 /**
141 * Whether an MCP access-token scope list permits the HTTP method on REST.
142 * @param {string[]} scopes
143 * @param {string} method
144 * @returns {boolean}
145 */
146 export function mcpScopesPermitMethod(scopes, method) {
147 const list = Array.isArray(scopes) ? scopes : [];
148 const hasWrite =
149 list.includes('vault:write') || list.includes('vault:admin') || list.includes('admin');
150 const hasRead =
151 hasWrite || list.includes('vault:read');
152 if (isSafeHttpMethod(method)) return hasRead;
153 return hasWrite;
154 }
155
156 /**
157 * Resolve `sub` from a verified access-token payload for a REST request method.
158 * Returns null when the token is missing, invalid for identity, or (for mcp_access /
159 * agent_access) insufficient for the method/path.
160 *
161 * @param {object|null|undefined} payload - decoded JWT payload (already verified)
162 * @param {{ method?: string, path?: string }} [opts]
163 * @returns {string|null}
164 */
165 export function subFromVerifiedPayload(payload, opts = {}) {
166 if (!payload || typeof payload !== 'object') return null;
167 const sub = typeof payload.sub === 'string' ? payload.sub : null;
168 if (!sub) return null;
169 if (payload.type === 'mcp_access') {
170 if (!mcpScopesPermitMethod(payload.scopes, opts.method || 'GET')) return null;
171 return sub;
172 }
173 if (payload.type === 'agent_access') {
174 if (payload.aud !== 'knowtation-hub-rest') return null;
175 if (payload.typ !== 'kt_agent_access') return null;
176 if (!agentScopesPermitMethod(payload.scopes, opts.method || 'GET', opts.path || '')) {
177 return null;
178 }
179 }
180 return sub;
181 }
182
183 /**
184 * Vault binding for agent_access (freeze §7.4).
185 * @param {object|null|undefined} payload
186 * @param {string} vaultId
187 * @returns {boolean}
188 */
189 export function assertAgentVaultAllowed(payload, vaultId) {
190 if (!isAgentAccessPayload(payload)) return true;
191 const ids = Array.isArray(payload.vault_ids) ? payload.vault_ids.map(String) : [];
192 const vid = String(vaultId || 'default').trim() || 'default';
193 return ids.includes(vid);
194 }
195
196 export { agentScopesPermitMethod };
197
198 /**
199 * Whether durable MCP / native OAuth agent-auth endpoints may mount.
200 * Offline-locked posture and Netlify serverless both leave them unmounted
201 * (docs/DURABLE-AGENT-AUTH-SPEC.md §14).
202 *
203 * @param {{ sessionSecret?: string|null, netlify?: boolean, offlineLockedActive?: boolean }} opts
204 * @returns {boolean}
205 */
206 export function shouldMountDurableAgentAuth(opts = {}) {
207 return Boolean(opts.sessionSecret) && !opts.netlify && !opts.offlineLockedActive;
208 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago