device-oauth-provider.mjs
384 lines 14.1 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 10 days ago
1 /**
2 * RFC 8628 device authorization for Hub “Connect cloud agent” (Phase B).
3 *
4 * Endpoints (mounted at /api/v1/auth/device):
5 * GET /.well-known/oauth-authorization-server discovery
6 * POST /authorize device authorization request
7 * POST /token poll device_code → mcp_access + refresh
8 * POST /approve Hub UI: bind authenticated user to user_code
9 * POST /deny Hub UI: deny pending user_code
10 * GET /pending Hub UI: list pending (no secrets)
11 * POST /revoke revoke a refresh token (agent credential)
12 * GET /setup-pack non-secret cloud-agent setup instructions
13 *
14 * Security:
15 * - Device codes hashed at rest; refresh via createGatewayRefreshStore({ consistency: 'strong' })
16 * - Access tokens are mcp_access JWTs (same shape as MCP OAuth Phase A)
17 * - Approve/deny require authenticated Hub session (getUserId)
18 * - Poll rate limited via slow_down; user_code brute-force limited
19 * - Never log device_code, refresh_token, or access_token
20 */
21
22 import crypto from 'node:crypto';
23 import express from 'express';
24 import jwt from 'jsonwebtoken';
25 import {
26 createDeviceAuthorization,
27 approveUserCode,
28 denyUserCode,
29 pollDeviceCode,
30 listPendingForDisplay,
31 pruneExpiredDeviceCodes,
32 normalizeUserCode,
33 DEVICE_CODE_TTL_MS,
34 DEVICE_POLL_INTERVAL_SEC,
35 } from './device-oauth-store.mjs';
36 import {
37 DEFAULT_TOKEN_TTL_MS,
38 DEFAULT_FAMILY_TTL_MS,
39 } from '../lib/refresh-token-core.mjs';
40
41 const MCP_TOKEN_EXPIRY_SECONDS = 3600;
42 const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
43
44 /** In-memory rate limit for approve attempts by IP (user_code brute-force). */
45 const _approveAttempts = new Map();
46 const APPROVE_WINDOW_MS = 15 * 60 * 1000;
47 const APPROVE_MAX_ATTEMPTS = 20;
48
49 /**
50 * @param {string} key
51 * @returns {boolean} true if allowed
52 */
53 function _allowApproveAttempt(key) {
54 const now = Date.now();
55 let bucket = _approveAttempts.get(key);
56 if (!bucket || now - bucket.start > APPROVE_WINDOW_MS) {
57 bucket = { start: now, count: 0 };
58 _approveAttempts.set(key, bucket);
59 }
60 bucket.count += 1;
61 return bucket.count <= APPROVE_MAX_ATTEMPTS;
62 }
63
64 /**
65 * Apply scope ceiling (never exceed role scopes).
66 * @param {string[]} requested
67 * @param {string[]} ceiling
68 * @returns {string[]}
69 */
70 export function applyDeviceScopeCeiling(requested, ceiling) {
71 if (!Array.isArray(ceiling) || ceiling.length === 0) return ['vault:read'];
72 if (!Array.isArray(requested) || requested.length === 0) return [...ceiling];
73 return requested.filter((s) => ceiling.includes(s));
74 }
75
76 /**
77 * Create the device OAuth Express router.
78 *
79 * @param {{
80 * baseUrl: string,
81 * sessionSecret: string,
82 * refreshStore: { issue: Function, rotate: Function, revoke: Function, peek?: Function },
83 * getUserId: (req: import('express').Request) => string | null,
84 * grantedScopes: (sub: string) => string[],
85 * hubVerificationPath?: string,
86 * }} opts
87 */
88 export function createDeviceOAuthRouter(opts) {
89 const {
90 baseUrl,
91 sessionSecret,
92 refreshStore,
93 getUserId,
94 grantedScopes,
95 hubVerificationPath = '/hub/#settings/integrations',
96 } = opts;
97
98 if (!sessionSecret || typeof sessionSecret !== 'string') {
99 throw new Error('createDeviceOAuthRouter requires sessionSecret');
100 }
101 if (!refreshStore || typeof refreshStore.issue !== 'function') {
102 throw new Error('createDeviceOAuthRouter requires refreshStore');
103 }
104 if (typeof getUserId !== 'function') {
105 throw new Error('createDeviceOAuthRouter requires getUserId');
106 }
107 if (typeof grantedScopes !== 'function') {
108 throw new Error('createDeviceOAuthRouter requires grantedScopes');
109 }
110
111 const issuer = `${String(baseUrl).replace(/\/$/, '')}/api/v1/auth/device`;
112 const verificationUri = `${String(baseUrl).replace(/\/$/, '')}${hubVerificationPath}`;
113 const router = express.Router();
114 router.use(express.json({ limit: '32kb' }));
115 router.use(express.urlencoded({ extended: false, limit: '32kb' }));
116
117 // RFC 8414 discovery (device grant advertised)
118 router.get('/.well-known/oauth-authorization-server', (_req, res) => {
119 res.json({
120 issuer,
121 device_authorization_endpoint: `${issuer}/authorize`,
122 token_endpoint: `${issuer}/token`,
123 revocation_endpoint: `${issuer}/revoke`,
124 grant_types_supported: [DEVICE_GRANT_TYPE, 'refresh_token'],
125 response_types_supported: ['none'],
126 scopes_supported: ['vault:read', 'vault:write'],
127 token_endpoint_auth_methods_supported: ['none'],
128 code_challenge_methods_supported: [],
129 });
130 });
131
132 /**
133 * POST /authorize — agent starts device flow (no user auth required).
134 * Body (JSON or form): client_id?, client_name?, scope?
135 */
136 router.post('/authorize', async (req, res) => {
137 try {
138 const clientId = String(req.body?.client_id || req.body?.clientId || 'cloud-agent').slice(0, 128);
139 const clientName = String(req.body?.client_name || req.body?.clientName || 'Cloud agent').slice(0, 128);
140 const scopeRaw = req.body?.scope || req.body?.scopes || 'vault:read vault:write';
141 const scopes = String(scopeRaw).trim().split(/\s+/).filter(Boolean).slice(0, 16);
142 const created = await createDeviceAuthorization({
143 clientId,
144 clientName,
145 scopes,
146 });
147 const verificationUriComplete = `${verificationUri}${verificationUri.includes('?') || verificationUri.includes('#') ? '&' : '?'}user_code=${encodeURIComponent(created.userCode)}`;
148 res.status(200).json({
149 device_code: created.deviceCode,
150 user_code: created.userCode,
151 verification_uri: verificationUri,
152 verification_uri_complete: verificationUriComplete,
153 expires_in: created.expiresIn,
154 interval: created.interval,
155 });
156 } catch (_) {
157 res.status(500).json({ error: 'server_error', error_description: 'Could not start device authorization' });
158 }
159 });
160
161 /**
162 * POST /token — poll with device_code, or refresh_token grant.
163 */
164 router.post('/token', async (req, res) => {
165 try {
166 const grantType = String(req.body?.grant_type || '');
167 if (grantType === 'refresh_token') {
168 return await _handleRefresh(req, res);
169 }
170 if (grantType !== DEVICE_GRANT_TYPE) {
171 return res.status(400).json({
172 error: 'unsupported_grant_type',
173 error_description: 'Supported: device_code and refresh_token',
174 });
175 }
176 const deviceCode = String(req.body?.device_code || '');
177 const poll = await pollDeviceCode(deviceCode);
178 if (poll.status === 'authorization_pending') {
179 return res.status(400).json({ error: 'authorization_pending' });
180 }
181 if (poll.status === 'slow_down') {
182 return res.status(400).json({
183 error: 'slow_down',
184 interval: DEVICE_POLL_INTERVAL_SEC + 5,
185 });
186 }
187 if (poll.status === 'access_denied') {
188 return res.status(400).json({ error: 'access_denied' });
189 }
190 if (poll.status === 'expired_token') {
191 return res.status(400).json({ error: 'expired_token' });
192 }
193 if (poll.status !== 'approved' || !poll.entry?.userId) {
194 return res.status(400).json({ error: 'invalid_grant' });
195 }
196
197 const ceiling = grantedScopes(poll.entry.userId);
198 const scopes = applyDeviceScopeCeiling(poll.entry.scopes || [], ceiling);
199 const accessToken = jwt.sign(
200 {
201 sub: poll.entry.userId,
202 client_id: poll.entry.clientId,
203 scopes,
204 type: 'mcp_access',
205 },
206 sessionSecret,
207 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
208 );
209
210 let refreshResult;
211 try {
212 refreshResult = await refreshStore.issue(poll.entry.userId, {
213 tokenTtlMs: DEFAULT_TOKEN_TTL_MS,
214 familyTtlMs: DEFAULT_FAMILY_TTL_MS,
215 meta: {
216 agent: String(poll.entry.clientName || 'cloud-agent').slice(0, 64),
217 client_id: poll.entry.clientId,
218 scopes: scopes.join(' '),
219 },
220 });
221 } catch (_) {
222 return res.status(500).json({ error: 'server_error' });
223 }
224
225 return res.status(200).json({
226 access_token: accessToken,
227 token_type: 'bearer',
228 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
229 refresh_token: refreshResult.token,
230 scope: scopes.join(' '),
231 });
232 } catch (_) {
233 return res.status(500).json({ error: 'server_error' });
234 }
235 });
236
237 async function _handleRefresh(req, res) {
238 const refreshToken = String(req.body?.refresh_token || '');
239 if (!refreshToken) {
240 return res.status(400).json({ error: 'invalid_request' });
241 }
242 let result;
243 try {
244 result = await refreshStore.rotate(refreshToken, {});
245 } catch (_) {
246 return res.status(400).json({ error: 'invalid_grant' });
247 }
248 if (!result.ok) {
249 return res.status(400).json({ error: 'invalid_grant' });
250 }
251 const meta = result.meta || {};
252 const storedScopes = typeof meta.scopes === 'string' && meta.scopes.trim()
253 ? meta.scopes.trim().split(/\s+/).filter(Boolean)
254 : ['vault:read'];
255 const ceiling = grantedScopes(result.sub);
256 const scopes = applyDeviceScopeCeiling(storedScopes, ceiling);
257 const accessToken = jwt.sign(
258 {
259 sub: result.sub,
260 client_id: meta.client_id || 'cloud-agent',
261 scopes,
262 type: 'mcp_access',
263 },
264 sessionSecret,
265 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
266 );
267 return res.status(200).json({
268 access_token: accessToken,
269 token_type: 'bearer',
270 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
271 refresh_token: result.token,
272 scope: scopes.join(' '),
273 });
274 }
275
276 /**
277 * POST /approve — Hub user enters user_code while signed in.
278 */
279 router.post('/approve', async (req, res) => {
280 const sub = getUserId(req);
281 if (!sub) return res.status(401).json({ error: 'unauthorized' });
282 const ip = String(req.ip || req.headers['x-forwarded-for'] || 'unknown').slice(0, 64);
283 if (!_allowApproveAttempt(`${sub}:${ip}`)) {
284 return res.status(429).json({ error: 'rate_limited', error_description: 'Too many approval attempts' });
285 }
286 const userCode = normalizeUserCode(req.body?.user_code || req.body?.userCode || '');
287 if (!userCode) return res.status(400).json({ error: 'invalid_request', error_description: 'user_code required' });
288 const vaultId = req.body?.vault_id || req.body?.vaultId || null;
289 const result = await approveUserCode(userCode, sub, { vaultId });
290 if (!result.ok) {
291 const status = result.reason === 'not_found' || result.reason === 'expired' ? 404 : 400;
292 return res.status(status).json({ error: result.reason });
293 }
294 return res.status(200).json({
295 ok: true,
296 user_code: result.entry.userCode,
297 client_name: result.entry.clientName,
298 scopes: result.entry.scopes,
299 });
300 });
301
302 /**
303 * POST /deny
304 */
305 router.post('/deny', async (req, res) => {
306 const sub = getUserId(req);
307 if (!sub) return res.status(401).json({ error: 'unauthorized' });
308 const userCode = normalizeUserCode(req.body?.user_code || req.body?.userCode || '');
309 if (!userCode) return res.status(400).json({ error: 'invalid_request' });
310 const result = await denyUserCode(userCode, sub);
311 if (!result.ok) {
312 const status = result.reason === 'not_found' ? 404 : 400;
313 return res.status(status).json({ error: result.reason });
314 }
315 return res.status(200).json({ ok: true });
316 });
317
318 /**
319 * GET /pending — list pending codes for Hub (user_code only, no device_code).
320 */
321 router.get('/pending', async (req, res) => {
322 const sub = getUserId(req);
323 if (!sub) return res.status(401).json({ error: 'unauthorized' });
324 const pending = await listPendingForDisplay();
325 return res.status(200).json({ pending });
326 });
327
328 /**
329 * POST /revoke — revoke a refresh token (agent presents it, or Hub with body).
330 */
331 router.post('/revoke', async (req, res) => {
332 const token = String(req.body?.token || req.body?.refresh_token || '');
333 if (!token) return res.status(200).json({ ok: true }); // RFC 7009: always succeed shape
334 try {
335 await refreshStore.revoke(token);
336 } catch (_) {
337 /* best effort */
338 }
339 return res.status(200).json({ ok: true });
340 });
341
342 /**
343 * GET /setup-pack — non-secret instructions for cloud agents (Hermes / Hostinger interim).
344 */
345 router.get('/setup-pack', (_req, res) => {
346 res.json({
347 title: 'Knowtation cloud agent setup pack',
348 secrets: false,
349 mcp_url: 'https://mcp.knowtation.store/mcp',
350 device_authorize_url: `${issuer}/authorize`,
351 device_token_url: `${issuer}/token`,
352 verification_uri: verificationUri,
353 interim_mcp_remote: {
354 summary: 'Desktop OAuth via mcp-remote, copy token folder to agent host, Hermes stdio mcp-remote',
355 docs: 'docs/AGENT-INTEGRATION.md#always-on-cloud-agents',
356 steps: [
357 'On a laptop with a browser: npx -y mcp-remote https://mcp.knowtation.store/mcp',
358 'Complete OAuth; tokens land under ~/.mcp-auth/mcp-remote-*/',
359 'Pack: tar czf mcp-auth-knowtation.tgz -C ~ .mcp-auth/mcp-remote-<version>',
360 'Upload tarball to the agent host HOME (e.g. /data), extract to .mcp-auth/',
361 'Hermes config: command npx, args [-y, mcp-remote, https://mcp.knowtation.store/mcp] — no JWT headers',
362 'Restart Hermes; verify tools list (expect ~23 hosted tools)',
363 ],
364 },
365 do_not: [
366 'Do not put Hub Copy session JWT into always-on server .env',
367 'Do not use api.knowtation.store/mcp or Netlify /mcp for MCP',
368 'Do not paste token JSON into Telegram or chat logs',
369 ],
370 phase_b: {
371 status: 'available',
372 grant_type: DEVICE_GRANT_TYPE,
373 note: 'Prefer device authorization when the agent can poll /token; Hub Settings → Integrations → Connect cloud agent to approve user_code',
374 },
375 });
376 });
377
378 // Opportunistic prune on router creation (fire-and-forget).
379 pruneExpiredDeviceCodes().catch(() => {});
380
381 return { router, issuer, verificationUri };
382 }
383
384 export { DEVICE_GRANT_TYPE, MCP_TOKEN_EXPIRY_SECONDS, DEVICE_CODE_TTL_MS };
File History 1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor 10 days ago