device-oauth-store.mjs
357 lines 10.8 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 10 days ago
1 /**
2 * Durable store for RFC 8628 device authorization pending codes (Phase B).
3 *
4 * Survives gateway restart on the persistent MCP host (same durability pattern as
5 * native-as-store.mjs). Device codes are hashed at rest; user codes are stored
6 * uppercase for lookup. No access/refresh tokens appear in this file.
7 *
8 * Storage path: $KNOWTATION_GATEWAY_DATA_DIR/device_pending_codes.json
9 *
10 * Shape:
11 * {
12 * "devices": {
13 * "<device_code_hash>": {
14 * "userCode": "ABCD-EFGH",
15 * "clientId": "hermes-cloud",
16 * "clientName": "Hermes Agent",
17 * "scopes": ["vault:read", "vault:write"],
18 * "userId": null | "provider:id",
19 * "status": "pending" | "approved" | "denied",
20 * "interval": 5,
21 * "lastPollAt": 0,
22 * "expires": <unix-ms>,
23 * "vaultId": "default" | null
24 * }
25 * }
26 * }
27 */
28
29 import fs from 'fs/promises';
30 import path from 'path';
31 import { fileURLToPath } from 'url';
32 import { createHash, randomBytes, randomInt } from 'node:crypto';
33
34 /** Device authorization TTL (RFC 8628 recommends 15–30 minutes). */
35 export const DEVICE_CODE_TTL_MS = 15 * 60 * 1000;
36
37 /** Default minimum polling interval in seconds. */
38 export const DEVICE_POLL_INTERVAL_SEC = 5;
39
40 /** Alphabet for user_code (no ambiguous 0/O/1/I). */
41 const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
42
43 let _projectRoot;
44 try {
45 const __dirname = path.dirname(fileURLToPath(import.meta.url));
46 _projectRoot = path.resolve(__dirname, '..', '..');
47 } catch (_) {
48 _projectRoot = process.cwd();
49 }
50
51 /**
52 * @returns {string}
53 */
54 function _storePath() {
55 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(_projectRoot, 'data');
56 return path.join(dataDir, 'device_pending_codes.json');
57 }
58
59 /**
60 * Hash a device_code for at-rest storage (never store the raw secret).
61 * @param {string} deviceCode
62 * @returns {string}
63 */
64 export function hashDeviceCode(deviceCode) {
65 return createHash('sha256').update(String(deviceCode), 'utf8').digest('hex');
66 }
67
68 /**
69 * Generate a human-typable user_code (XXXX-XXXX).
70 * @returns {string}
71 */
72 export function generateUserCode() {
73 let out = '';
74 for (let i = 0; i < 8; i++) {
75 out += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
76 if (i === 3) out += '-';
77 }
78 return out;
79 }
80
81 /**
82 * Generate an opaque device_code (URL-safe).
83 * @returns {string}
84 */
85 export function generateDeviceCode() {
86 return randomBytes(32).toString('base64url');
87 }
88
89 /**
90 * Normalize user codes for comparison (strip spaces/hyphens, uppercase).
91 * @param {string} code
92 * @returns {string}
93 */
94 export function normalizeUserCode(code) {
95 return String(code || '')
96 .toUpperCase()
97 .replace(/[^A-Z0-9]/g, '')
98 .replace(/^([A-Z0-9]{4})([A-Z0-9]{4})$/, '$1-$2');
99 }
100
101 /**
102 * @param {Record<string, object>} devices
103 * @param {number} [now]
104 * @returns {Record<string, object>}
105 */
106 function _pruneExpired(devices, now = Date.now()) {
107 const out = {};
108 for (const [hash, entry] of Object.entries(devices)) {
109 if (entry && typeof entry === 'object' && typeof entry.expires === 'number' && entry.expires > now) {
110 out[hash] = entry;
111 }
112 }
113 return out;
114 }
115
116 /**
117 * @param {unknown} raw
118 * @returns {Record<string, object>}
119 */
120 function _normalizeDevices(raw) {
121 if (!raw || typeof raw !== 'object' || !raw.devices || typeof raw.devices !== 'object') return {};
122 const out = {};
123 for (const [hash, entry] of Object.entries(raw.devices)) {
124 if (
125 typeof hash === 'string' &&
126 entry &&
127 typeof entry === 'object' &&
128 typeof entry.userCode === 'string' &&
129 typeof entry.clientId === 'string' &&
130 typeof entry.expires === 'number' &&
131 typeof entry.status === 'string'
132 ) {
133 out[hash] = entry;
134 }
135 }
136 return out;
137 }
138
139 /**
140 * @returns {Promise<Record<string, object>>}
141 */
142 async function _readDevices() {
143 try {
144 const raw = await fs.readFile(_storePath(), 'utf8');
145 return _normalizeDevices(JSON.parse(raw));
146 } catch (e) {
147 if (e && e.code === 'ENOENT') return {};
148 return {};
149 }
150 }
151
152 /**
153 * @param {Record<string, object>} devices
154 * @returns {Promise<void>}
155 */
156 async function _writeDevices(devices) {
157 const filePath = _storePath();
158 await fs.mkdir(path.dirname(filePath), { recursive: true });
159 const tmpPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
160 await fs.writeFile(
161 tmpPath,
162 JSON.stringify({ devices: devices || {} }, null, 2),
163 { encoding: 'utf8', mode: 0o600 }
164 );
165 await fs.rename(tmpPath, filePath);
166 }
167
168 /**
169 * Create a pending device authorization.
170 *
171 * @param {{
172 * clientId?: string,
173 * clientName?: string,
174 * scopes?: string[],
175 * vaultId?: string | null,
176 * intervalSec?: number,
177 * }} [opts]
178 * @returns {Promise<{ deviceCode: string, userCode: string, expiresIn: number, interval: number }>}
179 */
180 export async function createDeviceAuthorization(opts = {}) {
181 const deviceCode = generateDeviceCode();
182 const userCode = generateUserCode();
183 const interval = Math.max(1, Number(opts.intervalSec) || DEVICE_POLL_INTERVAL_SEC);
184 const expires = Date.now() + DEVICE_CODE_TTL_MS;
185 const devices = _pruneExpired(await _readDevices());
186
187 // Ensure unique user_code among live entries.
188 let finalUser = userCode;
189 const used = new Set(Object.values(devices).map((d) => normalizeUserCode(d.userCode)));
190 let attempts = 0;
191 while (used.has(normalizeUserCode(finalUser)) && attempts < 20) {
192 finalUser = generateUserCode();
193 attempts += 1;
194 }
195
196 devices[hashDeviceCode(deviceCode)] = {
197 userCode: finalUser,
198 clientId: String(opts.clientId || 'cloud-agent').slice(0, 128),
199 clientName: String(opts.clientName || 'Cloud agent').slice(0, 128),
200 scopes: Array.isArray(opts.scopes) && opts.scopes.length
201 ? opts.scopes.filter((s) => typeof s === 'string').slice(0, 16)
202 : ['vault:read', 'vault:write'],
203 userId: null,
204 status: 'pending',
205 interval,
206 lastPollAt: 0,
207 expires,
208 vaultId: opts.vaultId ? String(opts.vaultId).slice(0, 128) : null,
209 };
210 await _writeDevices(devices);
211 return {
212 deviceCode,
213 userCode: finalUser,
214 expiresIn: Math.floor(DEVICE_CODE_TTL_MS / 1000),
215 interval,
216 };
217 }
218
219 /**
220 * Look up a pending entry by user_code (for Hub approval UI).
221 * @param {string} userCode
222 * @returns {Promise<{ hash: string, entry: object } | null>}
223 */
224 export async function findByUserCode(userCode) {
225 const normalized = normalizeUserCode(userCode);
226 if (!normalized) return null;
227 const devices = await _readDevices();
228 const now = Date.now();
229 for (const [hash, entry] of Object.entries(devices)) {
230 if (entry.expires <= now) continue;
231 if (normalizeUserCode(entry.userCode) === normalized) {
232 return { hash, entry };
233 }
234 }
235 return null;
236 }
237
238 /**
239 * Approve a pending device flow for an authenticated Hub user.
240 * @param {string} userCode
241 * @param {string} userId
242 * @param {{ vaultId?: string | null }} [opts]
243 * @returns {Promise<{ ok: true, entry: object } | { ok: false, reason: string }>}
244 */
245 export async function approveUserCode(userCode, userId, opts = {}) {
246 if (!userId || typeof userId !== 'string') return { ok: false, reason: 'unauthorized' };
247 const found = await findByUserCode(userCode);
248 if (!found) return { ok: false, reason: 'not_found' };
249 if (found.entry.status !== 'pending') return { ok: false, reason: 'not_pending' };
250 if (Date.now() > found.entry.expires) return { ok: false, reason: 'expired' };
251
252 const devices = await _readDevices();
253 const entry = devices[found.hash];
254 if (!entry || entry.status !== 'pending') return { ok: false, reason: 'not_pending' };
255 devices[found.hash] = {
256 ...entry,
257 userId,
258 status: 'approved',
259 vaultId: opts.vaultId != null ? String(opts.vaultId).slice(0, 128) : entry.vaultId,
260 };
261 await _writeDevices(devices);
262 return { ok: true, entry: devices[found.hash] };
263 }
264
265 /**
266 * Deny a pending device flow.
267 * @param {string} userCode
268 * @param {string} userId
269 * @returns {Promise<{ ok: true } | { ok: false, reason: string }>}
270 */
271 export async function denyUserCode(userCode, userId) {
272 if (!userId || typeof userId !== 'string') return { ok: false, reason: 'unauthorized' };
273 const found = await findByUserCode(userCode);
274 if (!found) return { ok: false, reason: 'not_found' };
275 if (found.entry.status !== 'pending') return { ok: false, reason: 'not_pending' };
276
277 const devices = await _readDevices();
278 const entry = devices[found.hash];
279 if (!entry || entry.status !== 'pending') return { ok: false, reason: 'not_pending' };
280 devices[found.hash] = { ...entry, status: 'denied', userId };
281 await _writeDevices(devices);
282 return { ok: true };
283 }
284
285 /**
286 * Poll outcome for a device_code (RFC 8628 token endpoint).
287 * Consumes the entry on success (single-use).
288 *
289 * @param {string} deviceCode
290 * @returns {Promise<
291 * | { status: 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' | 'invalid_grant' }
292 * | { status: 'approved'; entry: object }
293 * >}
294 */
295 export async function pollDeviceCode(deviceCode) {
296 if (!deviceCode || typeof deviceCode !== 'string') return { status: 'invalid_grant' };
297 const hash = hashDeviceCode(deviceCode);
298 const devices = await _readDevices();
299 const entry = devices[hash];
300 const now = Date.now();
301 if (!entry) return { status: 'invalid_grant' };
302 if (now > entry.expires) {
303 delete devices[hash];
304 await _writeDevices(devices);
305 return { status: 'expired_token' };
306 }
307 if (entry.status === 'denied') {
308 delete devices[hash];
309 await _writeDevices(devices);
310 return { status: 'access_denied' };
311 }
312 if (entry.status === 'pending') {
313 const intervalMs = (Number(entry.interval) || DEVICE_POLL_INTERVAL_SEC) * 1000;
314 if (entry.lastPollAt && now - entry.lastPollAt < intervalMs) {
315 devices[hash] = { ...entry, lastPollAt: now };
316 await _writeDevices(devices);
317 return { status: 'slow_down' };
318 }
319 devices[hash] = { ...entry, lastPollAt: now };
320 await _writeDevices(devices);
321 return { status: 'authorization_pending' };
322 }
323 if (entry.status === 'approved' && entry.userId) {
324 delete devices[hash];
325 await _writeDevices(devices);
326 return { status: 'approved', entry };
327 }
328 return { status: 'invalid_grant' };
329 }
330
331 /**
332 * List pending (not yet approved/denied) device flows for Hub UI (no secrets).
333 * @returns {Promise<object[]>}
334 */
335 export async function listPendingForDisplay() {
336 const devices = _pruneExpired(await _readDevices());
337 return Object.values(devices)
338 .filter((d) => d.status === 'pending')
339 .map((d) => ({
340 userCode: d.userCode,
341 clientName: d.clientName,
342 clientId: d.clientId,
343 scopes: d.scopes,
344 expires: d.expires,
345 }));
346 }
347
348 /**
349 * @returns {Promise<{ removed: number }>}
350 */
351 export async function pruneExpiredDeviceCodes() {
352 const devices = await _readDevices();
353 const pruned = _pruneExpired(devices);
354 const removed = Object.keys(devices).length - Object.keys(pruned).length;
355 if (removed > 0) await _writeDevices(pruned);
356 return { removed };
357 }
File History 1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor 10 days ago