native-as-store.mjs
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