native-as-store.mjs
216 lines 7.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Durable store for native OAuth client pending authorization codes (C4).
3 *
4 * docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §6 change C4 requires that pending
5 * authorization codes for the native client path survive process restart. This module
6 * persists code records as an atomic-rename JSON file on the persistent gateway host
7 * (knowtation-mcp-gateway, i-025679d93cf47aeab), using the same durability pattern as
8 * hub/gateway/refresh-token-store.mjs.
9 *
10 * Records are keyed by the authorization code (a cryptographically random UUID v4).
11 * Expired records are pruned on every write so the file never grows without bound.
12 *
13 * Storage path: $KNOWTATION_GATEWAY_DATA_DIR/native_pending_codes.json (dev/test)
14 * data/native_pending_codes.json (default)
15 *
16 * Storage shape:
17 * {
18 * "codes": {
19 * "<uuid>": {
20 * "clientId": "...",
21 * "codeChallenge": "<sha256-base64url>",
22 * "redirectUri": "http://127.0.0.1:<port>/...",
23 * "state": "<opaque>|null",
24 * "scopes": ["vault:read", ...],
25 * "userId": "<provider:id>|null",
26 * "expires": <unix-ms>
27 * }
28 * }
29 * }
30 *
31 * Security properties:
32 * - Codes are opaque 128-bit random values (UUID v4); not predictable.
33 * - The PKCE code_challenge (hash) is stored; the code_verifier is NEVER stored.
34 * - No access token, refresh token, or session secret appears in this file.
35 * - File mode 0o600 (owner read/write only, not group or world).
36 * - Atomic write via temp-file + rename: a crash mid-write cannot corrupt the store.
37 * - Corrupt or unreadable file → fail-closed (empty store); users re-authenticate.
38 */
39
40 import fs from 'fs/promises';
41 import path from 'path';
42 import { fileURLToPath } from 'url';
43 import { randomBytes } from 'node:crypto';
44
45 /** Lifetime of a pending authorization code (must match the client's expectations). */
46 export const NATIVE_AUTH_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
47
48 let _projectRoot;
49 try {
50 const __dirname = path.dirname(fileURLToPath(import.meta.url));
51 _projectRoot = path.resolve(__dirname, '..', '..');
52 } catch (_) {
53 _projectRoot = process.cwd();
54 }
55
56 /**
57 * Resolve the absolute path to the code store file.
58 * KNOWTATION_GATEWAY_DATA_DIR allows tests to redirect to a temp directory.
59 * @returns {string}
60 */
61 function _nativeCodePath() {
62 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(_projectRoot, 'data');
63 return path.join(dataDir, 'native_pending_codes.json');
64 }
65
66 /**
67 * Return a new object containing only entries whose `expires` is in the future.
68 * @param {Record<string, object>} codes
69 * @param {number} [now]
70 * @returns {Record<string, object>}
71 */
72 function _pruneExpired(codes, now = Date.now()) {
73 const out = {};
74 for (const [code, entry] of Object.entries(codes)) {
75 if (entry && typeof entry === 'object' && typeof entry.expires === 'number' && entry.expires > now) {
76 out[code] = entry;
77 }
78 }
79 return out;
80 }
81
82 /**
83 * Coerce arbitrary persisted JSON to a safe codes map, dropping malformed entries.
84 * A damaged file therefore degrades to "no pending codes" (fail-closed: user re-auths)
85 * rather than throwing on every authorization attempt.
86 * @param {unknown} raw
87 * @returns {Record<string, object>}
88 */
89 function _normalizeCodes(raw) {
90 if (!raw || typeof raw !== 'object' || !raw.codes || typeof raw.codes !== 'object') return {};
91 const out = {};
92 for (const [code, entry] of Object.entries(raw.codes)) {
93 if (
94 typeof code === 'string' &&
95 entry &&
96 typeof entry === 'object' &&
97 typeof entry.clientId === 'string' &&
98 typeof entry.codeChallenge === 'string' &&
99 typeof entry.redirectUri === 'string' &&
100 typeof entry.expires === 'number'
101 ) {
102 out[code] = entry;
103 }
104 }
105 return out;
106 }
107
108 /**
109 * Load the current code records from disk.
110 * @returns {Promise<Record<string, object>>}
111 */
112 async function _readCodes() {
113 try {
114 const raw = await fs.readFile(_nativeCodePath(), 'utf8');
115 return _normalizeCodes(JSON.parse(raw));
116 } catch (e) {
117 if (e && e.code === 'ENOENT') return {};
118 // Unreadable/corrupt file: fail-closed rather than crashing.
119 return {};
120 }
121 }
122
123 /**
124 * Atomically persist the code records to disk.
125 * Uses a temp-file + rename strategy so a crash mid-write cannot corrupt the store.
126 * @param {Record<string, object>} codes
127 * @returns {Promise<void>}
128 */
129 async function _writeCodes(codes) {
130 const filePath = _nativeCodePath();
131 await fs.mkdir(path.dirname(filePath), { recursive: true });
132 // Use a random suffix to prevent temp-file collisions under concurrent writes
133 // (two callers in the same millisecond on the same PID would otherwise share a name).
134 const tmpPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
135 await fs.writeFile(
136 tmpPath,
137 JSON.stringify({ codes: codes || {} }, null, 2),
138 { encoding: 'utf8', mode: 0o600 }
139 );
140 await fs.rename(tmpPath, filePath);
141 }
142
143 /**
144 * Store a new pending authorization code.
145 * Existing expired entries are pruned before writing.
146 *
147 * @param {string} code - authorization code (UUID v4)
148 * @param {{
149 * clientId: string,
150 * codeChallenge: string,
151 * redirectUri: string,
152 * state?: string | null,
153 * scopes?: string[],
154 * }} entry
155 * @returns {Promise<void>}
156 */
157 export async function savePendingCode(code, entry) {
158 const codes = await _readCodes();
159 const pruned = _pruneExpired(codes);
160 pruned[code] = {
161 clientId: entry.clientId,
162 codeChallenge: entry.codeChallenge,
163 redirectUri: entry.redirectUri,
164 state: entry.state || null,
165 scopes: Array.isArray(entry.scopes) ? entry.scopes : [],
166 userId: null,
167 expires: Date.now() + NATIVE_AUTH_CODE_TTL_MS,
168 };
169 await _writeCodes(pruned);
170 }
171
172 /**
173 * Bind an authenticated userId to a pending code after the IDP callback.
174 * Fails (returns false) if the code is unknown or expired.
175 *
176 * @param {string} code
177 * @param {string} userId - authenticated user sub ("provider:id")
178 * @returns {Promise<boolean>} true if the code was found and updated
179 */
180 export async function bindUserToCode(code, userId) {
181 const codes = await _readCodes();
182 const entry = codes[code];
183 if (!entry || Date.now() > entry.expires) return false;
184 codes[code] = { ...entry, userId };
185 await _writeCodes(codes);
186 return true;
187 }
188
189 /**
190 * Atomically consume (read-then-delete) a pending code.
191 * Returns the entry, or null if the code is unknown or expired.
192 * The record is deleted before returning so it cannot be replayed (single-use guarantee).
193 *
194 * @param {string} code
195 * @returns {Promise<object|null>}
196 */
197 export async function consumePendingCode(code) {
198 const codes = await _readCodes();
199 const entry = codes[code];
200 if (!entry || Date.now() > entry.expires) return null;
201 delete codes[code];
202 await _writeCodes(codes);
203 return entry;
204 }
205
206 /**
207 * Opportunistically prune expired codes. Safe to call at startup or any time.
208 * @returns {Promise<{ removed: number }>}
209 */
210 export async function pruneExpiredCodes() {
211 const codes = await _readCodes();
212 const pruned = _pruneExpired(codes);
213 const removed = Object.keys(codes).length - Object.keys(pruned).length;
214 if (removed > 0) await _writeCodes(pruned);
215 return { removed };
216 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago