device-oauth-provider.mjs
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